Merge remote-tracking branch 'origin/main' into feat/996-inspect-fields

# Conflicts:
#	docs/roadmap.md
This commit is contained in:
Mert Dede
2026-10-30 07:07:54 +03:00
182 changed files with 5064 additions and 578 deletions
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: figcraft
app-dir: FigCraft
+5
View File
@@ -131,3 +131,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
+5
View File
@@ -132,3 +132,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+2
View File
@@ -1848,6 +1848,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.21",
"unicode-segmentation",
]
[[package]]
@@ -2100,6 +2101,7 @@ dependencies = [
"egui",
"figcraft-collab-net",
"figcraft-engine",
"figcraft-format",
"figcraft-text",
"figcraft-ui-egui",
"js-sys",
+21 -1
View File
File diff suppressed because one or more lines are too long
+4 -5
View File
@@ -29,7 +29,7 @@ Usage:
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
figcraft-server files remove FILE remove a file and its links for good
figcraft-server links list FILE a file's share links
figcraft-server links create FILE --access edit|view [--label TEXT]
figcraft-server links create FILE --access edit|comment|view [--label TEXT]
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
figcraft-server plugins [list] the plugin directory: every plugin and its versions
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
@@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
"--listen" => config.listen = value("--listen")?,
"--data" => config.data = PathBuf::from(value("--data")?),
"--public-url" => config.public_url = Some(value("--public-url")?),
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?),
"--label" => label = value("--label")?,
"--rotate" => rotate = true,
"--sort" => {
@@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
["links", "list", file] => Command::Links { file: file.to_string() },
["links", "create", file] => {
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label }
}
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
["plugins"] | ["plugins", "list"] => Command::Plugins,
@@ -361,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
};
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
let mut out = String::new();
let what = if l.link.access == Access::View { "view" } else { "edit" };
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file);
let _ = writeln!(out, " {}", invite.link());
let _ = writeln!(out, " {}", invite.url());
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
+5 -4
View File
@@ -57,7 +57,7 @@ fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &
assert!(!links.contains(secret), "{links}");
}
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
assert_eq!(links.matches("\"code\"").count(), 2);
assert_eq!(links.matches("\"code\"").count(), 3);
}
#[test]
@@ -76,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() {
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
let p = a.live().published.clone().unwrap();
assert!(p.owner.is_some(), "the owner token is kept as before");
// Others get links of their own (the owner keeps the file's first ones): four in all.
// Others get links of their own (edit, view and comment; the owner keeps the file's first
// ones): five in all.
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
let ids = |dir: &std::path::Path| -> Vec<String> {
@@ -84,7 +85,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
};
let before_stop = ids(dir.path());
assert_eq!(before_stop.len(), 4);
assert_eq!(before_stop.len(), 5);
// On disk: hashes only, of the codes as of the links' tokens.
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
// Someone types the edit code, the way people type.
@@ -142,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
assert_eq!(ids(dir.path()).len(), 4);
assert_eq!(ids(dir.path()).len(), 5);
ex(&mut b, "collab.join", json!({"code": edit2}));
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
// The old code is still gone.
@@ -0,0 +1,145 @@
//! Comment links end to end over loopback: sharing online makes a comment link and code beside the
//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses
//! their other edits, and the server refuses them too for a client that skips the check; a client
//! that can't read comment links gets in as a viewer, and links listed to it read as view links.
use std::time::Duration;
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json};
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
use figcraft_doc::{Comment, Message};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
/// The welcome `raw` gets after saying hello with `client_version` through `link`.
fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client {
let invite = Invite::parse(link).unwrap();
let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") };
h.client_version = client_version.into();
raw.send(&ClientMsg::Hello(h));
loop {
if let w @ ServerMsg::Welcome { .. } = raw.next() {
return Client::join(w).unwrap();
}
}
}
/// What the server says to the batch `raw` sends next.
fn answer(raw: &mut Raw) -> ServerMsg {
loop {
if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() {
return m;
}
}
}
#[test]
fn a_comment_link_comments_and_the_server_refuses_its_edits() {
let dir = TempDir::new("comment-link");
let mut server = crate::start(&config(dir.path())).unwrap();
let addr = server.addr();
let mut a = session("Ada");
ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80}));
let p = publish(&mut a, dir.path(), addr);
let file = Invite::parse(&p.edit).unwrap().file;
let endpoint = Endpoint::plain(addr.to_string());
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() };
assert_eq!(made.link.access, Access::Comment);
let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link();
// Cy joins with it and comments; Ada sees the thread.
let mut c = join(&link, "Cy");
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true)));
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"}));
pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1));
ex(&mut c, "comment.resolve", json!({"id": thread}));
pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved));
// Cy's app refuses anything else before it is sent.
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
assert!(e.contains("Can comment"), "{e}");
// A client that skips the app's check: its comment goes through, its edit is refused.
let mut raw = Raw::connect(addr);
let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version());
assert_eq!(client.access(), Access::Comment);
let mut d = client.document().clone();
let page = d.first_page().unwrap();
let id = d.new_comment_id();
d.comments.push(Comment {
id,
page,
x: 1.0,
y: 1.0,
anchor: None,
first: Message::new("Raw", "Hi", 0),
resolved: false,
replies: vec![],
read_by: None,
});
raw.send(&client.edit(&d).unwrap().unwrap());
let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") };
client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap();
let mut d = client.document().clone();
d.node_mut(page).unwrap().name = "Sneaky".into();
raw.send(&client.edit(&d).unwrap().unwrap());
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
// So is a forged batch that hides an edit among comment changes.
let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() };
let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() };
raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] });
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
pump(&mut [&mut a], "the raw comment, nothing else", |s| {
let d = s[0].doc().unwrap();
d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky"
});
// A client from before comment links gets in as a viewer.
let mut old = Raw::connect(addr);
assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View);
// And lists the link as a view link.
let mut m = Raw::connect(addr);
m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() }));
assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready));
m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() }));
let ServerMsg::Admin(r) = m.next() else { panic!("no listing") };
let AdminReply::Links(links) = *r else { panic!("{r:?}") };
assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View));
let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() };
assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it");
server.stop();
}
#[test]
fn sharing_online_offers_a_comment_code() {
let dir = TempDir::new("comment-code");
let mut server = crate::start(&config(dir.path())).unwrap();
let url = format!("ws://{}/?key={}", server.addr(), key(dir.path()));
let mut a = session("Ada");
ex(&mut a, "collab.share", json!({"server": url}));
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true);
let shared = a.live_summary()["share"].clone();
let comment = shared["commentCode"].as_str().unwrap().to_string();
assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}");
assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str());
// Someone types it: they can comment, not edit.
let mut c = session("Cy");
ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())}));
ex(&mut c, "collab.join", json!({"code": comment}));
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
assert_eq!(c.live_summary()["access"], "comment");
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap();
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
// Stopping revokes it with the others: Cy is out.
ex(&mut a, "collab.stop", json!({}));
pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty());
server.stop();
}
+25
View File
@@ -78,6 +78,7 @@ fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
#[cfg(not(unix))]
let _ = private;
let mut f = o.open(&tmp)?;
counted(true);
f.write_all(bytes)?;
f.sync_all()?;
}
@@ -101,6 +102,7 @@ pub fn sync_dir(dir: Option<&Path>) {
/// Read a file of at most `max` bytes (a longer one is refused without being read).
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
counted(false);
let len = f.metadata().map_err(|e| e.to_string())?.len();
if len > max {
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
@@ -110,6 +112,29 @@ pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
Ok(bytes)
}
#[cfg(test)]
thread_local! {
/// Files read and written on this thread (tests: what an action costs, #1074).
static IO: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
}
/// Count a file read or write (a log append counts as a write).
pub(crate) fn counted(write: bool) {
#[cfg(test)]
IO.with(|io| {
let (r, w) = io.get();
io.set(if write { (r, w + 1) } else { (r + 1, w) });
});
#[cfg(not(test))]
let _ = write;
}
/// The files read and written on this thread since the last call: `(reads, writes)`.
#[cfg(test)]
pub(crate) fn take_io() -> (u32, u32) {
IO.with(|io| io.replace((0, 0)))
}
fn name(path: &Path) -> String {
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
}
+162
View File
@@ -0,0 +1,162 @@
//! Joining and leaving a file nobody is editing costs no disk I/O while it is kept loaded
//! (#1074), and what changes is still saved as before.
use std::fs;
use figcraft_collab::message::{Access, ClientMsg, Hello};
use figcraft_collab::{Client, NoRecompute, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::Document;
use crate::disk::{self, META};
use crate::store::{FILES, Store, StoreConfig};
use crate::test_util::{TempDir, config};
fn hello() -> Hello {
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
h
}
/// Let `hello` into `server` and hand its messages to `client` (a new one when `None`); the
/// client and its id there.
fn enter(server: &mut Server, client: Option<Client>, hello: Hello) -> (Client, u32) {
let (id, out) = server.join_as(hello, Access::Edit).unwrap();
let mut msgs = out.into_iter().filter_map(|o| match o {
Outgoing::To(to, m) if to == id => Some(m),
_ => None,
});
let client = match client {
Some(mut c) => {
for m in msgs {
c.receive(m, &NoRecompute).unwrap();
}
c
}
None => Client::join(msgs.next().unwrap()).unwrap(),
};
(client, id)
}
/// A file opened, joined and left once: the store, the file's id and the client, ready to resume.
fn published(dir: &TempDir) -> (Store, String, Client, StoreConfig) {
let c = StoreConfig { compact_after: 1000, ..config(dir.path()).store() };
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let d = Document::new("Idle");
let (info, _, _) = store.publish("Idle", &Replica::new(d).snapshot().unwrap()).unwrap();
let id = info.id;
let mut server = store.open(&id).unwrap();
let (client, cid) = enter(&mut server, None, hello());
server.leave(cid);
store.changed(&id, &mut server);
store.flush();
assert!(store.idle(&id, &mut server));
(store, id, client, c)
}
/// One rejoin and leave, as the hub drives it; `edit` renames the page on the way.
fn cycle(store: &mut Store, id: &str, client: Client, edit: Option<&str>) -> Client {
let mut server = store.open(id).unwrap();
let (mut client, cid) = {
let mut c = client;
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
enter(&mut server, Some(c), h)
};
if let Some(name) = edit {
let mut d = client.document().clone();
let page = d.first_page().unwrap();
d.node_mut(page).unwrap().name = name.into();
let m = client.edit(&d).unwrap().unwrap();
for o in server.receive(cid, m) {
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
client.receive(m, &NoRecompute).unwrap();
}
}
}
server.leave(cid);
store.changed(id, &mut server);
store.flush();
assert!(store.idle(id, &mut server));
client
}
fn page_name(server: &Server) -> String {
let d = server.replica().document();
d.first_page().and_then(|p| d.node(p)).map(|n| n.name.clone()).unwrap_or_default()
}
#[test]
fn rejoining_an_unchanged_file_reads_and_writes_nothing() {
let dir = TempDir::new("idle-io");
let (mut store, id, mut client, _) = published(&dir);
let log = dir.path().join(FILES).join(&id).join(crate::disk::LOG);
let log_len = fs::metadata(&log).unwrap().len();
disk::take_io();
for _ in 0..20 {
client = cycle(&mut store, &id, client, None);
}
assert_eq!(disk::take_io(), (0, 0), "20 join/leave cycles: no file read or written");
assert_eq!(fs::metadata(&log).unwrap().len(), log_len);
assert_eq!(store.compactions(), 0);
}
#[test]
fn edits_while_kept_loaded_are_still_saved() {
let dir = TempDir::new("idle-edits");
let (mut store, id, mut client, c) = published(&dir);
for i in 0..3 {
client = cycle(&mut store, &id, client, Some(&format!("Page {i}")));
}
// A cycle with an edit compacts it on leaving, as before; the next one does nothing again.
assert!(store.compactions() > 0);
disk::take_io();
let _ = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 0));
// Saved: another store (a restart) reads it back.
drop(store);
let mut again = Store::open(dir.path(), c).unwrap();
disk::take_io();
let server = again.open(&id).unwrap();
assert_eq!(page_name(&server), "Page 2");
assert_eq!(disk::take_io().0, 2, "one load: the snapshot and the log");
}
#[test]
fn the_file_kept_loaded_longest_lets_go_first() {
let dir = TempDir::new("idle-many");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let ids: Vec<String> =
(0..17).map(|i| store.publish(&format!("F{i}"), &Replica::new(Document::new("F")).snapshot().unwrap()).unwrap().0.id).collect();
for id in &ids {
let mut server = store.open(id).unwrap();
assert!(store.idle(id, &mut server));
}
disk::take_io();
let _ = store.open(&ids[16]).unwrap();
assert_eq!(disk::take_io(), (0, 0), "the latest is still loaded");
let _ = store.open(&ids[0]).unwrap();
assert_eq!(disk::take_io(), (2, 0), "past 16, the first let go: read again");
}
#[test]
fn the_open_time_is_written_hourly_and_when_the_server_stops() {
let dir = TempDir::new("idle-opened");
let (mut store, id, client, _) = published(&dir);
let opened = || -> u64 {
let meta: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join(FILES).join(&id).join(META)).unwrap()).unwrap();
meta["opened"].as_u64().unwrap()
};
// Last written half an hour ago: a join doesn't write it yet; stopping does.
store.age(&id, 1800);
let before = opened();
disk::take_io();
let client = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 0));
assert_eq!(opened(), before);
store.stop(Vec::new());
assert!(opened() >= before + 1800);
// Written over an hour ago: the next join writes it.
store.age(&id, 4000);
disk::take_io();
let _ = cycle(&mut store, &id, client, None);
assert_eq!(disk::take_io(), (0, 1));
}
+4
View File
@@ -95,10 +95,14 @@ mod cli_tests;
#[cfg(test)]
mod codes_tests;
#[cfg(test)]
mod comment_link_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod idle_io_tests;
#[cfg(test)]
mod library_tests;
#[cfg(test)]
mod owned_tests;
+1 -1
View File
@@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() {
assert_eq!(list.len(), 5, "one answer per pair, in order");
let first = list[0].as_ref().expect("its own token");
assert_eq!(first.name, "Untitled");
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
assert_eq!(first.links.len(), 5, "the owner's two links and the three made for others");
assert!(first.expires.is_some());
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
+24 -1
View File
@@ -241,8 +241,30 @@ struct Entry {
bytes: u64,
/// When it was last compacted to make room (seconds since 1970).
forced: u64,
/// Closed lately: its session kept loaded ([`PARK_FOR`]).
parked: Option<Parked>,
/// The `opened` time `meta.json` holds ([`TOUCH_EVERY`]).
opened_written: u64,
}
/// A file everyone left lately, kept loaded so a join soon after neither reads it nor rewrites it
/// (#1074): its session and its log (synced, its state compacted as on any close).
struct Parked {
server: Server,
log: Log,
at: Instant,
}
/// How long a file everyone left stays loaded.
const PARK_FOR: std::time::Duration = std::time::Duration::from_secs(60);
/// Most files kept loaded after everyone left (the one parked longest goes first).
const MAX_PARKED: usize = 16;
/// Most bytes (on disk) of the files kept loaded after everyone left.
const MAX_PARKED_BYTES: u64 = 256 << 20;
/// How often, at most, a file's `opened` time is written while it keeps being opened (seconds):
/// expiry counts in days, so it needn't be written at every join and leave.
const TOUCH_EVERY: u64 = 3600;
/// A library's usage reports in memory, and whether (and when) they were written.
struct UsageCache {
list: Vec<FileUsage>,
@@ -494,7 +516,8 @@ impl Store {
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
}
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
let opened_written = meta.opened;
Ok(Entry { meta, links, log: None, bytes, forced: 0, parked: None, opened_written })
}
/// The server key (for managing files).
+14 -3
View File
@@ -62,12 +62,14 @@ impl Backend for Store {
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let mut synced = false;
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
synced = flush_log(file, log).is_ok();
!synced || (log.dirty && server.seq() > snapshot_seq)
})
});
if compact {
@@ -75,8 +77,12 @@ impl Backend for Store {
}
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
let log = self.files.get_mut(file).and_then(|e| e.log.take());
// Kept loaded a while, when its log was sound: a join soon after reads and rewrites
// nothing (#1074). The hub drops its room as this returns: the session moves here.
if let Some(log) = log.filter(|l| synced && l.pending.is_empty()) {
let left = std::mem::replace(server, Server::new(figcraft_collab::Document::new(""), "", self.cfg.core));
self.park(file, left, log);
}
// Everyone just left: idle time counts from now.
self.touch(file);
@@ -86,6 +92,11 @@ impl Backend for Store {
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
self.write_usage(true);
self.flush_all();
// The open times not written yet (they are written at most hourly while files are used).
let unwritten: Vec<String> = self.files.iter().filter(|(_, e)| e.meta.opened > e.opened_written).map(|(id, _)| id.clone()).collect();
for file in unwritten {
self.write_opened(&file);
}
for (file, server) in open {
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
self.compact(file, server);
+65 -7
View File
@@ -1,7 +1,8 @@
use super::{
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
MAX_BLOCKED, MAX_OWNED, MAX_PARKED, MAX_PARKED_BYTES, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, PARK_FOR, Parked, REPORTS, Replica,
ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, Store, TOUCH_EVERY, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now,
same_secret, token, write_json,
};
impl Store {
@@ -133,7 +134,9 @@ impl Store {
let _ = fs::remove_dir_all(&dir);
return Err(format!("couldn't keep the file: {e}"));
}
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
let opened_written = meta.opened;
let (links, bytes_kept) = (vec![edit.clone(), view.clone()], (bytes.len() + LOG_HEADER) as u64);
let entry = Entry { meta, links, log: None, bytes: bytes_kept, forced: 0, parked: None, opened_written };
self.files.insert(id.clone(), entry);
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
match ip {
@@ -199,6 +202,12 @@ impl Store {
pub fn sweep(&mut self) -> Vec<String> {
let t = now();
self.next_sweep = t.saturating_add(SWEEP_EVERY);
// Files kept loaded after everyone left let go once their time is up.
for e in self.files.values_mut() {
if e.parked.as_ref().is_some_and(|p| p.at.elapsed() > PARK_FOR) {
e.parked = None;
}
}
let due: Vec<String> =
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
for id in &due {
@@ -217,6 +226,7 @@ impl Store {
if let Some(e) = self.files.get_mut(file) {
let m = &mut e.meta;
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
e.opened_written = m.opened;
let _ = write_json(&path, m);
}
}
@@ -232,17 +242,65 @@ impl Store {
}
}
/// Note that a file was opened now (it is used: it doesn't expire).
/// Note that a file was opened now (it is used: it doesn't expire). Expiry counts in days, so
/// `meta.json` gets the time at most every [`TOUCH_EVERY`] seconds (and when the server stops),
/// not at every join and leave (#1074).
pub(super) fn touch(&mut self, file: &str) {
let t = now();
let due = self.files.get_mut(file).is_some_and(|e| {
e.meta.opened = t;
t >= e.opened_written.saturating_add(TOUCH_EVERY)
});
if due {
self.write_opened(file);
}
}
/// Write `file`'s `opened` time to its `meta.json`.
pub(super) fn write_opened(&mut self, file: &str) {
let path = self.dir(file).join(META);
if let Some(e) = self.files.get_mut(file) {
e.meta.opened = now();
if let Err(err) = write_json(&path, &e.meta) {
log::warn!("file {file}: couldn't update {META}: {err}");
match write_json(&path, &e.meta) {
Ok(()) => e.opened_written = e.meta.opened,
Err(err) => log::warn!("file {file}: couldn't update {META}: {err}"),
}
}
}
/// Keep `file`'s session loaded for a while after everyone left ([`PARK_FOR`]), at most
/// [`MAX_PARKED`] files of at most [`MAX_PARKED_BYTES`] together (the ones parked longest let
/// go first; a file larger than that isn't kept).
pub(super) fn park(&mut self, file: &str, server: Server, log: super::Log) {
let size = self.bytes(file);
if size > MAX_PARKED_BYTES {
return;
}
let mut parked: Vec<(String, Instant, u64)> =
self.files.iter().filter_map(|(id, e)| e.parked.as_ref().map(|p| (id.clone(), p.at, e.bytes))).collect();
parked.sort_by_key(|(_, at, _)| *at);
let mut held: u64 = parked.iter().map(|(_, _, b)| b).sum();
let mut n = parked.len();
for (id, _, b) in &parked {
if n < MAX_PARKED && held.saturating_add(size) <= MAX_PARKED_BYTES {
break;
}
if let Some(e) = self.files.get_mut(id) {
e.parked = None;
}
n -= 1;
held = held.saturating_sub(*b);
}
if let Some(e) = self.files.get_mut(file) {
e.parked = Some(Parked { server, log, at: Instant::now() });
}
}
/// `file`'s session as it was left, while it is still kept loaded ([`PARK_FOR`]).
pub(super) fn unpark(&mut self, file: &str) -> Option<(Server, super::Log)> {
let p = self.files.get_mut(file)?.parked.take()?;
(p.at.elapsed() <= PARK_FOR).then_some((p.server, p.log))
}
/// What the server says about its hosting (with `file`'s expiry).
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
let h = &self.cfg.hosting;
@@ -44,8 +44,13 @@ impl Store {
}
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
/// One everyone left lately is still loaded: it comes back as it was, nothing read (#1074).
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
self.touch(file);
if let Some((server, log)) = self.unpark(file) {
self.entry(file)?.log = Some(log);
return Ok(server);
}
let dir = self.dir(file);
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
@@ -258,6 +263,7 @@ pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
if log.pending.is_empty() {
return Ok(0);
}
disk::counted(true);
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
Ok(()) => {
let n = log.pending.len() as u64;
+2 -1
View File
@@ -15,9 +15,11 @@ path = "src/main.rs"
[dependencies]
figcraft-engine = { workspace = true }
figcraft-format = { workspace = true }
figcraft-ui-egui = { workspace = true }
figcraft-collab-net = { workspace = true }
figcraft-text = { workspace = true }
base64 = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
@@ -26,7 +28,6 @@ serde_json = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
+4
View File
@@ -10,9 +10,13 @@
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
/* Shown once the page stopped on an internal error (web.rs: show_stopped). */
#figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box;
padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; }
@media (prefers-color-scheme: dark) {
html, body { background: #2c2c2c; }
#figcraft_loading { color: #b8b8b8; }
#figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); }
}
</style>
</head>
+6
View File
@@ -9,6 +9,8 @@
//! - no native file dialogs yet: the session starts with one new document;
//! - links open in a new browser tab;
//! - of the preferences, the page keeps only your name across reloads (`kept.rs`);
//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved
//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`);
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
//! (`local_fonts.rs`);
@@ -37,6 +39,10 @@ mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod recovery;
#[cfg(test)]
mod recovery_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+85
View File
@@ -0,0 +1,85 @@
//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts),
//! so a command or frame that panics ends the page instead of failing as on the desktop. The page
//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared
//! copies, taken only when a file changed); the panic hook writes them to the page's local storage
//! ([`store`]) before the page stops, and the next page opens them again as unsaved files
//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies.
use std::sync::Arc;
use base64::Engine as _;
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
use figcraft_engine::doc::Document;
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
/// A file with unsaved changes as of the last frame.
struct Copy {
uid: u64,
/// [`stamp`]: the revisions this copy is of.
stamp: Vec<u64>,
doc: Arc<Document>,
versions: Vec<Version>,
branches: Vec<Branch>,
}
impl Copy {
fn of(s: &Session, d: &DocState) -> Self {
let (doc, versions, branches) = recovery_copy(s, d);
Self { uid: d.uid, stamp: stamp(s, d), doc, versions, branches }
}
}
/// The page's files with unsaved changes, as of the last frame.
#[derive(Default)]
pub struct Unsaved(Vec<Copy>);
impl Unsaved {
/// Catch up with `s`: a file that changed is taken again, the others are kept as they are.
pub fn follow(&mut self, s: &Session) {
let mut before = std::mem::take(&mut self.0);
for d in s.docs.iter().filter(|d| needs_copy(s, d)) {
let now = stamp(s, d);
let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) {
Some(i) => before.swap_remove(i),
None => Copy::of(s, d),
};
self.0.push(copy);
}
}
/// How many files have unsaved changes.
pub fn count(&self) -> usize {
self.0.len()
}
/// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out).
pub fn files(&self) -> Vec<String> {
let b64 = base64::engine::general_purpose::STANDARD;
self.0
.iter()
.filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b)))
.collect()
}
}
/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says
/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept.
pub fn store(files: Vec<String>, mut put: impl FnMut(&str) -> bool) -> usize {
let mut kept = Vec::new();
for f in files {
kept.push(f);
if !put(&json!(kept).to_string()) {
kept.pop();
}
}
kept.len()
}
/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with
/// unsaved changes. Returns how many came back; junk in the storage is skipped.
pub fn restore(s: &mut Session, kept: Option<String>) -> usize {
let files: Vec<String> = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default();
files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count()
}
+115
View File
@@ -0,0 +1,115 @@
//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the
//! next page.
use figcraft_engine::Session;
use figcraft_ui_egui::browser_stopped;
use serde_json::json;
use crate::recovery::{Unsaved, restore, store};
fn edited(name: &str) -> Session {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
s.execute("file.rename", &json!({"name": name})).unwrap();
s
}
/// The page's copies, stored as the panic hook stores them.
fn stored(u: &Unsaved) -> Option<String> {
let mut out = None;
let kept = store(u.files(), |v| {
out = Some(v.to_string());
true
});
assert_eq!(kept, u.count());
out
}
#[test]
fn a_stopped_pages_unsaved_files_open_again_unsaved() {
let s = edited("Poster");
let mut u = Unsaved::default();
u.follow(&s);
assert_eq!(u.count(), 1);
let mut next = Session::new();
let before = next.docs.len();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.docs.len(), before + 1);
let d = next.ds().unwrap();
assert_eq!(d.doc.name, "Poster");
assert!(d.is_dirty(), "a recovered file has unsaved changes");
assert!(d.path.is_none(), "saving it asks where");
}
#[test]
fn the_latest_edit_is_kept_and_a_saved_file_is_not() {
let mut s = edited("First");
let mut u = Unsaved::default();
u.follow(&s);
u.follow(&s);
assert_eq!(u.count(), 1, "following again without a change keeps the one copy");
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
u.follow(&s);
let mut next = Session::new();
assert_eq!(restore(&mut next, stored(&u)), 1);
assert_eq!(next.ds().unwrap().doc.name, "Later");
s.execute("file.markSaved", &json!({})).unwrap();
u.follow(&s);
assert_eq!(u.count(), 0);
assert!(u.files().is_empty());
}
#[test]
fn what_the_storage_has_no_room_for_is_left_out() {
let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)];
let mut kept = String::new();
let n = store(files, |v| {
let fits = v.len() < 60;
if fits {
kept = v.to_string();
}
fits
});
assert_eq!(n, 2);
assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string());
assert_eq!(store(vec!["x".into()], |_| false), 0);
}
#[test]
fn junk_in_the_storage_opens_nothing() {
let mut s = Session::new();
let before = s.docs.len();
for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] {
assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}");
}
assert_eq!(s.docs.len(), before);
}
#[test]
fn the_stopped_page_says_what_was_kept() {
let says = |unsaved, kept, what: &str| {
let text = browser_stopped(unsaved, kept);
assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}");
};
says(0, 0, "start again");
says(2, 2, "get your unsaved files back");
says(2, 0, "too large for the browser to keep");
says(3, 1, "the browser had room for");
}
#[test]
fn a_copy_taken_while_a_version_is_previewed_is_of_the_file() {
let mut s = edited("Poster");
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
// Version v1 (named Poster) shows in place of the file.
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().name, "Poster", "the preview shows");
let mut u = Unsaved::default();
u.follow(&s);
let mut next = Session::new();
assert_eq!(restore(&mut next, stored(&u)), 1);
let d = next.ds().unwrap();
assert_eq!(d.doc.name, "Later", "the file comes back, not the version shown");
assert_eq!(d.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
}
+51
View File
@@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
const LOADING_ID: &str = "figcraft_loading";
/// What the page shows once it stopped on an internal error (styled in `index.html`).
const STOPPED_ID: &str = "figcraft_stopped";
/// The local storage key your name is kept under ([`crate::kept`]).
const NAME_KEY: &str = "figcraft.userName";
/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of
/// base64 `.figcraft` files.
const RECOVERY_KEY: &str = "figcraft.recovery";
thread_local! {
/// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]).
static UNSAVED: std::cell::RefCell<crate::recovery::Unsaved> = std::cell::RefCell::default();
}
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
// Before the runner's own hook, which logs the panic and then calls this one.
keep_unsaved_on_panic();
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -39,11 +51,13 @@ pub fn start() {
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
let mut session = Session::new();
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
let recovered = restore_unsaved(&mut session);
// Live sessions: the browser joins sessions and publishes files to servers;
// whatever arrives on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
fig.note_recovered(recovered);
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
@@ -70,6 +84,41 @@ pub fn start() {
});
}
/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with
/// unsaved changes in the page's storage for the next page, and say so on the page.
fn keep_unsaved_on_panic() {
static ONCE: std::sync::Once = std::sync::Once::new();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
previous(info);
// Callbacks that run after the page stopped can panic again: the files are kept once.
ONCE.call_once(|| {
let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default());
let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok()));
show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept));
});
}));
}
/// Open the files the last page kept when it stopped; how many came back.
fn restore_unsaved(session: &mut Session) -> usize {
let Some(storage) = local_storage() else { return 0 };
let kept = storage.get_item(RECOVERY_KEY).ok().flatten();
// Gone before they open: a copy that stops the page again must not come back on every reload
// (once a frame ran, the files it opened are followed as any unsaved file).
let _ = storage.remove_item(RECOVERY_KEY);
crate::recovery::restore(session, kept)
}
/// Say on the page that it stopped (the canvas stays as it was).
fn show_stopped(text: &str) {
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return };
el.set_id(STOPPED_ID);
el.set_text_content(Some(text));
let _ = body.append_child(&el);
}
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
fn local_storage() -> Option<web_sys::Storage> {
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
@@ -90,6 +139,8 @@ impl eframe::App for WebShell {
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
+2 -19
View File
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use std::thread::JoinHandle;
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
use figcraft_engine::{DocState, Session};
use figcraft_format::{Branch, Version};
use serde_json::json;
@@ -203,8 +204,7 @@ impl Recovery {
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
continue;
}
let doc = d.doc.clone();
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
let (doc, versions, branches) = recovery_copy(s, d);
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id, last change and the secret the
// client id resumes with), while it
@@ -321,23 +321,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
}
/// The tabs showing branches of the file in tab `uid`.
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
/// only its own revision). A new copy is due when they change.
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// Write `doc` with its version history and branches to `path` through a temporary file; false
/// when it couldn't be written (the copy before it is still there).
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
+18
View File
@@ -388,3 +388,21 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
r.finish();
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_copy_made_while_a_version_is_previewed_is_of_the_file_not_the_version() {
let root = root("preview");
let mut s = edited();
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
// The one-rectangle version shows in place of the two-rectangle file.
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert_eq!(rectangles(s.doc().unwrap()), 1, "the preview shows");
assert!(s.ds().unwrap().is_dirty(), "the file itself still has unsaved changes");
let mut r = Recovery::start(&root).unwrap();
r.write(&s, true);
let next = crash_and_recover(r, &root);
assert_eq!(rectangles(next.doc().unwrap()), 2, "the file comes back, not the version shown");
assert_eq!(next.docs[0].versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
let _ = std::fs::remove_dir_all(&root);
}
+38 -21
View File
@@ -53,9 +53,7 @@ impl Exchange {
/// `file` with its `owner` token.
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
let open = match key {
Some(key) => {
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() })
}
Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }),
None => ClientMsg::OpenHosting(OpenHosting {
protocol: PROTOCOL,
client_version: message::client_version(),
@@ -144,7 +142,7 @@ pub(crate) fn publish(job: Publish) -> Next {
// The file's first links stay its owner's (this app joins with one): others get their
// own, which stopping takes away again.
let owner = published.owner.unwrap_or_default();
let then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next> = Box::new(move |made| {
let then: LinksThen = Box::new(move |made| {
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
Next::Done(events, Some(edit))
});
@@ -163,26 +161,45 @@ struct Sharing {
create: bool,
}
/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a
/// connection of their own; `then` gets the links.
fn share_links(s: Sharing, then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>) -> Next {
/// What [`share_links`] hands the links to.
type LinksThen = Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>;
/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their
/// codes, each on a connection of its own; `then` gets the links.
fn share_links(s: Sharing, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
Next::Ask(
Box::new(ex),
Box::new(move |answers| {
let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
Ok(Some(links)) => links,
other => return then(other.map(|_| None)),
};
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
Next::Ask(
Box::new(ex),
Box::new(move |codes| {
// A server from before join codes: the links are shared without them.
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
}),
)
Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
Ok(Some(links)) => comment_link(s, links, then),
other => then(other.map(|_| None)),
}),
)
}
/// Ask for a comment link beside `links`, then for their codes.
fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file));
Next::Ask(
Box::new(ex),
Box::new(move |made| {
// A server from before comment links: the file is shared without one.
let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display()));
links.comment = made.ok().and_then(|a| requests::made_comment(a.replies));
codes(s, links, then)
}),
)
}
/// Ask for the codes of `links`; `then` gets them with their codes.
fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next {
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
Next::Ask(
Box::new(ex),
Box::new(move |codes| {
// A server from before join codes: the links are shared without them.
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
}),
)
}
+21 -7
View File
@@ -129,6 +129,11 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
// The comment link, on another.
d.opened(t0);
say(d, &hosting(), t0);
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Comment, .. })));
say(d, &admin(AdminReply::Link(new_link("c1", Access::Comment, "SaySaySaySaySaySaySayS"))), t0);
// Their codes, on another.
d.opened(t0);
say(d, &ServerMsg::Pong, t0);
@@ -136,6 +141,7 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
say(d, &admin(AdminReply::Code { code: "VWXYZ0123".into() }), t0);
}
#[test]
@@ -153,15 +159,18 @@ fn publishing_without_the_key_uploads_shares_then_joins() {
say(&mut d, &hosted(), t0);
make_links(&mut d, t0);
let events = inbox.drain();
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view) }))] = events.as_slice() else {
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view), comment: Some(comment) }))] = events.as_slice()
else {
panic!("{events:#?}")
};
assert_eq!(h.operator, "ops@example.com");
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
assert_eq!((comment.id.as_str(), comment.code.as_deref()), ("c1", Some("VWXYZ0123")));
assert!(comment.link.contains("token=SaySaySaySaySaySaySayS"));
// Then the file, with the owner's edit link.
assert_eq!(d.sock.opened.len(), 4);
assert_eq!(d.sock.opened.len(), 5);
d.opened(t0);
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
@@ -270,7 +279,8 @@ fn sharing_that_fails_still_joins_the_file() {
let events = inbox.drain();
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
// A server from before join codes shares the links without them.
// A server from before join codes (and comment links) shares the edit and view links without
// codes.
let (mut d, inbox) = publish(job(None, true), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
@@ -279,11 +289,15 @@ fn sharing_that_fails_still_joins_the_file() {
say(&mut d, &hosting(), t0);
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
d.opened(t0);
say(&mut d, &hosting(), t0);
d.closed(1000, "that message couldn't be read", t0);
for _ in ["the comment link", "the codes"] {
d.opened(t0);
say(&mut d, &hosting(), t0);
d.closed(1000, "that message couldn't be read", t0);
}
let events = inbox.drain();
assert!(matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none())));
assert!(
matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none()) && s.comment.is_none())
);
}
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
+18 -12
View File
@@ -71,14 +71,15 @@ const BURST: usize = 256;
enum Kind {
/// In `file`'s session as client `id`, through link `link`.
Joined { file: String, id: u32, link: Option<String> },
/// Managing the server's files.
Managing,
/// Managing the server's files; `comments`: its client reads comment links.
Managing { comments: bool },
/// Using `file`'s team library through link `link` of `access` (revoking it disconnects).
Library { file: String, access: Access, link: Option<String> },
/// Using the plugin directory; `publisher`: with the server's key.
Plugins { publisher: bool },
/// Publishing without the key, or managing the file `owns` with its owner token.
Hosting { owns: Option<String> },
/// Publishing without the key, or managing the file `owns` with its owner token; `comments`:
/// its client reads comment links.
Hosting { owns: Option<String>, comments: bool },
}
/// A connection whose first message the hub is looking at.
@@ -234,7 +235,7 @@ impl<B: Backend> Hub<B> {
let Some(c) = self.conns.get(&conn) else { return };
let ip = c.ip;
// The operator (with the server key) isn't held to the limits on addresses.
if !matches!(c.kind, Kind::Managing) && !self.backend.message(ip, &msg) {
if !matches!(c.kind, Kind::Managing { .. }) && !self.backend.message(ip, &msg) {
log::info!("event=refused ip={ip} reason=messages_per_address");
return self.kick(&[conn], "too many messages from your address: wait a minute and try again");
}
@@ -281,7 +282,7 @@ impl<B: Backend> Hub<B> {
let (file, id) = (file.clone(), *id);
self.client_message(&file, id, msg, Some(bytes));
}
Some(Kind::Hosting { owns }) => match msg {
Some(Kind::Hosting { owns, .. }) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = if owns.as_deref() == Some(file.as_str()) {
self.removed_reply(&file, client)
@@ -300,7 +301,7 @@ impl<B: Backend> Hub<B> {
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
},
Some(Kind::Managing) => match msg {
Some(Kind::Managing { .. }) => match msg {
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
let reply = self.removed_reply(&file, client);
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
@@ -475,7 +476,8 @@ impl<B: Backend> Hub<B> {
return refuse(&c.out, &why);
}
log::info!("live session: connection {conn} manages files");
self.take_in(conn, c, Kind::Managing, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
let comments = message::reads_comments(&m.client_version);
self.take_in(conn, c, Kind::Managing { comments }, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
}
fn open_library(&mut self, conn: u64, o: OpenLibrary, c: Pending) {
@@ -483,7 +485,7 @@ impl<B: Backend> Hub<B> {
return refuse(&c.out, &unsupported(o.protocol));
}
let (access, link) = match self.backend.admit(&o.file, &o.token) {
Ok(a) => (a.access, a.link),
Ok(a) => (a.access.for_reader(message::reads_comments(&o.client_version)), a.link),
Err(why) => return refuse(&c.out, &why),
};
self.take_in(conn, c, Kind::Library { file: o.file, access, link }, &ServerMsg::Library(Box::new(LibraryReply::Ready { access })));
@@ -515,7 +517,8 @@ impl<B: Backend> Hub<B> {
}
};
let owns = (!o.owner.is_empty()).then(|| o.file.clone());
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
let comments = message::reads_comments(&o.client_version);
self.take_in(conn, c, Kind::Hosting { owns, comments }, &ServerMsg::Hosting(Box::new(info)));
}
/// A join code: the file it opens and the token to join it with, then the connection closes
@@ -545,9 +548,12 @@ impl<B: Backend> Hub<B> {
self.handled(conn, handled);
}
/// Answer a managing or hosting request, and disconnect whoever it affects.
/// Answer a managing or hosting request (as its client reads it), and disconnect whoever it
/// affects.
fn handled(&mut self, conn: u64, handled: Handled) {
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
let comments =
self.conns.get(&conn).is_some_and(|c| matches!(c.kind, Kind::Managing { comments: true } | Kind::Hosting { comments: true, .. }));
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply.for_reader(comments))));
if let Some((file, link)) = handled.revoked {
let with = |joined: bool| -> Vec<u64> {
let uses = |c: &Conn| match &c.kind {
+1 -1
View File
@@ -85,7 +85,7 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::new(),
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing { comments: true }, ip })]),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
+1 -1
View File
@@ -65,7 +65,7 @@ fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
for id in 1..=3u64 {
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
receivers.push(rx);
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing { comments: true }, ip });
}
// Connection 1 doesn't read: it holds most of the budget.
let big = Bytes::from(vec![0u8; 9_000]);
+11 -6
View File
@@ -16,7 +16,7 @@ use tungstenite::{Bytes, Message, WebSocket};
use crate::invite::{Endpoint, Invite};
use crate::net::{Duplex, NetLimits, config, open};
pub(crate) use crate::requests::REFUSED;
use crate::requests::{code_requests, hosting_error, link_requests, made_links, shared};
use crate::requests::{code_requests, comment_request, hosting_error, link_requests, made_comment, made_links, shared};
type Ws = WebSocket<Duplex>;
@@ -28,7 +28,7 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
ServerMsg::Admin(r) => Some(*r),
_ => None,
};
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() };
let mut ws = connect(server, limits, timeout)?;
send(&mut ws, &ClientMsg::Manage(manage))?;
match reply(&mut ws, limits, admin)? {
@@ -97,8 +97,9 @@ pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Durat
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
/// make a new edit link and a new view link and ask for their join codes: what others join with.
/// A server older than join codes makes the links without codes.
/// make a new edit link, a new view link and a new comment link and ask for their join codes: what
/// others join with. A server older than join codes makes the links without codes, and one older
/// than comment links makes no comment link.
pub fn share(
server: &Endpoint,
key: Option<&str>,
@@ -113,7 +114,11 @@ pub fn share(
Some(key) => request(server, key, requests, limits, timeout),
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
};
let Some(links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
let Some(mut links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
// The comment link on a connection of its own: a server from before comment links can't read
// the request and closes it (the file is shared without one then).
let comment = ask(comment_request(file)).map_err(|e| log::info!("live session: no comment link from {}: {e}", server.display()));
links.comment = made_comment(comment.unwrap_or_default());
// Their codes, on a connection of its own: a server from before join codes can't read the
// request and closes it (the links are shared without codes then).
let codes =
@@ -130,7 +135,7 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
_ => None,
};
let invite = Invite::parse(link)?;
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: env!("CARGO_PKG_VERSION").to_string() };
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: message::client_version() };
let mut ws = connect(&invite.server, limits, timeout)?;
send(&mut ws, &ClientMsg::OpenLibrary(open))?;
match reply(&mut ws, limits, lib)? {
+34 -10
View File
@@ -63,18 +63,42 @@ pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(
Ok((edit, published))
}
/// The links made for others: an edit and a view link, and a comment link from a server that
/// makes them.
pub(crate) struct Links {
pub edit: NewLink,
pub view: NewLink,
pub comment: Option<NewLink>,
}
/// The request for a new link of `access` to `file`, for others.
fn link_request(file: &str, access: Access) -> Admin {
Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) }
}
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
/// edit link and a new view link.
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
requests.extend(create.then(|| [link_request(file, Access::Edit), link_request(file, Access::View)]).into_iter().flatten());
requests
}
/// The request for a comment link to `file`, asked on a connection of its own once
/// [`made_links`] gave the others: a server from before comment links can't read it and closes
/// the connection (the file is shared without one then).
pub(crate) fn comment_request(file: &str) -> Vec<Admin> {
vec![link_request(file, Access::Comment)]
}
/// The comment link the answers to [`comment_request`] made, if any.
pub(crate) fn made_comment(replies: Vec<AdminReply>) -> Option<NewLink> {
replies.into_iter().find_map(|r| if let AdminReply::Link(l) = r { Some(l) } else { None })
}
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
/// or why the server didn't do it.
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<(NewLink, NewLink)>, String> {
/// or why the server didn't do it; the comment link comes after ([`made_comment`]).
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<Links>, String> {
let mut made = Vec::new();
for reply in replies {
match reply {
@@ -86,7 +110,7 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
}
}
match (made.pop(), made.pop()) {
(Some(view), Some(edit)) => Ok(Some((edit, view))),
(Some(view), Some(edit)) => Ok(Some(Links { edit, view, comment: None })),
_ if create => Err("the server didn't make the links".into()),
_ => Ok(None),
}
@@ -94,21 +118,21 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
/// their own: a server from before join codes can't read them and closes it).
pub(crate) fn code_requests(file: &str, (edit, view): &(NewLink, NewLink)) -> Vec<Admin> {
[edit, view].into_iter().map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
pub(crate) fn code_requests(file: &str, links: &Links) -> Vec<Admin> {
[&links.edit, &links.view].into_iter().chain(&links.comment).map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
}
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
/// [`code_requests`]; none from a server older than join codes).
pub(crate) fn shared(server: &Endpoint, file: &str, (edit, view): (NewLink, NewLink), codes: Vec<AdminReply>) -> Shared {
pub(crate) fn shared(server: &Endpoint, file: &str, Links { edit, view, comment }: Links, codes: Vec<AdminReply>) -> Shared {
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
let mut link = |l: NewLink| SharedLink {
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
id: l.link.id,
code: codes.next().flatten(),
};
let edit = link(edit);
Shared { edit, view: Some(link(view)) }
let (edit, view) = (link(edit), Some(link(view)));
Shared { edit, view, comment: comment.map(link) }
}
/// A hosting answer as the app shows it: a new link whole, in place of its token.
+10
View File
@@ -318,6 +318,16 @@ impl Change {
pub(crate) fn is_image(&self) -> bool {
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
}
/// Whether this only changes comment threads (a thread, their order, or all of them): what a
/// comment link may send.
pub fn is_comment(&self) -> bool {
match self {
Change::Put { entry, .. } | Change::Remove { entry } => entry.list() == Some(Family::Comments),
Change::SetDoc { prop, .. } => matches!(prop.list(), Some(Ok(Family::Comments) | Err(Family::Comments))),
Change::Create { .. } | Change::Set { .. } | Change::Delete { .. } => false,
}
}
}
impl Entry {
+6
View File
@@ -229,6 +229,12 @@ impl Client {
Ok(self.flush(&NoRecompute).pop())
}
/// Whether `doc` differs from what this client shows only in comment threads (what a comment
/// link may send; nothing differing counts too).
pub fn changes_only_comments(&self, doc: &Document) -> bool {
diff(&self.view, doc).is_ok_and(|d| d.changes.iter().all(Change::is_comment))
}
/// Undo the user's last step that still changes something: write back the values it replaced
/// where nobody else wrote since (their later writes win), delete what it created (unless
/// others put layers inside) and bring back what it deleted. Returns the batches to send now
+200
View File
@@ -0,0 +1,200 @@
//! Comment links: a commenter sees everything and may change comment threads (add, reply, react,
//! resolve, remove, reorder), but a batch that changes anything else is refused with the reason
//! and rolled back; a client that can't read comment links joins with one as a viewer, managing
//! clients that can't read them get them listed as view links, and a level this build doesn't know
//! reads as view.
use figcraft_doc::{Comment, Document, Message, NodeKind};
use crate::change::{Change, DocProp, Entry, Json, Prop};
use crate::message::{
self, Access, AdminReply, ClientMsg, Hello, LinkInfo, NewLink, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
};
use crate::sim_tests::base;
use crate::{COMMENT_ONLY, Client, Limits, NoRecompute, Outgoing, Server};
fn hello(client_version: &str) -> Hello {
let ClientMsg::Hello(mut h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
h.client_version = client_version.into();
h
}
fn join_as(s: &mut Server, access: Access, client_version: &str) -> Client {
let (_, out) = s.join_as(hello(client_version), access).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
Client::join(welcome).unwrap()
}
/// `c` edits its copy with `f`; the server's answer, delivered to everyone in `all`.
fn send(s: &mut Server, c: &mut Client, f: impl FnOnce(&mut Document), all: &mut [&mut Client]) -> Vec<Outgoing> {
let mut d = c.document().clone();
f(&mut d);
let m = c.edit(&d).unwrap().expect("a change");
let out = s.receive(c.id(), m);
for o in &out {
let m = match o {
Outgoing::All(m) => m,
Outgoing::To(t, m) if *t == c.id() => m,
_ => continue,
};
c.receive(m.clone(), &NoRecompute).unwrap();
for other in all.iter_mut() {
if matches!(o, Outgoing::All(_)) {
other.receive(m.clone(), &NoRecompute).unwrap();
}
}
}
out
}
fn accepted(out: &[Outgoing]) -> bool {
matches!(out, [Outgoing::All(ServerMsg::Changes(_))])
}
fn refused(out: &[Outgoing]) -> bool {
matches!(out, [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == COMMENT_ONLY)
}
fn comment(id: u64, d: &Document, text: &str) -> Comment {
let page = d.first_page().unwrap();
Comment { id, page, x: 10.0, y: 20.0, anchor: None, first: Message::new("Cy", text, 0), resolved: false, replies: vec![], read_by: None }
}
#[test]
fn a_commenter_changes_comment_threads_and_nothing_else() {
let mut s = Server::new(base(), "", Limits::default());
let mut editor = join_as(&mut s, Access::Edit, &message::client_version());
let mut cy = join_as(&mut s, Access::Comment, &message::client_version());
assert_eq!(cy.access(), Access::Comment, "the welcome says what the client may do");
let page = s.document().first_page().unwrap();
// A new thread reaches everyone; so do a reply, a resolve and a reaction to it.
let out = send(
&mut s,
&mut cy,
|d| {
let id = d.new_comment_id();
let c = comment(id, d, "Bigger logo?");
d.comments.push(c);
},
&mut [&mut editor],
);
assert!(accepted(&out), "{out:?}");
assert_eq!(editor.document().comments.len(), 1);
let out = send(
&mut s,
&mut cy,
|d| {
let c = &mut d.comments[0];
c.replies.push(Message::new("Cy", "Or a darker one", 1));
c.resolved = true;
c.first.react("👍", "Cy", None);
},
&mut [&mut editor],
);
assert!(accepted(&out), "{out:?}");
assert_eq!(s.document().comments[0].replies.len(), 1);
assert!(s.document().comments[0].resolved);
// A layer, a page or any other change is refused with the reason and rolled back.
let seq = s.seq();
assert!(refused(&send(&mut s, &mut cy, |d| d.node_mut(page).unwrap().name = "Mine".into(), &mut [])));
assert!(refused(&send(
&mut s,
&mut cy,
|d| {
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
},
&mut []
)));
assert!(refused(&send(&mut s, &mut cy, |d| d.name = "Renamed".into(), &mut [])));
// So is a batch that sneaks an edit in beside a comment.
assert!(refused(&send(
&mut s,
&mut cy,
|d| {
d.comments[0].resolved = false;
d.node_mut(page).unwrap().name = "Mine".into();
},
&mut []
)));
assert_eq!(s.seq(), seq, "nothing accepted");
assert_eq!(cy.view(), s.replica(), "the commenter's copy is the server's again");
assert!(s.document().comments[0].resolved);
// Removing a thread is a comment change too.
assert!(accepted(&send(&mut s, &mut cy, |d| d.comments.clear(), &mut [&mut editor])));
assert!(editor.document().comments.is_empty());
// The editor still edits.
assert!(accepted(&send(&mut s, &mut editor, |d| d.node_mut(page).unwrap().name = "Home".into(), &mut [&mut cy])));
assert_eq!(cy.document().node(page).unwrap().name, "Home");
}
#[test]
fn a_client_that_cant_read_comment_links_joins_with_one_as_a_viewer() {
let mut s = Server::new(base(), "", Limits::default());
let mut old = join_as(&mut s, Access::Comment, "0.12.1 signals hosting");
assert_eq!(old.access(), Access::View);
let page = s.document().first_page().unwrap();
let out = send(
&mut s,
&mut old,
|d| {
let id = d.new_comment_id();
let c = comment(id, d, "Hi");
d.comments.push(c);
},
&mut [],
);
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == crate::VIEW_ONLY), "{out:?}");
// Edit and view links are as they were for it.
assert_eq!(join_as(&mut s, Access::Edit, "0.12.1").access(), Access::Edit);
assert_eq!(join_as(&mut s, Access::View, "0.12.1").access(), Access::View);
assert!(s.document().node(page).is_some());
}
#[test]
fn only_comment_thread_changes_count_as_comments() {
let d = base();
let c = comment(7, &d, "x");
let value = Json::of(&c).unwrap();
let page = d.first_page().unwrap();
let comments = [
Change::Put { entry: Entry::Comment(7), value: value.clone() },
Change::Remove { entry: Entry::Comment(7) },
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[7u64]).unwrap() },
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&[c]).unwrap() },
];
assert!(comments.iter().all(Change::is_comment));
let others = [
Change::Put { entry: Entry::Style("S:1".into()), value: value.clone() },
Change::Remove { entry: Entry::Image("a".into()) },
Change::SetDoc { prop: DocProp::Name, value: Json::of("x").unwrap() },
Change::SetDoc { prop: DocProp::StyleOrder, value: Json::of(&["S:1"]).unwrap() },
Change::Set { id: page, prop: Prop::Name, value: Json::of("x").unwrap() },
Change::Delete { id: page },
];
assert!(!others.iter().any(Change::is_comment));
}
#[test]
fn comment_access_reads_back_and_an_unknown_level_reads_as_view() {
assert_eq!(Access::parse(" Comment "), Some(Access::Comment));
assert_eq!(Access::Comment.id(), "comment");
for a in [Access::Edit, Access::View, Access::Comment] {
assert_eq!(decode::<Access>(&encode(&a).unwrap(), 64).unwrap(), a);
assert_eq!(decode_json::<Access>(&encode_json(&a).unwrap(), 64).unwrap(), a);
}
// A level from a newer build.
assert_eq!(decode::<Access>(&[WIRE_VERSION, 9], 64).unwrap(), Access::View);
assert_eq!(decode_json::<Access>("\"owner\"", 64).unwrap(), Access::View);
}
#[test]
fn managing_clients_that_cant_read_comment_links_get_view_links() {
let link = |access| LinkInfo { id: "l".into(), file: "f".into(), access, label: String::new(), created: 0 };
let listing = AdminReply::Links(vec![link(Access::Edit), link(Access::Comment), link(Access::View)]);
let AdminReply::Links(old) = listing.clone().for_reader(false) else { panic!() };
assert_eq!(old.iter().map(|l| l.access).collect::<Vec<_>>(), [Access::Edit, Access::View, Access::View]);
assert_eq!(listing.clone().for_reader(true), listing);
let made = AdminReply::Link(NewLink { link: link(Access::Comment), token: "t".into() });
assert!(matches!(made.for_reader(false), AdminReply::Link(l) if l.link.access == Access::View));
assert!(message::reads_comments(&message::client_version()) && !message::reads_comments("0.12.1 signals hosting"));
}
+3 -1
View File
@@ -44,7 +44,7 @@ pub use figcraft_doc::{Document, NodeId};
pub use history::{BatchKind, Notice};
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg, Signal};
pub use replica::{Applied, Mode, Replica};
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
pub use server::{COMMENT_ONLY, Journal, Outgoing, Server, SessionState, VIEW_ONLY};
/// Compare secrets (tokens, keys, their hashes) without stopping at the first difference.
pub fn same_secret(a: &str, b: &str) -> bool {
@@ -131,6 +131,8 @@ mod client_tests;
#[cfg(test)]
mod codec_tests;
#[cfg(test)]
mod comment_access_tests;
#[cfg(test)]
mod diff_tests;
#[cfg(test)]
mod entry_ids_tests;
+90 -7
View File
@@ -23,19 +23,33 @@ pub const SIGNALS: &str = "signals";
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
pub const HOSTING: &str = "hosting";
/// The word a client puts in its [`Hello::client_version`] (or its managing connection's) when it
/// reads [`Access::Comment`]: older ones get a comment link as a view link.
pub const COMMENTS: &str = "comments";
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
pub fn client_version() -> String {
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION"))
}
/// Whether `client_version` announces `word`.
fn announces(client_version: &str, word: &str) -> bool {
client_version.split_whitespace().any(|w| w == word)
}
/// A [`Hello::client_version`] that announces [`SIGNALS`].
pub fn reads_signals(client_version: &str) -> bool {
client_version.split_whitespace().any(|w| w == SIGNALS)
announces(client_version, SIGNALS)
}
/// A [`Hello::client_version`] that announces [`HOSTING`].
pub fn reads_hosting(client_version: &str) -> bool {
client_version.split_whitespace().any(|w| w == HOSTING)
announces(client_version, HOSTING)
}
/// A [`Hello::client_version`] that announces [`COMMENTS`].
pub fn reads_comments(client_version: &str) -> bool {
announces(client_version, COMMENTS)
}
/// Join a file's session.
@@ -56,22 +70,72 @@ pub struct Hello {
pub resume: Option<Resume>,
}
/// What a participant may do: edit, or only view (a view link). A viewer gets everyone's changes
/// and presence, and the server refuses every batch it sends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
/// What a participant may do: edit, only view (a view link), or view and comment (a comment
/// link). A viewer gets everyone's changes and presence, and the server refuses every batch it
/// sends; a commenter's batches are refused unless they only change comment threads. A level
/// this build doesn't know reads as [`View`](Access::View), and a client that doesn't announce
/// [`COMMENTS`] is welcomed with a comment link as a viewer.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum Access {
#[default]
Edit,
View,
/// New levels go at the end: the binary form numbers them.
Comment,
}
/// Reads what [`Access`]'s derived `Serialize` writes (a variant's number in the binary form, its
/// name in JSON); an unknown one reads as [`Access::View`].
impl<'de> Deserialize<'de> for Access {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{EnumAccess, VariantAccess, Visitor};
struct Level(Access);
impl<'de> Deserialize<'de> for Level {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct Id;
impl Visitor<'_> for Id {
type Value = Level;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an access level")
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Level, E> {
Ok(Level(match v {
0 => Access::Edit,
2 => Access::Comment,
_ => Access::View,
}))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Level, E> {
Ok(Level(Access::parse(v).unwrap_or(Access::View)))
}
}
d.deserialize_identifier(Id)
}
}
struct Unit;
impl<'de> Visitor<'de> for Unit {
type Value = Access;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("an access level")
}
fn visit_enum<A: EnumAccess<'de>>(self, data: A) -> Result<Access, A::Error> {
let (Level(a), v) = data.variant()?;
v.unit_variant()?;
Ok(a)
}
}
d.deserialize_enum("Access", &["edit", "view", "comment"], Unit)
}
}
impl Access {
/// `"edit"` or `"view"`.
/// `"edit"`, `"view"` or `"comment"`.
pub fn id(self) -> &'static str {
match self {
Access::Edit => "edit",
Access::View => "view",
Access::Comment => "comment",
}
}
@@ -80,9 +144,16 @@ impl Access {
match s.trim().to_ascii_lowercase().as_str() {
"edit" => Some(Access::Edit),
"view" => Some(Access::View),
"comment" => Some(Access::Comment),
_ => None,
}
}
/// This access as a client can read it: a comment link only views for one that doesn't
/// announce [`COMMENTS`] (`reads_comments` false).
pub fn for_reader(self, reads_comments: bool) -> Access {
if self == Access::Comment && !reads_comments { Access::View } else { self }
}
}
/// What a reconnecting client tells the server in its [`Hello`].
@@ -351,6 +422,18 @@ pub struct NewLink {
pub token: String,
}
impl AdminReply {
/// This reply as a managing client can read it ([`Access::for_reader`]).
pub fn for_reader(mut self, reads_comments: bool) -> AdminReply {
match &mut self {
AdminReply::Links(links) => links.iter_mut().for_each(|l| l.access = l.access.for_reader(reads_comments)),
AdminReply::Link(l) => l.link.access = l.link.access.for_reader(reads_comments),
_ => {}
}
self
}
}
/// The answer to an [`Admin`] request (the first one on a connection answers [`Manage`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
+22 -10
View File
@@ -7,7 +7,9 @@ use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
use crate::message::{
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops, same_secret};
@@ -25,6 +27,9 @@ pub enum Outgoing {
/// Why a viewer's batch is refused.
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
/// Why a commenter's batch that changes more than comment threads is refused.
pub const COMMENT_ONLY: &str = "you can only view and comment on this file (your link is a comment link)";
/// A new style, variable, collection or comment may not take an id in a space at or above this
/// that isn't its creator's. Client ids start above every space in the document after a restart,
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
@@ -75,8 +80,8 @@ pub struct Server {
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
log_changes: usize,
log_bytes: usize,
/// The connected clients that may only view.
viewers: BTreeSet<u32>,
/// The connected clients that may not edit, and what they may do instead (view, or comment).
limited: BTreeMap<u32, Access>,
/// What changed the lasting state since it was last taken (`None`: not kept).
journal: Option<Vec<Journal>>,
/// The connected clients that read signals (cursor chat, spotlight).
@@ -133,7 +138,7 @@ impl Server {
log: VecDeque::new(),
log_changes: 0,
log_bytes: 0,
viewers: BTreeSet::new(),
limited: BTreeMap::new(),
journal: None,
signals: BTreeSet::new(),
spotlight: None,
@@ -291,8 +296,11 @@ impl Server {
self.join_as(hello, Access::Edit)
}
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused.
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused, and
/// every batch of a commenter that changes more than comment threads. A client that can't
/// read comment links joins with one as a viewer.
pub fn join_as(&mut self, hello: Hello, access: Access) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
let access = access.for_reader(reads_comments(&hello.client_version));
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
if hello.protocol != PROTOCOL {
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
@@ -338,10 +346,10 @@ impl Server {
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
self.peers.insert(id, peer.clone());
if access == Access::View {
self.viewers.insert(id);
if access == Access::Edit {
self.limited.remove(&id);
} else {
self.viewers.remove(&id);
self.limited.insert(id, access);
}
let mut outs = vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))];
if signals {
@@ -370,7 +378,7 @@ impl Server {
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
self.acked.remove(&id);
self.viewers.remove(&id);
self.limited.remove(&id);
self.signals.remove(&id);
if self.spotlight == Some(id) {
self.spotlight = None;
@@ -392,7 +400,11 @@ impl Server {
// client learned what happened to it from the catch-up.
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
ClientMsg::Changes { base_seq, batch_id, changes } => {
let result = if self.viewers.contains(&from) { Err(Error::Invalid(VIEW_ONLY.into())) } else { self.accept(from, base_seq, &changes) };
let result = match self.limited.get(&from) {
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
_ => self.accept(from, base_seq, &changes),
};
if let Some(p) = self.issued.get_mut(&from) {
p.note(batch_id, result.is_err());
}
+1
View File
@@ -15,3 +15,4 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
imbl = { workspace = true }
unicode-segmentation = { workspace = true }
+30
View File
@@ -78,3 +78,33 @@ fn spread_reaches_further_at_sharp_corners_only() {
assert_eq!(d.node(r).unwrap().paint_reach(), 10.0);
assert_eq!(d.node(s).unwrap().paint_reach(), 10.0 * SPREAD_MITER_LIMIT);
}
#[test]
fn stacked_layer_effects_reach_together_and_shadows_from_there() {
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
let blur = |r: f64| Effect { kind: EffectKind::LayerBlur(Blur::uniform(r)), visible: true };
// One blur reaches as before; four of 10 as one of 20 (their variances add).
n.effects = vec![blur(10.0)];
assert_eq!(n.layer_effects_reach(), 15.0);
n.effects = vec![blur(10.0), blur(10.0), blur(10.0), blur(10.0)];
assert!((n.layer_effects_reach() - 30.0).abs() < 1e-9, "{}", n.layer_effects_reach());
// A sharp shadow below is cast from the blurred layer: it reaches the blur's distance further.
let shadow =
Effect { kind: EffectKind::DropShadow(Shadow { radius: 0.0, offset: (0.0, 50.0), spread: 0.0, ..Shadow::default() }), visible: true };
n.effects = vec![blur(10.0), shadow];
let b = n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
assert_eq!((b.y0, b.y1), (-15.0, 75.0), "{b:?}");
// Hidden effects reach nowhere.
n.effects = vec![Effect { visible: false, ..blur(10.0) }];
assert_eq!(n.layer_effects_reach(), 0.0);
}
#[test]
fn reaches_too_large_to_add_stay_finite() {
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
let t = Texture { radius: 1e308, ..Default::default() };
n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }];
let (reach, b) = (n.paint_reach(), n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)));
assert!(reach.is_finite() && reach > 1e6, "{reach}");
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 1e6, "{b:?}");
}
+4 -2
View File
@@ -738,7 +738,8 @@ impl Document {
/// Bounds of everything painted by a node, effects and strokes included (page coordinates):
/// a clipping frame's own area, else that and what its visible children show (masked
/// children only within their mask). A group is what its children show, grown by its effects.
/// children only within their mask). A group, or an unclipped frame past its box, is what its
/// children show grown by its effects, which apply to all of it.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
}
@@ -751,7 +752,8 @@ impl Document {
}
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
(Some(kids), _) => own.union(kids),
// An unclipped frame's effects apply to its children too, past its box included.
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
(None, _) => own,
})
}
+39 -8
View File
@@ -828,8 +828,25 @@ impl Node {
/// Maximum distance (in local units) painting can reach outside the node box.
pub fn paint_reach(&self) -> f64 {
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
self.outline_reach() + fx + self.spread_miter_reach()
self.outline_reach() + self.effects_reach() + self.spread_miter_reach()
}
/// How far (local units) this node's effects paint past what they apply to: the layer effects
/// together (see [`Node::layer_effects_reach`]), then the largest of the others.
pub fn effects_reach(&self) -> f64 {
let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max);
self.layer_effects_reach() + fx
}
/// How far the layer blurs and texture move the layer's pixels together: they apply one after
/// another, each moving what the one before it moved. Blurs one after another spread as one
/// blur whose radius squared is the sum of theirs (Gaussians add their variances); texture
/// scatters by up to its radius, added on. Drop shadows are cast from what they leave.
pub fn layer_effects_reach(&self) -> f64 {
let moving = || self.effects.iter().filter(|e| e.moves_the_layer());
let blurs: f64 = moving().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).map(|e| effect_reach(e.reach()).powi(2)).sum();
let texture: f64 = moving().filter(|e| matches!(e.kind, EffectKind::Texture(_))).map(|e| effect_reach(e.reach())).sum();
effect_reach(blurs.sqrt()) + texture
}
/// The page-space area this node paints itself under `world` (its children aside): its box
@@ -842,18 +859,23 @@ impl Node {
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
/// round, drop shadows towards their offset (in page units, as the renderer draws them), so a
/// shadow falling down adds room below and none above.
/// Layer blurs and texture grow it by what they reach together, and drop shadows are cast from
/// that (see [`Node::layer_effects_reach`]).
pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect {
let miter = self.spread_miter_reach();
self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind {
let layer = self.layer_effects_reach();
let moved = if layer > 0.0 { b.inflate(layer, layer) } else { b };
self.effects.iter().filter(|e| e.visible).fold(moved, |r, e| match &e.kind {
EffectKind::DropShadow(s) => {
let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter;
let (dx, dy) = s.offset;
if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r }
}
_ => {
let g = e.reach();
if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r }
if g.is_finite() && dx.is_finite() && dy.is_finite() {
r.union(moved.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy))
} else {
r
}
}
_ => r,
})
}
@@ -899,3 +921,12 @@ impl Node {
.fold(0.0, f64::max)
}
}
/// The most one effect's reach counts for (document units) when reaches are added up: far past any
/// canvas, and a sum of any number of them (or of their squares) stays finite.
const MAX_EFFECT_REACH: f64 = 1e100;
/// An effect's reach made safe to add: finite, in 0..[`MAX_EFFECT_REACH`].
fn effect_reach(r: f64) -> f64 {
if r.is_finite() { r.clamp(0.0, MAX_EFFECT_REACH) } else { 0.0 }
}
+5
View File
@@ -869,6 +869,11 @@ impl Effect {
_ => 0.0,
}
}
/// Whether this is a visible layer blur or texture: what moves the layer's own pixels, one
/// after another in list order.
pub fn moves_the_layer(&self) -> bool {
self.visible && matches!(self.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_))
}
/// A layer or background blur's settings.
pub fn blur(&self) -> Option<&Blur> {
match &self.kind {
+30 -4
View File
@@ -150,8 +150,12 @@ impl TextCase {
} else {
out.push(ch);
// An apostrophe only continues a word right after a letter or digit.
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
start = !is_word_char(ch) || opening_apostrophe;
let opening_apostrophe =
matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c));
// A combining mark attaches to the char before it and never changes word state.
if !is_combining_mark(ch) {
start = !is_word_char(ch) || opening_apostrophe;
}
}
last = Some(ch);
}
@@ -166,9 +170,23 @@ impl TextCase {
}
}
/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't").
/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't"
/// and decomposed accents stay inside their word).
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}')
c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}')
}
/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that
/// extend the grapheme of the char before them.
fn is_combining_mark(c: char) -> bool {
use unicode_segmentation::UnicodeSegmentation;
if c.is_ascii() || c.is_alphanumeric() {
return false;
}
let mut buf = [0u8; 8];
buf[0] = b'a';
let end = 1 + c.encode_utf8(&mut buf[1..]).len();
std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -969,4 +987,12 @@ mod title_case_tests {
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
}
#[test]
fn title_case_combining_marks_do_not_break_words() {
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}");
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon");
assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B");
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
}
}
@@ -0,0 +1,59 @@
//! Moving an auto layout frame (#1571): a frame that only moves keeps the layers inside it where
//! they are, so a drag doesn't lay its contents out again on every pointer move; a change that can
//! move or resize them still does, and its parent's layout still follows it.
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
use crate::params::id_of;
/// Two rectangles wrapped in an auto layout frame: the session, the frame and the rectangles.
fn stack() -> (Session, NodeId, [NodeId; 2]) {
let mut s = Session::new();
let mut make = |y| id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 100, "height": 40})).unwrap()["id"]).unwrap();
let kids = [make(0), make(60)];
s.execute("select.set", &json!({"ids": kids})).unwrap();
s.execute("layout.add", &json!({})).unwrap();
let frame = s.doc().unwrap().node(kids[0]).and_then(|n| n.parent).unwrap();
(s, frame, kids)
}
fn local_y(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().transform.translation().y
}
#[test]
fn moving_an_auto_layout_frame_leaves_its_contents_alone() {
let (mut s, frame, [_, second]) = stack();
assert!(s.doc().unwrap().node(frame).unwrap().layout.is_some());
let laid_out = local_y(&s, second);
// Put the second rectangle out of place without anything noticing (as if the layout had
// never run on it), then move the frame: its contents aren't laid out again.
{
let doc = s.ds_mut().unwrap().doc_mut();
doc.node_mut(second).unwrap().transform = figcraft_doc::geom::Affine::translate((0.0, laid_out + 500.0));
doc.take_dirty();
}
s.execute("node.set", &json!({"ids": [frame.0], "props": {"x": 300, "y": 200}})).unwrap();
assert_eq!(local_y(&s, second), laid_out + 500.0, "a move doesn't lay the frame out");
// A change that can move its contents does.
s.execute("node.set", &json!({"ids": [frame.0], "props": {"width": 160}})).unwrap();
assert_eq!(local_y(&s, second), laid_out, "a resize lays it out");
}
#[test]
fn a_frame_moved_inside_auto_layout_still_moves_its_siblings() {
// A stack and a rectangle side by side in an outer auto layout frame: the outer layout still
// follows when the stack grows.
let (mut s, inner, [first, _]) = stack();
let other = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 100, "height": 40})).unwrap()["id"]).unwrap();
s.execute("select.set", &json!({"ids": [inner.0, other.0]})).unwrap();
s.execute("layout.add", &json!({})).unwrap();
let outer = s.doc().unwrap().node(inner).and_then(|n| n.parent).unwrap();
assert!(s.doc().unwrap().node(outer).unwrap().layout.is_some());
let before = s.doc().unwrap().world_bounds(other).unwrap();
s.execute("node.set", &json!({"ids": [first.0], "props": {"width": 200}})).unwrap();
let after = s.doc().unwrap().world_bounds(other).unwrap();
assert!(after.x0 > before.x0, "the outer layout followed the inner frame's growth: {before:?} {after:?}");
}
+39
View File
@@ -0,0 +1,39 @@
//! A boolean group past the path operations' caps: it stays a boolean group, flattening it is
//! refused with the reason, and the document is unchanged.
use figcraft_doc::{NodeKind, VectorNetwork};
use figcraft_geom::kurbo::{BezPath, Rect, Shape};
use serde_json::json;
use crate::Session;
use crate::params::id_of;
/// `n` long thin bars, across or down: 750 of each cross 2.25 million times, past the caps.
fn bars(n: usize, across: bool) -> BezPath {
let mut p = BezPath::new();
for i in 0..n {
let (at, len) = (i as f64 * 4.0, n as f64 * 4.0);
let r = if across { Rect::new(0.0, at, len, at + 2.0) } else { Rect::new(at, 0.0, at + 2.0, len) };
p.extend(r.path_elements(0.1));
}
p
}
#[test]
fn flattening_a_boolean_past_the_caps_is_refused() {
let mut s = Session::new();
let page = s.page().unwrap();
let ids: Vec<_> = [true, false]
.into_iter()
.map(|across| {
let network = VectorNetwork::from_path(&bars(750, across));
s.doc_mut().unwrap().create(page, NodeKind::Vector { network }, "Bars", 0.0, 0.0, 3000.0, 3000.0).unwrap()
})
.collect();
let g = id_of(&s.execute("bool.union", &json!({"ids": ids})).unwrap()["id"]).unwrap();
let e = s.execute("vector.flatten", &json!({"ids": [g]})).unwrap_err().to_string();
assert!(e.contains("too complex to combine"), "{e}");
let doc = s.doc().unwrap();
assert!(matches!(doc.node(g).map(|n| &n.kind), Some(NodeKind::BooleanOp { .. })), "the group is kept");
assert_eq!(doc.node(g).map(|n| n.children.len()), Some(2));
}
+3
View File
@@ -557,6 +557,9 @@ pub struct ToolState {
pub dev_mode: bool,
/// Pen snap targets, kept while hovering (see `vectoredit::point_snap`).
pub(crate) pen_targets: Option<crate::vectoredit::point_snap::PenTargets>,
/// Vector edit mode's pen: where the last press that started something new (no path being
/// drawn) was, and the undo depth before it, so a double-click there takes that edit back.
pub(crate) pen_press: Option<(Point, usize)>,
/// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the
/// selection box that stays put (`tool.scaleAnchor`).
pub scale_anchor: (u8, u8),
+1 -1
View File
@@ -26,7 +26,7 @@ impl Session {
let ds = self.ds()?;
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
if self.tool.tool == ToolId::Pen {
return self.pen_finish();
return self.pen_double_click(ev);
}
let edits = !self.tool.dev_mode;
// A frame's or section's name label: rename it there (the canvas puts a field over it).
+5
View File
@@ -8,6 +8,11 @@ impl Session {
if self.tool.dev_mode {
return Ok(false);
}
// Enter while the pen draws ends the path and edits it with the Move tool (#1430).
if name == "Enter" && !alt && !cmd && self.tool.pen.is_some() {
self.pen_enter()?;
return Ok(true);
}
if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
return crate::vectoredit::key(self, name, shift, alt, cmd);
}
+43 -8
View File
@@ -3,6 +3,7 @@ use super::{
Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2,
VectorNetwork, at_45_degrees, frame_at, json, pen_handles,
};
use crate::vectoredit::VTool;
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no
@@ -84,7 +85,7 @@ impl Session {
if self.tool.pen.is_none()
&& let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6))
{
return self.pen_edit(id, Some(v));
return self.edit_vector_with(id, VTool::Pen, Some(v));
}
let pen = self.tool.pen.get_or_insert_with(PenPath::default);
// Clicking the first anchor closes the path.
@@ -103,6 +104,10 @@ impl Session {
self.pen_update()?;
return self.pen_finish();
}
// A press on the last anchor (a double-click's second press) adds no point on top of it.
if pen.points.last().is_some_and(|(q, _, _)| near(q)) {
return Ok(());
}
if pen.parent.is_none() {
pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page));
}
@@ -200,29 +205,59 @@ impl Session {
Ok(())
}
/// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting
/// a new path in the same layer at the next click).
fn pen_edit(&mut self, id: NodeId, anchor: Option<usize>) -> Result<()> {
/// Edit `id` as a vector with `tool` (the pen going on from point `anchor`, or, without one,
/// starting a new path in the same layer at the next click).
fn edit_vector_with(&mut self, id: NodeId, tool: VTool, anchor: Option<usize>) -> Result<()> {
self.set_tool(ToolId::Move);
crate::vectoredit::begin(self, id)?;
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
crate::vectoredit::set_tool(self, tool)?;
crate::vectoredit::set_state(self, |e| {
e.anchor = anchor;
e.vertices = anchor.into_iter().collect();
})
}
/// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the
/// pen, so the next path joins the same vector (another Escape leaves edit mode).
/// Escape while the pen draws: the path ends but its layer stays in edit mode with the pen, so
/// the next path joins the same vector (another Escape leaves edit mode).
pub fn pen_end(&mut self) -> Result<()> {
self.end_pen_path(VTool::Pen)
}
/// Enter (or a double-click) while the pen draws: the path ends and its layer is edited with
/// the Move tool, its points and handles selectable.
pub fn pen_enter(&mut self) -> Result<()> {
self.end_pen_path(VTool::Move)
}
/// End the pen's path and edit its layer with `tool` (nothing to edit before a segment).
fn end_pen_path(&mut self, tool: VTool) -> Result<()> {
let id = self.tool.pen.as_ref().and_then(|p| p.id);
self.pen_finish()?;
match id {
Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None),
Some(id) if self.doc()?.contains(id) => self.edit_vector_with(id, tool, None),
_ => Ok(()),
}
}
/// A double-click with the pen (#1430). While a path is drawn it ends there, as Enter does.
/// Otherwise the double-click only started a path (its second press adds nothing): that is
/// dropped, and the layer under the pointer is edited with the Move tool, as a double-click on
/// it with the Move tool would.
pub(super) fn pen_double_click(&mut self, ev: &PointerEvent) -> Result<()> {
if self.tool.pen.as_ref().is_some_and(|p| p.id.is_some()) {
return self.pen_enter();
}
self.pen_finish()?;
let (doc, page) = {
let ds = self.ds()?;
(ds.doc.clone(), ds.page)
};
match self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|id| crate::vectoredit::enter_targets(&doc, &[*id]).is_some()) {
Some(id) => self.edit_vector_with(id, VTool::Move, None),
None => Ok(()),
}
}
/// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch).
pub fn pen_finish(&mut self) -> Result<()> {
let pen = self.tool.pen.take();
+4 -7
View File
@@ -741,13 +741,10 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re
}
match (name, shift, alt, cmd) {
("Escape", ..) => escape(s)?,
("Enter", ..) => {
if ve.anchor.is_some() {
escape(s)?;
} else {
end(s)?;
}
}
// With the pen, Enter ends its path and goes on editing with the Move tool (#1430); the next
// Enter leaves edit mode.
("Enter", ..) if ve.tool == VTool::Pen => set_tool(s, VTool::Move)?,
("Enter", ..) => end(s)?,
("Backspace" | "Delete", false, false, false) if ve.tool == VTool::Width => {
// The selected width point (nothing else is selected with the Variable width tool).
if let Some(i) = ve.width_point {
@@ -205,6 +205,7 @@ impl Session {
PointerKind::Down => self.vector_down(&ve, ev),
PointerKind::Move => self.vector_drag(&ve, ev).map(|()| true),
PointerKind::Up => self.vector_up(&ve, ev).map(|()| true),
PointerKind::DoubleClick if ve.tool == VTool::Pen => self.pen_double_click_editing(ve.id, ev).map(|()| true),
PointerKind::DoubleClick => {
let tol = 6.0 / ev.zoom.max(1e-6);
let doc = self.doc()?.clone();
@@ -218,6 +219,21 @@ impl Session {
}
}
/// A double-click with the pen in edit mode (#1430): the path ends and the layer `id` goes on
/// being edited with the Move tool. When the double-click began something new (a point split
/// into a segment, or a new path's first point), that edit of its first press is taken back.
fn pen_double_click_editing(&mut self, id: NodeId, ev: &PointerEvent) -> Result<()> {
if let Some((at, depth)) = self.tool.pen_press.take()
&& (at - ev.p()).hypot() * ev.zoom < 8.0
{
while self.undo_depth().0 > depth && self.undo()? {}
}
if editing(self).is_err() {
super::begin(self, id)?;
}
super::set_tool(self, VTool::Move)
}
fn vgesture(&mut self, g: VGesture) {
self.tool.gesture = Gesture::Vector(g);
}
@@ -743,6 +759,7 @@ impl Session {
VTool::Pen => {
let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() };
let Some(plan) = self.pen_plan(ve, h, w.inverse() * at, ev.shift) else { return Ok(true) };
self.tool.pen_press = ve.anchor.is_none().then(|| (ev.p(), self.undo_depth().0));
self.begin_interaction("Pen")?;
let out = ve.pen_out;
let mut v = None;
+24 -1
View File
@@ -65,6 +65,9 @@ struct Registry {
index: HashMap<&'static str, usize>,
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
names: Vec<Option<Vec<&'static str>>>,
/// Whether each command's doc lists a parameter it can't run without (`list` order; see
/// [`doc_needs_params`]).
needs: Vec<bool>,
}
fn registry() -> &'static Registry {
@@ -127,7 +130,8 @@ fn registry() -> &'static Registry {
}
}
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
Registry { list, index, names }
let needs = list.iter().map(|s| doc_needs_params(s.params)).collect();
Registry { list, index, names, needs }
})
}
@@ -147,6 +151,25 @@ pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
}
/// Whether command `id` can't run without parameters: its doc lists one that isn't optional (no
/// `?`), so a call with none is refused (`image.place` needs `data` or `path`). A UI that runs
/// commands without parameters (the Actions palette) leaves these out. `false` for an unknown id
/// or a doc that isn't a `{…}` list.
pub fn needs_params(id: &str) -> bool {
let r = registry();
r.index.get(id).and_then(|i| r.needs.get(*i)).is_some_and(|n| *n)
}
/// Whether a `{…}` params doc lists an item whose name has no `?` (`{data (base64) | path, x?}`:
/// yes; `{ids?, direction? (h|v)}`, `{}` or a doc of another form: no).
fn doc_needs_params(doc: &'static str) -> bool {
let required = |item: &str| {
let first = item.trim().split(|c: char| c.is_whitespace() || c == '(').next().unwrap_or_default();
first.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && !first.contains('?')
};
doc_items(doc).is_some_and(|items| items.into_iter().any(required))
}
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
/// the layers a command acts on (`params::targets` reads either).
+104 -22
View File
@@ -332,17 +332,79 @@ fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
people.iter().map(String::as_str).chain([user]).collect()
}
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
let mut named: Vec<Value> = Vec::new();
for (r, who) in mentions(&m.text, people) {
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
/// The names `text` @mentions by the rule the thread view highlights them with
/// ([`figcraft_doc::mentions`]), and whether each is one of `people` (else it is the letters and
/// digits after the `@`).
fn mentioned<'a>(text: &'a str, people: &[&'a str]) -> impl Iterator<Item = (&'a str, bool)> {
mentions(text, people).into_iter().map(move |(r, who)| (who.or_else(|| text.get(r.start + 1..r.end)).unwrap_or_default(), who.is_some()))
}
/// Whether a message of `c` @mentions `name` (any case), as `comment.list` reports mentions;
/// `name` resolves like one of `people`, so a name nobody in the file has can be several words.
fn thread_mentions(c: &Comment, people: &[&str], name: &str) -> bool {
let people: Vec<&str> = people.iter().copied().chain([name]).collect();
c.messages().any(|m| mentioned(&m.text, &people).any(|(n, _)| n.eq_ignore_ascii_case(name)))
}
/// A list's `mentions` filter: a name (a leading `@` is fine), or none.
fn mentions_param(cmd: &str, p: &Value) -> crate::Result<Option<String>> {
match get(p, "mentions") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(s)) if !s.trim().trim_start_matches('@').trim().is_empty() => {
Ok(Some(s.trim().trim_start_matches('@').trim().to_string()))
}
Some(_) => Err(bad(cmd, "`mentions` must be a name")),
}
}
/// The threads a list's params pick (`list_params`, `frame`, `mentions`), in list order, as
/// `who` sees them; mentions resolve against `people` (`mentionable`).
fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str]) -> crate::Result<Vec<&'s Comment>> {
let (page, filter, sort, text) = list_params(cmd, s, p)?;
let want = mentions_param(cmd, p)?;
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
Ok(query(s.doc()?, &q, who).into_iter().filter(|c| want.as_deref().is_none_or(|w| thread_mentions(c, people, w))).collect())
}
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
for (name, known) in mentioned(text, people) {
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
if !named.contains(&v) {
named.push(v);
}
}
}
/// The most mentions `comment.prompt` lists for one thread (its list stays bounded).
const PROMPT_MAX_MENTIONS: usize = 32;
/// `comment.people`: everyone who wrote or reacted in a comment, and every name a message
/// @mentions (as `comment.list` reports it), once each in any case, sorted as
/// `Document::comment_people` sorts.
fn people_and_mentioned(doc: &Document, you: &str) -> Vec<String> {
let people = doc.comment_people();
let resolve = mentionable(&people, you);
let mut seen = std::collections::HashSet::new();
let mut out: Vec<String> = Vec::new();
let names = people
.iter()
.map(String::as_str)
.chain(doc.comments.iter().flat_map(Comment::messages).flat_map(|m| mentioned(&m.text, &resolve).map(|(n, _)| n)));
for name in names {
if seen.insert(name.to_lowercase()) {
out.push(name.to_string());
}
}
out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
out
}
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
let mut named: Vec<Value> = Vec::new();
push_mentions(&mut named, &m.text, people, user);
json!({
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
@@ -371,9 +433,23 @@ pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) ->
/// Bounded metadata for `comment.prompt`'s `comments` list. Full messages and replies stay in
/// `comment.list`; a prompt over thousands of threads must not repeat all of them here.
fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
fn comment_brief_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
let (x, y) = c.position(doc);
let frame = frame_of(doc, c);
// The names the whole thread @mentions, as `comment.list` reports them per message.
let mut named = Vec::new();
for m in c.messages() {
push_mentions(&mut named, &m.text, people, user);
if named.len() >= PROMPT_MAX_MENTIONS {
break;
}
}
named.truncate(PROMPT_MAX_MENTIONS);
for v in &mut named {
if let Some(name) = v.get("name").and_then(Value::as_str).map(|n| clip_chars(n, 80)) {
v["name"] = json!(name);
}
}
json!({
"id": c.id, "page": c.page,
"pageName": doc.node(c.page).map(|n| clip_chars(&n.name, 80)),
@@ -381,7 +457,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
"replies": c.replies.len(),
"replies": c.replies.len(), "mentions": named,
})
}
@@ -760,15 +836,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"comment.next",
"Next comment",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved?, mentions? (the list, as comment.list), author?} → the thread, as comment.list lists it",
false,
|s, p| {
let dir = num(p, "dir").unwrap_or(1.0);
let doc = s.doc()?;
let who = author(s, p);
let (page, filter, sort, text) = list_params("comment.next", s, p)?;
let ids: Vec<u64> =
query(doc, &Query { page, frame: id(p, "frame"), filter, sort, text: &text }, &who).iter().map(|c| c.id).collect();
let people = s.doc()?.comment_people();
let ids: Vec<u64> = listed("comment.next", s, p, &who, &mentionable(&people, &who))?.iter().map(|c| c.id).collect();
let at = id(p, "id").and_then(|i| ids.iter().position(|x| *x == i.0));
let next = match at {
Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)),
@@ -801,22 +875,28 @@ pub fn specs() -> Vec<CommandSpec> {
let id = req_comment("comment.link", s, p)?;
Ok(json!({"link": link(id)}))
}),
spec!("comment.people", "People in comments", "{}", false, |s, _| {
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
}),
spec!(
"comment.people",
"People in comments",
"{} → {people (everyone who wrote or reacted in a comment, and every name a message @mentions), you}",
false,
|s, _| {
let you = acting_name(s);
Ok(json!({"people": people_and_mentioned(s.doc()?, &you), "you": you}))
}
),
spec!(
"comment.list",
"List comments",
"{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state)}",
"{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state), mentions? (a name, any case: only threads where a message @mentions it, as each message's `mentions` reports)}",
false,
|s, p| {
let doc = s.doc()?;
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
let who = author(s, p);
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
let people = doc.comment_people();
let people = mentionable(&people, &who);
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
let list: Vec<Value> =
listed("comment.list", s, p, &who, &people)?.into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
Ok(json!({"comments": list}))
}
),
@@ -877,7 +957,9 @@ pub fn specs() -> Vec<CommandSpec> {
};
let count = selected.len();
let (text, truncated) = prompt_text(doc, path.as_deref(), &selected);
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who)).collect();
let people = doc.comment_people();
let people = mentionable(&people, &who);
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who, &people)).collect();
Ok(json!({"count": count, "truncated": truncated, "text": text, "comments": comments}))
}
),
+9 -4
View File
@@ -33,7 +33,7 @@ use load::font_report;
#[cfg(test)]
pub(crate) use load::{installed_family, sketch_line};
pub use load::{load, load_bytes, load_path};
pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
use open::append_pages;
@@ -181,11 +181,16 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.recover",
"Recover file",
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
"{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
false,
|s, p| {
let path = req_string("file.recover", p, "path")?;
s.docs.push(recovered(&path)?);
let ds = match string(p, "data") {
Some(d) => recovered_from(
&base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?,
)?,
None => recovered(&req_string("file.recover", p, "path")?)?,
};
s.docs.push(ds);
s.active = s.docs.len() - 1;
Ok(json!({"index": s.active}))
}
+11 -1
View File
@@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul
/// The recovery copy at `path` as a document with unsaved changes and no path.
pub(crate) fn recovered(path: &str) -> Result<DocState> {
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
recovered_from(&read(path)?)
}
/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its
/// copies in the page's storage, not in files).
pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
}
@@ -693,6 +699,10 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
None | Some(Value::Null) => return Ok(None),
Some(v @ Value::Object(_)) => v.clone(),
Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?,
// Capped like the sibling file; the browser has no file system, which `read` says.
#[cfg(not(target_arch = "wasm32"))]
Some(Value::String(path)) => parse(&read_capped(path, MAX_VARIABLES_FILE)?)?,
#[cfg(target_arch = "wasm32")]
Some(Value::String(path)) => parse(&read(path)?)?,
Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")),
};
+1 -1
View File
@@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
}
"figcraft" | "json" | "fig" | "sketch" | "penpot" => {
"figcraft" | "json" | "fig" | "jam" | "sketch" | "penpot" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
+20 -7
View File
@@ -475,13 +475,7 @@ fn collect_flatten_parts(
part
}
_ => {
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
let mut part = VectorNetwork::from_path(&path);
for region in &mut part.regions {
if rule == figcraft_render::FillRule::EvenOdd {
region.winding = WindingRule::EvenOdd;
}
}
let Some(part) = outline_part(doc, node)? else { return Ok(()) };
part
}
};
@@ -490,6 +484,25 @@ fn collect_flatten_parts(
Ok(())
}
/// What a layer other than a vector or a container flattens into: its outline (`None`: it has
/// none). A boolean group has no outline only past the path operations' caps; flattening it would
/// drop it, so that is refused.
fn outline_part(doc: &Document, node: &Node) -> Result<Option<VectorNetwork>> {
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else {
if matches!(node.kind, NodeKind::BooleanOp { .. }) {
return Err(bad("vector.flatten", figcraft_pathops::PathOpsError::TooComplex.to_string()));
}
return Ok(None);
};
let mut part = VectorNetwork::from_path(&path);
for region in &mut part.regions {
if rule == figcraft_render::FillRule::EvenOdd {
region.winding = WindingRule::EvenOdd;
}
}
Ok(Some(part))
}
fn append_flatten_part(
node: &Node,
fallback_fills: Option<&[Paint]>,
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path to a regular file of at most 256 MiB; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+10 -2
View File
@@ -201,8 +201,9 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
}
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
// A file you can't edit now (view only, or on its way to a server): moving around only.
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
// A file you can't edit now (view only, or on its way to a server): moving around only, and
// commenting with a comment link.
if let Some(why) = s.active_read_only().filter(|why| !crate::live::tool_left(why, t)) {
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
}
s.set_tool(t);
@@ -369,6 +370,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!(
"pref.scrollWheelZoom",
"Scroll wheel zoom",
"{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}",
false,
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
+91
View File
@@ -0,0 +1,91 @@
//! A server file joined with a comment link, against a scripted link: comments (new threads,
//! replies, resolving, reactions) are made and sync through the server; every other edit, the
//! editing tools and gestures are refused as with a view link, and a change made outside a command
//! is put back unless it only touches comment threads.
use figcraft_collab::message::ClientMsg;
use figcraft_doc::{Document, NodeKind};
use serde_json::json;
use crate::live::{Access, COMMENT_ONLY, Event, Role, tool_left};
use crate::live_tests::{info, push, relay, serve_as, session};
use crate::tools::ToolId;
use crate::{EngineError, Session};
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value) {
match s.execute(cmd, &params) {
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, COMMENT_ONLY, "{cmd}"),
other => panic!("{cmd}: expected a refusal, got {other:?}"),
}
}
#[test]
fn a_comment_link_lets_you_comment_and_nothing_else() {
let (mut s, script) = session();
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
let mut doc = Document::new("Shared");
let page = doc.first_page().unwrap();
let card = doc.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let (mut server, id, welcome) = serve_as(&doc, Access::Comment);
let mut me = info(Role::Guest, id);
me.access = Access::Comment;
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
s.live_poll();
assert!(s.live().syncing());
assert_eq!(s.active_read_only(), Some(COMMENT_ONLY));
let summary = s.live_summary();
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("comment"), Some(true)));
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
// The comment tool is left, and commenting syncs through the server.
s.execute("tool.set", &json!({"tool": "comment"})).unwrap();
let thread = s.execute("comment.add", &json!({"x": 5, "y": 5, "text": "Bigger logo?"})).unwrap()["id"].as_u64().unwrap();
s.execute("comment.reply", &json!({"id": thread, "text": "Or darker"})).unwrap();
s.execute("comment.react", &json!({"id": thread, "emoji": "👍"})).unwrap();
s.execute("comment.resolve", &json!({"id": thread})).unwrap();
let answers = relay(&script, &mut server, id);
let on_server = server.document().comment(thread).expect("the thread is on the server");
assert!(on_server.resolved && on_server.replies.len() == 1 && !on_server.first.reactions.is_empty());
push(&script, answers);
s.live_poll();
// Everything else is refused before anything is sent, as with a view link.
let before = s.doc().unwrap().clone();
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}));
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}));
refused(&mut s, "edit.delete", json!({"ids": [card.0]}));
refused(&mut s, "tool.set", json!({"tool": "text"}));
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
assert_eq!(*s.doc().unwrap(), before);
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing sent");
// A change made outside a command: put back, unless it only touches comment threads.
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
s.live_sync();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
s.ds_mut().unwrap().doc_mut().comment_mut(thread).unwrap().resolved = false;
s.live_sync();
relay(&script, &mut server, id);
assert!(!server.document().comment(thread).unwrap().resolved, "sent");
// Your other tabs stay yours.
s.active = 0;
assert_eq!(s.active_read_only(), None);
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
}
#[test]
fn only_a_comment_link_leaves_the_comment_tool() {
for t in [ToolId::Move, ToolId::Hand, ToolId::Comment] {
assert!(tool_left(COMMENT_ONLY, t), "{t:?}");
}
assert!(!tool_left(COMMENT_ONLY, ToolId::Rectangle));
assert!(!tool_left(crate::live::VIEW_ONLY, ToolId::Comment) && tool_left(crate::live::VIEW_ONLY, ToolId::Hand));
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
}
#[test]
fn a_local_share_has_no_comment_code() {
let (mut s, _) = session();
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
assert!(e.contains("can edit it"), "{e}");
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
assert!(e.contains("\"comment\""), "{e}");
}
@@ -1,5 +1,6 @@
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
//! them with: resolved against the file's people where it can be, and whether the name is you.
//! Its `mentions` param lists only the threads where a message mentions a name.
use serde_json::{Value, json};
@@ -42,3 +43,101 @@ fn the_mention_rule_matches_the_highlighting_rule() {
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
}
/// The ids of the threads `comment.list` returns for `p`.
fn listed(s: &mut Session, p: Value) -> Vec<u64> {
let l = s.execute("comment.list", &p).unwrap();
l["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect()
}
fn add(s: &mut Session, text: &str, author: &str) -> u64 {
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": text, "author": author})).unwrap()["id"].as_u64().unwrap()
}
#[test]
fn the_mentions_param_lists_only_the_threads_that_mention_a_name() {
let mut s = Session::new();
let claude = add(&mut s, "@Claude make this bigger", "Sam");
let in_reply = add(&mut s, "Spacing looks off", "Sam");
s.execute("comment.reply", &json!({"id": in_reply, "text": "agreed, @claude: tighten it", "author": "Ada Lovelace"})).unwrap();
let other = add(&mut s, "@Ada Lovelace and @Zoe Smith, mail claude@example.com", "Sam");
add(&mut s, "No mentions here", "Sam");
let mut both = listed(&mut s, json!({"mentions": "CLAUDE"}));
both.sort_unstable();
assert_eq!(both, vec![claude, in_reply], "any case, in a reply too, never an email address");
assert_eq!(listed(&mut s, json!({"mentions": " @Claude "})), listed(&mut s, json!({"mentions": "claude"})), "a leading @ is fine");
assert_eq!(listed(&mut s, json!({"mentions": "ada lovelace"})), vec![other]);
assert!(listed(&mut s, json!({"mentions": "Ada"})).is_empty(), "@Ada Lovelace names Ada Lovelace, not Ada");
assert_eq!(listed(&mut s, json!({"mentions": "Zoe Smith"})), vec![other], "a name nobody in the file has, several words");
assert_eq!(listed(&mut s, json!({"mentions": null})).len(), 4);
// Combined with the other filters: resolved threads drop out.
s.execute("comment.resolve", &json!({"id": claude})).unwrap();
assert_eq!(listed(&mut s, json!({"mentions": "Claude", "filter": "open"})), vec![in_reply]);
for junk in [json!(""), json!("@"), json!(5), json!(["Claude"])] {
assert!(s.execute("comment.list", &json!({"mentions": junk})).is_err(), "{junk}");
}
}
#[test]
fn people_lists_the_names_only_mentioned_too() {
let mut s = Session::new();
let id = add(&mut s, "@ada lovelace, @Zoe and @zoe: see me@example.com", "Sam");
s.execute("comment.reply", &json!({"id": id, "text": "On it", "author": "Ada Lovelace"})).unwrap();
s.execute("comment.react", &json!({"id": id, "emoji": "👍", "author": "Lin"})).unwrap();
let p = s.execute("comment.people", &json!({})).unwrap();
// Ada once, spelt as the file spells her; Zoe once in any case; no email address.
assert_eq!(p["people"], json!(["Ada Lovelace", "Lin", "Sam", "Zoe"]));
assert_eq!(p["you"], "You");
}
#[test]
fn people_who_write_in_two_cases_are_listed_once() {
let mut s = Session::new();
let id = add(&mut s, "Hi", "Ada");
s.execute("comment.reply", &json!({"id": id, "text": "Hi @ADA", "author": "ada"})).unwrap();
let p = s.execute("comment.people", &json!({})).unwrap();
assert_eq!(p["people"].as_array().map(Vec::len), Some(1), "{}", p["people"]);
}
#[test]
fn next_and_prompt_carry_the_mentions_too() {
let mut s = Session::new();
let first = add(&mut s, "@Claude make this bigger", "Sam");
let second = add(&mut s, "Spacing looks off", "Sam");
s.execute("comment.reply", &json!({"id": second, "text": "@Claude and @Bob, tighten it", "author": "Ada"})).unwrap();
let plain = add(&mut s, "No mentions here", "Sam");
let claude = json!({"name": "Claude", "known": false, "you": false});
// comment.next returns the thread as comment.list lists it, mentions and all.
let list = s.execute("comment.list", &json!({})).unwrap();
let ids: Vec<u64> = list["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect();
let next = s.execute("comment.next", &json!({"id": ids[0]})).unwrap();
let listed = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == next["id"]).unwrap();
assert_eq!(next["mentions"], listed["mentions"]);
assert_eq!(next["replies"], listed["replies"]);
// Stepping through the threads that mention Claude skips the others.
let n = s.execute("comment.next", &json!({"mentions": "claude"})).unwrap();
let m = s.execute("comment.next", &json!({"mentions": "claude", "id": n["id"]})).unwrap();
let mut both = vec![n["id"].as_u64().unwrap(), m["id"].as_u64().unwrap()];
both.sort_unstable();
assert_eq!(both, vec![first, second]);
assert!(s.execute("comment.next", &json!({"mentions": 5})).is_err());
// comment.prompt: each thread's mentions, the first message's and the replies' together.
let prompt = s.execute("comment.prompt", &json!({})).unwrap();
let mentions = |id: u64| prompt["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap()["mentions"].clone();
assert_eq!(mentions(first), json!([claude]));
assert_eq!(mentions(second), json!([claude, {"name": "Bob", "known": false, "you": false}]));
assert_eq!(mentions(plain), json!([]));
}
#[test]
fn a_prompt_lists_a_bounded_number_of_short_mentions() {
let mut s = Session::new();
let many: String = (0..100).map(|i| format!("@n{i} ")).collect();
let id = add(&mut s, &format!("{many} @{}", "x".repeat(500)), "Sam");
let prompt = s.execute("comment.prompt", &json!({"ids": [id]})).unwrap();
let mentions = prompt["comments"][0]["mentions"].as_array().unwrap().clone();
assert_eq!(mentions.len(), 32);
let id = add(&mut s, &format!("@{}", "y".repeat(500)), "Sam");
let long = s.execute("comment.prompt", &json!({"ids": [id]})).unwrap();
assert!(long["comments"][0]["mentions"][0]["name"].as_str().unwrap().chars().count() <= 81);
}
+2 -1
View File
@@ -137,7 +137,8 @@ fn mentions_and_people() {
let c = add(&mut s, 0.0, 0.0, "Ask @Ada", "Sam");
s.execute("comment.react", &json!({"id": c, "emoji": "👀", "author": "Lin"})).unwrap();
let p = s.execute("comment.people", &json!({})).unwrap();
assert_eq!(p["people"], json!(["Lin", "Sam"]));
// Ada was only @mentioned, and is listed too (#1415).
assert_eq!(p["people"], json!(["Ada", "Lin", "Sam"]));
assert_eq!(p["you"], "You");
// A thread you're mentioned in is yours.
assert!(comment(&s, c).involves("Ada"));
+15 -4
View File
@@ -128,9 +128,10 @@ pub fn normalize(doc: &mut Document, before: &Document) {
if !doc.contains(id) {
continue;
}
let mut chain = vec![id];
chain.extend(doc.ancestors(id));
for c in chain {
// A frame that only moved (or turned) keeps its contents where they are: its own layout
// runs again only for a change that can move or resize them. Its parent's still runs.
let own = (!moved_only(before, doc, id)).then_some(id);
for c in own.into_iter().chain(doc.ancestors(id)) {
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
frames.insert(c);
}
@@ -177,9 +178,19 @@ fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool {
/// layout settings.
fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
let (Some(a), Some(b)) = (before.node(id), doc.node(id)) else { return true };
a.transform != b.transform || reshaped(a, b)
}
/// Whether layer `id` only moved (or turned) since `before`: its transform is all that changed of
/// what [`affects_layout`] looks at, so the layers inside it stay where they are.
fn moved_only(before: &Document, doc: &Document, id: NodeId) -> bool {
before.node(id).zip(doc.node(id)).is_some_and(|(a, b)| !reshaped(a, b))
}
/// What [`affects_layout`] looks at, but where the layer sits in its parent.
fn reshaped(a: &Node, b: &Node) -> bool {
a.width != b.width
|| a.height != b.height
|| a.transform != b.transform
|| a.visible != b.visible
|| a.parent != b.parent
|| a.children != b.children
+4
View File
@@ -1,5 +1,9 @@
//! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and
//! the caller rolls the document back. It exists for bugs and doesn't replace `Result`.
//!
//! The browser build can't catch a panic (wasm32 always aborts, and stable Rust has no unwinding
//! std for it), so there a panicking command stops the page: its panic hook keeps the files with
//! unsaved changes for the next page instead (`apps/figcraft-web/src/recovery.rs`).
use crate::{EngineError, Result};
+57
View File
@@ -94,6 +94,63 @@ fn open_reads_variables_from_param_or_next_to_the_file() {
let _ = std::fs::remove_dir_all(&dir);
}
/// Every document, the active tab and the recent files, compared before and after a refused command.
fn state(s: &Session) -> (String, usize, Vec<String>) {
(format!("{:?}", s.docs), s.active, s.prefs.recent_files.clone())
}
#[test]
fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
let dir = temp_dir("restvars-cap");
let path = dir.join("proto.json");
std::fs::write(&path, serde_json::to_vec(&file()).unwrap()).unwrap();
let path = path.to_string_lossy().to_string();
let vars = dir.join("theme.json");
std::fs::write(&vars, serde_json::to_vec(&variables()).unwrap()).unwrap();
let vars = vars.to_string_lossy().to_string();
// A small file opens and imports as before.
let mut s = Session::new();
s.execute("file.open", &json!({"path": path, "variables": vars})).unwrap();
assert_eq!(named(&s, "Button").bound.get("fill").map(String::as_str), Some(BRAND));
// One byte over the limit (sparse, so nothing is written), a folder, and a device are refused
// before the document changes, by the same check the sibling file goes through.
let big = dir.join("big.json");
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
let mut refused = vec![big.to_string_lossy().to_string(), dir.to_string_lossy().to_string()];
if cfg!(unix) {
refused.push("/dev/null".to_string());
}
s.prefs.recent_files = vec![path.clone()];
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
for bad in &refused {
for cmd in ["file.open", "file.import"] {
let before = state(&s);
let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}");
assert_eq!(state(&s), before, "{cmd} {bad}");
}
}
// Access roots still apply: inside them the file reads, outside it is rejected.
let inside = temp_dir("restvars-cap-root");
std::fs::copy(&vars, inside.join("theme.json")).unwrap();
std::fs::copy(&path, inside.join("proto.json")).unwrap();
let roots = std::sync::Arc::new(crate::file_access::Roots::new(&[inside.to_string_lossy().to_string()], &[]).unwrap());
let inside_vars = inside.join("theme.json").to_string_lossy().to_string();
let inside_path = inside.join("proto.json").to_string_lossy().to_string();
crate::file_access::confined(Some(&roots), || {
s.execute("file.import", &json!({"path": inside_path, "variables": inside_vars})).unwrap();
let before = state(&s);
let e = s.execute("file.import", &json!({"path": inside_path, "variables": vars})).unwrap_err().to_string();
assert!(e.contains("automation path rejected"), "{e}");
assert_eq!(state(&s), before);
});
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&inside);
}
#[test]
fn import_appends_flows_interactions_and_variables() {
let mut s = Session::new();
+55
View File
@@ -0,0 +1,55 @@
//! FigJam `.jam` files (#1342) open and import like `.fig` files: File ▸ Open (`file.open`), the
//! command line (the same command) and File ▸ Import / drag and drop (`file.import`). The file is
//! made here from our own `.fig` export with the canvas header FigJam writes (`fig-jam.`).
use std::io::{Read, Write};
use serde_json::json;
use crate::Session;
/// A `.jam` holding one page with a rectangle named `Card`, written to a temporary file.
fn jam_file(tag: &str) -> std::path::PathBuf {
let mut doc = figcraft_doc::Document::new("Synthetic board");
let page = doc.first_page().unwrap();
doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 10.0, 10.0, 80.0, 40.0).unwrap();
let (fig, _) = figcraft_format::fig::export(&doc, None).unwrap();
let mut from = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap();
let mut to = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..from.len() {
let mut member = from.by_index(i).unwrap();
let mut bytes = Vec::new();
member.read_to_end(&mut bytes).unwrap();
if member.name() == "canvas.fig" {
assert!(bytes.starts_with(b"fig-kiwi"));
bytes.splice(..8, *b"fig-jam.");
}
to.start_file(member.name(), zip::write::SimpleFileOptions::default()).unwrap();
to.write_all(&bytes).unwrap();
}
let path = std::env::temp_dir().join(format!("figcraft-jam-{tag}-{}.jam", std::process::id()));
std::fs::write(&path, to.finish().unwrap().into_inner()).unwrap();
path
}
fn cards(s: &Session) -> usize {
s.doc().unwrap().nodes().filter(|n| n.name == "Card").count()
}
#[test]
fn a_jam_file_opens_and_imports_like_a_fig() {
let path = jam_file("open");
let mut s = Session::new();
s.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(cards(&s), 1, "opened");
assert!(crate::board::is_board(s.doc().unwrap()), "a whiteboard opens as a board");
// Imported into a design file, it adds its pages to it (not an image of its bytes), which
// stays a design file.
s.execute("doc.switch", &json!({"index": 0})).unwrap();
let before = s.doc().unwrap().pages.len();
s.execute("file.import", &json!({"path": path})).unwrap();
assert_eq!(s.doc().unwrap().pages.len(), before + 1);
assert_eq!(cards(&s), 1);
assert!(!crate::board::is_board(s.doc().unwrap()));
std::fs::remove_file(path).unwrap();
}
+10
View File
@@ -187,6 +187,8 @@ mod arc_fields_tests;
#[cfg(test)]
mod arc_handles_tests;
#[cfg(test)]
mod auto_layout_move_tests;
#[cfg(test)]
mod autosave_tests;
#[cfg(test)]
mod batch_continue_events_tests;
@@ -207,6 +209,8 @@ mod board_tests;
#[cfg(test)]
mod board_tools_tests;
#[cfg(test)]
mod boolean_cap_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
@@ -233,6 +237,8 @@ mod collab_undo_tests;
#[cfg(test)]
mod comment_agent_tests;
#[cfg(test)]
mod comment_link_tests;
#[cfg(test)]
mod comment_mentions_tests;
#[cfg(test)]
mod comments_polish_tests;
@@ -349,6 +355,8 @@ mod interaction_rollback_tests;
#[cfg(test)]
mod invite_links_tests;
#[cfg(test)]
mod jam_open_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_path_tests;
@@ -411,6 +419,8 @@ mod pen_end_tests;
#[cfg(test)]
mod pen_overlay_tests;
#[cfg(test)]
mod pen_vector_edit_tests;
#[cfg(test)]
mod penpot_export_tests;
#[cfg(test)]
mod pixel_preview_tests;
+2 -2
View File
@@ -118,7 +118,7 @@ fn links(n: usize, codes: Option<(&str, &str)>) -> Shared {
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
code: code.map(str::to_string),
};
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))), comment: None }
}
/// The file is on the server and joined: its owner's links, and what others join with (codes
@@ -264,7 +264,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null));
assert_eq!(
r["share"],
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null})
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "commentCode": null, "editLink": null, "viewLink": null, "commentLink": null})
);
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
+1 -1
View File
@@ -108,7 +108,7 @@ fn links(file: &str, n: usize) -> Shared {
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
code: Some(code),
};
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))), comment: None }
}
fn session() -> (Session, Arc<Mutex<Script>>) {
+5 -4
View File
@@ -1,5 +1,6 @@
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
//! Ending a pen path with Escape (#760): the path's vector stays in edit mode with the pen, so the
//! next path is drawn into the same layer; another Escape leaves edit mode. (Enter goes on with the
//! Move tool instead: `pen_vector_edit_tests`.)
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
@@ -41,8 +42,8 @@ fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
}
#[test]
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
// Escape, and Enter (its key runs `pen_end`).
fn escape_keeps_the_vector_in_edit_mode_with_the_pen() {
// Escape, and `pen_end` that it runs.
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
for end in ends {
let (mut s, id) = drawn(end);
+121
View File
@@ -0,0 +1,121 @@
//! From the pen into vector edit mode (#1430): a double-click or Enter while the pen is out ends
//! the path and edits the vector with the Move tool's point handles, instead of drawing on (a
//! double-click used to leave a zero-length segment, or a stray two-point vector on the layer it
//! was meant to edit). Escape still keeps the pen (#760).
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::vectoredit::VTool;
use figcraft_doc::{NodeId, NodeKind};
fn press(s: &mut Session, kinds: &[PointerKind], x: f64, y: f64) {
for kind in kinds {
s.pointer(&PointerEvent::at(*kind, x, y, 1.0)).unwrap();
}
}
fn click(s: &mut Session, x: f64, y: f64) {
press(s, &[PointerKind::Down, PointerKind::Up], x, y);
}
/// A double-click as the canvas sends it: two clicks, then the double-click.
fn double_click(s: &mut Session, x: f64, y: f64) {
press(s, &[PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick], x, y);
}
fn layers(s: &Session) -> Vec<NodeId> {
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone()
}
/// (points, segments) of vector `id`.
fn shape(s: &Session, id: NodeId) -> (usize, usize) {
match &s.doc().unwrap().node(id).unwrap().kind {
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
k => panic!("not a vector: {k:?}"),
}
}
/// The layer and tool vector edit mode has (with no pen path open).
fn editing(s: &Session) -> Option<(NodeId, VTool)> {
s.ds().unwrap().vector_edit.as_ref().filter(|v| v.anchor.is_none()).map(|v| (v.id, v.tool))
}
/// A pen path from (100, 100) to (200, 100), still being drawn; its layer.
fn drawing() -> (Session, NodeId) {
let mut s = Session::new();
s.set_tool(ToolId::Pen);
click(&mut s, 100.0, 100.0);
click(&mut s, 200.0, 100.0);
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
(s, id)
}
#[test]
fn a_double_click_with_the_pen_on_a_layer_edits_it_instead_of_drawing() {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
let [rect] = <[NodeId; 1]>::try_from(layers(&s)).unwrap();
s.set_tool(ToolId::Pen);
double_click(&mut s, 150.0, 100.0);
assert_eq!(layers(&s), vec![rect], "no stray vector drawn");
assert_eq!(editing(&s), Some((rect, VTool::Move)), "its points are edited with the Move tool");
assert!(s.tool.pen.is_none());
assert_eq!(s.tool.tool, ToolId::Move);
// Off any layer the double-click draws nothing either.
let mut s = Session::new();
s.set_tool(ToolId::Pen);
double_click(&mut s, 400.0, 400.0);
assert!(layers(&s).is_empty() && s.tool.pen.is_none() && editing(&s).is_none());
}
#[test]
fn a_double_click_while_drawing_ends_the_path_there_and_edits_it() {
let (mut s, id) = drawing();
let undo = s.undo_depth().0;
double_click(&mut s, 200.0, 200.0);
assert_eq!(shape(&s, id), (3, 2), "the double-click's point once, no zero-length segment");
assert!(s.tool.pen.is_none());
assert_eq!(editing(&s), Some((id, VTool::Move)));
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
}
#[test]
fn enter_while_drawing_ends_the_path_and_edits_it_with_the_move_tool() {
let (mut s, id) = drawing();
assert!(s.tool_key("Enter", false, false, false).unwrap());
assert!(s.tool.pen.is_none());
assert_eq!(editing(&s), Some((id, VTool::Move)));
assert_eq!(shape(&s, id), (2, 1));
// The next Enter leaves edit mode.
assert!(s.tool_key("Enter", false, false, false).unwrap());
assert!(s.ds().unwrap().vector_edit.is_none());
assert_eq!(s.selection(), vec![id]);
}
#[test]
fn in_edit_mode_the_pen_goes_to_the_move_tool_on_a_double_click_or_enter() {
// Escape keeps the pen in edit mode (#760); a double-click on the path then edits it rather
// than adding a point where it landed.
let (mut s, id) = drawing();
s.escape().unwrap();
assert_eq!(editing(&s), Some((id, VTool::Pen)));
double_click(&mut s, 150.0, 100.0);
assert_eq!(editing(&s), Some((id, VTool::Move)));
assert_eq!(shape(&s, id), (2, 1), "no point split into the segment");
// Off the path: no lone point left behind.
let (mut s, id) = drawing();
s.escape().unwrap();
double_click(&mut s, 150.0, 300.0);
assert_eq!((editing(&s), shape(&s, id)), (Some((id, VTool::Move)), (2, 1)));
// Enter ends the pen's path in edit mode and takes the Move tool; the next one leaves.
let (mut s, id) = drawing();
s.escape().unwrap();
click(&mut s, 150.0, 300.0);
click(&mut s, 250.0, 300.0);
assert!(s.tool_key("Enter", false, false, false).unwrap());
assert_eq!((editing(&s), shape(&s, id)), (Some((id, VTool::Move)), (4, 2)));
assert!(s.tool_key("Enter", false, false, false).unwrap());
assert!(s.ds().unwrap().vector_edit.is_none());
}
+4
View File
@@ -29,6 +29,9 @@ pub struct Prefs {
pub highlight_on_hover: bool,
pub number_keys_opacity: bool,
pub show_dimensions: bool,
/// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the
/// other way round). Trackpads are unchanged.
pub scroll_wheel_zoom: bool,
/// "system" by default; an explicit "light" or "dark" choice stays saved.
pub theme: String,
/// The widths of the main window's left and right panels (the interface keeps them within its
@@ -122,6 +125,7 @@ impl Default for Prefs {
highlight_on_hover: true,
number_keys_opacity: true,
show_dimensions: true,
scroll_wheel_zoom: false,
theme: "system".into(),
panel_widths: (240.0, 240.0),
pages_height: None,
+5 -2
View File
@@ -186,12 +186,15 @@ impl Session {
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
/// read-only: refuse an edit (it is rolled back). With a comment link, one that changes only
/// comment threads goes through.
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
match self.live_read_only(*uid) {
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) && !self.live_comment_edit(*uid, &ds.doc) => {
Err(EngineError::Disabled(id.to_string(), why.into()))
}
_ => Ok(()),
}
}
+25
View File
@@ -180,6 +180,31 @@ pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
branches
}
/// The tabs showing branches of the file in tab `uid`.
pub fn tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
}
/// A file whose changes would be lost, so it needs a recovery copy: in its document, its history
/// or one of its branch tabs (a branch tab's edits are saved with its main file, so the tab has no
/// copy of its own).
pub fn needs_copy(s: &Session, d: &DocState) -> bool {
d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty))
}
/// What a recovery copy of `d` holds: the document itself (not a version or live text preview
/// shown in its place: a crash during a preview must bring the file back, not the version),
/// its version history and its branches as they are now ([`current`]).
pub fn recovery_copy(s: &Session, d: &DocState) -> (Arc<Document>, Vec<figcraft_format::Version>, Vec<Branch>) {
(d.document_itself().clone(), d.versions.clone(), current(s, d.uid))
}
/// The revisions a recovery copy of `d` is of: its own and its branch tabs' (a branch tab's edits
/// change only its own revision). A new copy is due when they change.
pub fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
std::iter::once(d.revision).chain(tabs(s, d.uid).map(|t| t.revision)).collect()
}
/// The tab a save writes: the main file's for a branch tab, else the active tab.
pub fn file_tab(s: &Session) -> usize {
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
+18
View File
@@ -386,6 +386,24 @@ pub const NO_TEAM_LIBRARIES: &str = "team libraries on a server need the desktop
/// Why an edit of a server file you joined with a view link is refused.
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
/// Why an edit other than to comment threads of a server file you joined with a comment link is
/// refused.
pub const COMMENT_ONLY: &str = "Can comment: the link you joined with lets you see this file and comment on it, not change it";
/// Why a session document can't be edited with `access` (`None`: it can).
pub(crate) fn limited_by(access: Access) -> Option<&'static str> {
match access {
Access::Edit => None,
Access::View => Some(VIEW_ONLY),
Access::Comment => Some(COMMENT_ONLY),
}
}
/// Whether `tool` is one a file you can't edit now leaves you (`why`: what
/// [`Session::live_read_only`] says): moving around and, with a comment link, commenting.
pub fn tool_left(why: &str, tool: ToolId) -> bool {
matches!(tool, ToolId::Move | ToolId::Hand) || (why == COMMENT_ONLY && tool == ToolId::Comment)
}
/// Why an edit of a file on its way to a server is refused.
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
+17 -7
View File
@@ -2,7 +2,7 @@ use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
use super::{
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
ToolId, VIEW_ONLY, display_name, editing_hint, editing_of, sanitize,
ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
};
/// What a live session's document holding components that contain each other's instances shows
@@ -410,8 +410,11 @@ impl Session {
}
_ => return,
};
// A view link: the editing tools are off.
if client.access() == Access::View && self.tab() == Some(uid) && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
// A view or comment link: the editing tools are off.
if let Some(why) = limited_by(client.access())
&& self.tab() == Some(uid)
&& !tool_left(why, self.tool.tool)
{
self.set_tool(ToolId::Move);
}
let Some(live) = self.live.sessions.get_mut(&id) else { return };
@@ -439,18 +442,25 @@ impl Session {
self.message = Some(reason);
}
/// Why document `uid` can't be edited now, if it can't: you may only view it in its session,
/// or it is on its way to a server.
/// Why document `uid` can't be edited now, if it can't: you may only view it (or view and
/// comment on it: see [`live_comment_edit`](Self::live_comment_edit)) in its session, or it is
/// on its way to a server.
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
let live = self.live.doc(uid)?;
match &live.client {
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
Some(_) => None,
Some(c) => limited_by(c.access()),
None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING),
None => None,
}
}
/// Whether `doc` is session document `uid` with nothing but comment threads changed, and the
/// link it was joined with lets you comment: such an edit goes through where others are refused.
pub(crate) fn live_comment_edit(&self, uid: u64, doc: &Document) -> bool {
let client = self.live.doc(uid).and_then(|l| l.client.as_ref());
client.is_some_and(|c| c.access() == Access::Comment && c.changes_only_comments(doc))
}
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
pub fn active_read_only(&self) -> Option<&'static str> {
self.tab().and_then(|uid| self.live_read_only(uid))
+20 -14
View File
@@ -82,8 +82,17 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
Ok(id)
}
/// Why a local network share has no view-only code.
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code";
/// Why a local network share has no view-only or comment code.
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
/// Why a share has no comment code.
const NO_COMMENT_LINK: &str =
"this share has no comment code (its server is older than comment links, or it was shared before them): stop sharing and share again";
/// The `access` parameter of `cmd`, if given.
fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
}
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "collab.share";
@@ -92,10 +101,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
Some("local") => Via::Local,
Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")),
};
let access = match string(p, "access") {
None => None,
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
};
let access = access_param(CMD, p)?;
// A share of this file is there already: only which code is shown changes, or (the file is
// on the server, not shared at the moment) it gets new links and codes. Nothing is published
// twice.
@@ -106,7 +112,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
));
}
if access == Some(Access::View) && shared == Via::Local {
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
return Err(bad(CMD, LOCAL_EDITS));
}
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
@@ -114,6 +120,9 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
shared_now(s, Some(clean_shared(links)));
}
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
if sh.links.as_ref().is_some_and(|l| l.of(a).is_none()) {
return Err(bad(CMD, NO_COMMENT_LINK));
}
sh.access = a;
}
return Ok(s.live_summary());
@@ -121,7 +130,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
let access = access.unwrap_or_default();
let mut server = String::new();
let id = match via {
Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)),
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
Via::Online => {
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
@@ -136,7 +145,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
// what the link says: where the session is and its secret. (Online they arrive with the file.)
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
let code = code::Local::from_link(&i.link).map(|l| l.code());
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None }
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
});
live.share = Some(Share { via, server, access, links });
}
@@ -155,6 +164,7 @@ pub(crate) fn clean_shared(mut links: Shared) -> Shared {
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
clean(&mut links.edit);
links.view.as_mut().map(clean);
links.comment.as_mut().map(clean);
links
}
@@ -217,11 +227,7 @@ pub(super) fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
"unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false),
"links" => Admin::Links { file: file.clone() },
"createLink" => {
let access = match string(p, "access").as_deref().map(str::trim) {
None | Some("view") => Access::View,
Some("edit") => Access::Edit,
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
};
let access = access_param(CMD, p)?.unwrap_or(Access::View);
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
}
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
+5 -5
View File
@@ -1,5 +1,5 @@
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
use super::types::{Published, Role, SessionId, Share, Status, join_target};
use super::types::{Published, Role, SessionId, Share, Shared, Status, join_target};
use super::{
Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
};
@@ -53,7 +53,7 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
// file. Anything else (a session this app hosts, one it joined, a file not shared now) ends.
let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref());
let ids: Vec<String> = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
let ids: Vec<String> = shared.into_iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?;
shared_now(s, None);
@@ -174,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.share",
"Share with a join code",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
false,
share
),
@@ -216,7 +216,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.manageFile",
"Manage published file",
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | comment | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, commentCode, editLink, viewLink, commentLink} | {done}",
false,
manage_file
),
@@ -230,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.peers",
"People in this file",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
false,
|s, _| Ok(s.live_summary())
),
+6 -3
View File
@@ -1,7 +1,7 @@
use super::host::hosting_json;
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
use super::{
Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
editing_of, json,
};
@@ -45,7 +45,7 @@ impl Session {
return;
}
let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone());
if self.live_read_only(uid).is_some() {
if self.live_read_only(uid).is_some() && !self.live_comment_edit(uid, &doc) {
// Nothing goes out: what changed outside a command is put back.
if let Some(s) = synced {
self.apply_remote(uid, s);
@@ -322,7 +322,8 @@ impl Session {
"published": l.published,
"code": l.share.as_ref().and_then(Share::code),
"share": l.share.as_ref().map(|sh| {
let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref()));
let of = |a| sh.links.as_ref().and_then(|s| s.of(a));
let (edit, view, comment) = (of(Access::Edit), of(Access::View), of(Access::Comment));
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
json!({
"via": sh.via,
@@ -330,8 +331,10 @@ impl Session {
"shared": sh.links.is_some(),
"editCode": code(edit),
"viewCode": code(view),
"commentCode": code(comment),
"editLink": edit.map(|l| l.link.as_str()),
"viewLink": view.map(|l| l.link.as_str()),
"commentLink": comment.map(|l| l.link.as_str()),
})
}),
"hosting": l.hosting.as_ref().map(hosting_json),
+23 -4
View File
@@ -117,7 +117,8 @@ pub struct SessionInfo {
pub address: String,
/// Reachable from other machines (the host listens on a network address, not loopback).
pub network: bool,
/// What you may do: edit, or only view (a server's view link).
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
/// link).
pub access: Access,
}
@@ -164,12 +165,31 @@ pub struct SharedLink {
pub code: Option<String>,
}
/// What others join a shared file with: to edit it and, on a server, to only view it.
/// What others join a shared file with: to edit it and, on a server, to only view it or to view
/// and comment on it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Shared {
pub edit: SharedLink,
pub view: Option<SharedLink>,
/// `None`: a server older than comment links, or a share made before them.
pub comment: Option<SharedLink>,
}
impl Shared {
/// The way in that gives `access`, if there is one.
pub fn of(&self, access: Access) -> Option<&SharedLink> {
match access {
Access::Edit => Some(&self.edit),
Access::View => self.view.as_ref(),
Access::Comment => self.comment.as_ref(),
}
}
/// Every way in (what stopping revokes).
pub fn all(&self) -> impl Iterator<Item = &SharedLink> {
std::iter::once(&self.edit).chain(&self.view).chain(&self.comment)
}
}
/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the
@@ -190,8 +210,7 @@ pub struct Share {
impl Share {
/// The way in to show: the one for [`access`](Self::access).
fn shown(&self) -> Option<&SharedLink> {
let links = self.links.as_ref()?;
if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) }
self.links.as_ref()?.of(self.access)
}
/// The code to show, grouped (`K7M-2QX-9TD`).
+8 -6
View File
@@ -161,6 +161,7 @@ impl OnlineFile {
let usable = |l: &SharedLink| link(&l.link) && l.id.len() <= 64 && !l.id.contains(char::is_control);
self.shared = self.shared.take().filter(|s| usable(&s.edit)).map(|mut s| {
s.view = s.view.take().filter(usable);
s.comment = s.comment.take().filter(usable);
clean_shared(s)
});
Some(self)
@@ -384,9 +385,7 @@ impl Session {
let Some(f) = self.prefs.online_files.get_mut(i) else { return };
let before = (f.name.clone(), f.shared.is_some());
// Links revoked from elsewhere no longer let anyone in.
let live = |f: &OnlineFile, o: &OwnedFile| {
f.shared.as_ref().is_some_and(|s| o.links.contains(&s.edit.id) && s.view.as_ref().is_none_or(|v| o.links.contains(&v.id)))
};
let live = |f: &OnlineFile, o: &OwnedFile| f.shared.as_ref().is_some_and(|s| s.all().all(|l| o.links.contains(&l.id)));
let shared = match answer {
Some(o) => {
f.state = FileState::Online;
@@ -429,6 +428,7 @@ impl Session {
"shared": f.shared.is_some(),
"code": code(f.shared.as_ref().map(|s| &s.edit)),
"viewCode": code(f.shared.as_ref().and_then(|s| s.view.as_ref())),
"commentCode": code(f.shared.as_ref().and_then(|s| s.comment.as_ref())),
"state": f.state,
"expires": f.expires,
"session": session.map(|l| l.id),
@@ -590,14 +590,16 @@ pub(crate) fn removed(s: &mut Session, server: &str, file: &str) {
pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, on: bool) -> Result<Value> {
let shared = s.online_index(server, file).and_then(|i| s.prefs.online_files.get(i)).and_then(|f| f.shared.clone());
let result = |links: Option<&Shared>| {
let (edit, view) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()));
let (edit, view, comment) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()), links.and_then(|l| l.comment.as_ref()));
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
json!({
"shared": links.is_some(),
"code": code(edit),
"viewCode": code(view),
"commentCode": code(comment),
"editLink": edit.map(|l| l.link.as_str()),
"viewLink": view.map(|l| l.link.as_str()),
"commentLink": comment.map(|l| l.link.as_str()),
})
};
// As asked already: nothing to tell the server.
@@ -607,7 +609,7 @@ pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, o
if !on && shared.is_none() {
return Err(bad(cmd, "the links others got aren't recorded on this computer: revoke them by id (action: links, then revoke)"));
}
let revoke: Vec<String> = shared.iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
let revoke: Vec<String> = shared.iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
let req = NetRequest::Share { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), revoke, create: on };
let links = crate::live::share_answer(s, req)?.map(clean_shared);
if on && links.is_none() {
@@ -622,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.files",
"Your online files",
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode (the join codes, while shared), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode, commentCode (the join codes, while shared; commentCode null from a server older than comment links), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
false,
files
),
+18
View File
@@ -89,3 +89,21 @@ fn batch_steps_refuse_unknown_keys_and_parameters() {
err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
}
#[test]
fn commands_that_cant_run_without_parameters_say_so() {
// A parameter without `?` (or one of alternatives) is needed; `?` ones and none aren't.
// (`file.placeImage` too: its menu item asks for the file, a call has to name it.)
for id in ["image.place", "file.placeImage", "comment.add", "comment.reply", "tool.set"] {
assert!(crate::cmd::needs_params(id), "{id}");
}
for id in ["view.zoomIn", "edit.delete", "edit.undo", "no.such.command"] {
assert!(!crate::cmd::needs_params(id), "{id}");
}
// What it says holds: such a command refuses no parameters.
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
for id in ["image.place", "comment.add", "tool.set"] {
assert!(s.execute(id, &json!({})).is_err(), "{id} ran without its parameters");
}
}
+30
View File
@@ -270,6 +270,36 @@ fn variables_import_checks_what_it_reads() {
healthy(&s);
}
#[test]
fn variables_import_caps_the_file_it_reads() {
let dir = std::env::temp_dir().join(format!("figcraft-vars-io-cap-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut s = Session::new();
theme(&mut s);
let small = dir.join("theme.json").to_string_lossy().into_owned();
ex(&mut s, "variable.export", json!({"path": small}));
let mut t = Session::new();
ex(&mut t, "variable.createCollection", json!({"name": "Other"}));
// One byte over the limit (sparse, so nothing is written), a folder, and a device: refused,
// nothing changes.
let big = dir.join("big.json");
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
let mut refused = vec![big.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()];
if cfg!(unix) {
refused.push("/dev/null".to_string());
}
for bad in &refused {
let before = (format!("{:?}", t.docs), t.active);
let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}");
assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
}
// A file under the limit still imports.
ex(&mut t, "variable.import", json!({"variables": small}));
assert_eq!(t.doc().unwrap().variables.collections.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
/// A rectangle filled with a three-stop linear gradient, and colour variables `brand` and `ink`.
fn gradient_rect(s: &mut Session) -> NodeId {
let r = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})));
+14 -2
View File
@@ -17,6 +17,10 @@
mod auto_layout_sizing_tests;
#[cfg(test)]
mod connector_label_tests;
#[cfg(test)]
mod deep_import_tests;
#[cfg(test)]
mod expansion_cost_tests;
mod export;
#[cfg(test)]
mod export_tests;
@@ -51,7 +55,7 @@ use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
use figcraft_doc::Document;
use figcraft_doc::{Document, FileKind};
pub use export::export;
@@ -81,6 +85,9 @@ pub(crate) fn layer_budget(packed: usize) -> usize {
(packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES)
}
/// The canvas header of a FigJam whiteboard (a `.jam`); a `.fig` has `fig-kiwi`.
pub const JAM_HEADER: &[u8] = b"fig-jam.";
/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
pub fn is_fig(bytes: &[u8]) -> bool {
bytes.starts_with(b"fig-") || (bytes.starts_with(b"PK") && zip_has_canvas(bytes))
@@ -102,6 +109,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
// inflate too).
let packed = archive.canvas.len().min(bytes.len());
// FigJam writes its whiteboards with this header: they open as boards.
let board = archive.canvas.starts_with(JAM_HEADER);
// Each stage's input is dropped as soon as the next one is built: in a large file every one
// of them takes gigabytes, and holding them all at once runs out of memory.
let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?;
@@ -115,7 +124,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
let embedded = embedded_copies(&msg);
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
if board {
doc.kind = FileKind::Board;
}
// Both stages stop at the same cap and say so in the same words: one note (#981).
for w in more.into_iter().filter_map(map::adapt_warning) {
let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b);
+37 -1
View File
@@ -4,7 +4,12 @@ use super::*;
/// The synthetic FigJam schema adds generated layout and text alignment records.
fn board_schema() -> Schema {
Schema::parse(&SCHEMA.replace(
board_schema_of(SCHEMA)
}
/// [`board_schema`] over another base schema.
fn board_schema_of(base: &str) -> Schema {
Schema::parse(&base.replace(
"\nmessage Blob ",
", GeneratedData nodeGenerationData, GeneratedData derivedImmutableFrameData, string textAlignHorizontal, string textAlignVertical\nmessage GeneratedData NodeChange[] overrides, uint version\nmessage Blob ",
))
@@ -97,6 +102,7 @@ fn imported(layer: Kv<'static>) -> (Document, Vec<String>) {
fn generated_stickies_and_shapes_keep_their_background_text_and_author() {
for ty in ["STICKY", "SHAPE_WITH_TEXT"] {
let (doc, warnings) = imported(board_node(ty));
assert_eq!(doc.kind, figcraft_doc::FileKind::Design, "the same layers in a .fig stay a design file");
let frame = named(&doc, "Board object");
assert!(matches!(frame.kind, NodeKind::Frame));
assert_eq!(frame.opacity, 0.5);
@@ -417,3 +423,33 @@ fn outer_sticky_body_survives_alongside_the_generated_author() {
}
}
}
/// A FigJam `.jam` file (#1342) is the same archive as a `.fig` with a `fig-jam.` header on its
/// canvas (synthetic here, never a real file): it imports the same way, as a board, notes and
/// shapes with text as their frames, a table as its stand-in, and a layer type the importer doesn't know is
/// left out with a note instead of failing the file.
#[test]
fn a_jam_file_imports_like_a_fig() {
let schema = board_schema_of(&SCHEMA.replace("CONNECTOR TABLE", "CONNECTOR TABLE SYNTHETIC_JAM_ONLY"));
let layers = vec![
node(0, None, "DOCUMENT", "Synthetic board", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
board_node("STICKY"),
node(3, Some((1, "\"")), "TABLE", "Synthetic table", vec![("size", xy(200.0, 100.0)), ("transform", at(400.0, 0.0))]),
node(4, Some((1, "#")), "SYNTHETIC_JAM_ONLY", "Unknown object", vec![("size", xy(50.0, 50.0)), ("transform", at(0.0, 400.0))]),
];
let msg = o(vec![("nodeChanges", arr(layers)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))]);
let mut jam = canvas(&schema, &msg);
jam.splice(..8, *b"fig-jam.");
for bytes in [jam.clone(), zip_file(&jam)] {
assert!(is_fig(&bytes), "read as the same archive");
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
let note = named(&doc, "Board object");
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Files nested as deep as the importers go, on the browser's stack (#1101): a `.fig` file and a
//! REST file 256 layers deep (and deeper, which the importers cut off) import on a 1 MB stack, the
//! size wasm32 gets. The REST import's layer walk used about 4 KB of stack a level, a megabyte at
//! 256; it now runs on a work list. The `.fig` mapping stays recursive at about 1.8 KB a level.
use serde_json::{Value, json};
use super::kiwi::Kv;
use super::map;
/// The stack wasm32 runs on.
const WASM_STACK: usize = 1 << 20;
fn on_wasm_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
std::thread::Builder::new().stack_size(WASM_STACK).spawn(f).unwrap().join().unwrap()
}
type Fields = Vec<(&'static str, Kv<'static>)>;
fn o(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn g(id: u64) -> Kv<'static> {
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, parent: Option<u64>, ty: &'static str) -> Kv<'static> {
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(format!("L{id}")))];
if let Some(parent) = parent {
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("a".into()))])));
}
o(fields)
}
/// A `.fig` message: a page holding `depth` frames, each inside the last.
fn deep_fig(depth: u64) -> Kv<'static> {
let mut nodes = vec![node(1, None, "DOCUMENT"), node(2, Some(1), "CANVAS")];
nodes.extend((0..depth).map(|i| node(10 + i, Some(if i == 0 { 2 } else { 9 + i }), "FRAME")));
o(vec![("nodeChanges", Kv::Array(nodes))])
}
/// A REST file: a page holding `depth` frames, each inside the last.
fn deep_rest(depth: usize) -> Value {
let mut layer = json!({"id": format!("1:{depth}"), "type": "FRAME", "name": "Innermost"});
for i in (0..depth - 1).rev() {
layer = json!({"id": format!("1:{i}"), "type": "FRAME", "name": format!("L{i}"), "children": [layer]});
}
json!({"name": "Deep", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [layer]}]}})
}
/// The `.fig` import of a file `depth` frames deep on the wasm stack: (layers, warnings).
fn fig_import(depth: u64) -> (usize, Vec<String>) {
on_wasm_stack(move || {
let msg = deep_fig(depth);
let (json, mut warnings, networks) = map::to_rest(&msg, "Deep", 2_000_000);
let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, 2_000_000).unwrap();
warnings.extend(more);
(doc.node_count(), warnings)
})
}
#[test]
fn a_fig_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
let (layers, warnings) = fig_import(256);
assert_eq!(layers, 257, "the page and every frame");
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
// Deeper than that, the rest is cut off with a note (still on the small stack).
let (layers, warnings) = fig_import(400);
assert!(layers < 400, "{layers}");
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
}
#[test]
fn a_rest_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
let (layers, warnings) = on_wasm_stack(|| {
let v = deep_rest(256);
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
(doc.node_count(), warnings)
});
assert_eq!(layers, 257, "the page and every frame");
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
let (layers, warnings) = on_wasm_stack(|| {
let v = deep_rest(400);
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
(doc.node_count(), warnings)
});
assert!(layers < 400, "{layers}");
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
}
#[test]
fn the_work_list_keeps_the_files_order_and_fits_groups_to_their_children() {
// Siblings in order, depth first, and a group's bounds from its children once they are in.
let v = json!({"name": "Order", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [
{"id": "1:1", "type": "GROUP", "name": "G", "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1, "height": 1}, "children": [
{"id": "1:2", "type": "RECTANGLE", "name": "A", "absoluteBoundingBox": {"x": 10, "y": 20, "width": 30, "height": 40}},
{"id": "1:3", "type": "FRAME", "name": "B", "absoluteBoundingBox": {"x": 50, "y": 20, "width": 10, "height": 10}, "children": [
{"id": "1:4", "type": "RECTANGLE", "name": "C", "absoluteBoundingBox": {"x": 52, "y": 22, "width": 2, "height": 2}}
]}
]},
{"id": "1:5", "type": "RECTANGLE", "name": "D", "absoluteBoundingBox": {"x": 0, "y": 100, "width": 5, "height": 5}}
]}]}});
let (doc, _) = crate::rest::import(&v, &|_: &str| None).unwrap();
let page = doc.first_page().unwrap();
let name = |id| doc.node(id).unwrap().name.clone();
let top: Vec<String> = doc.node(page).unwrap().children.iter().map(|c| name(*c)).collect();
assert_eq!(top, ["G", "D"]);
let g = doc.node(page).unwrap().children[0];
let kids: Vec<String> = doc.node(g).unwrap().children.iter().map(|c| name(*c)).collect();
assert_eq!(kids, ["A", "B"]);
let b = doc.node(g).unwrap().children[1];
assert_eq!(doc.node(b).unwrap().children.iter().map(|c| name(*c)).collect::<Vec<_>>(), ["C"]);
let group = doc.node(g).unwrap();
assert_eq!((group.width, group.height), (50.0, 40.0), "the group fits A and B");
}
@@ -0,0 +1,89 @@
//! Instance expansion stays near linear on crafted files (#1077): variable nodes under a main
//! aren't walked again for every instance (they were never layers, so the layer cap never stopped
//! them), and a nested instance takes only the outer instance's override rows addressed below it
//! instead of copying all of them. Overrides still reach the layers they name.
use std::time::{Duration, Instant};
use figcraft_doc::{Document, NodeId};
use super::kiwi::Kv;
use super::map;
type Fields = Vec<(&'static str, Kv<'static>)>;
fn o(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn g(id: u64) -> Kv<'static> {
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
}
fn node(id: u64, parent: Option<u64>, ty: &'static str, name: &str, more: Fields) -> Kv<'static> {
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))])));
}
fields.extend(more);
o(fields)
}
fn instance_of(main: u64, overrides: Vec<Kv<'static>>) -> Fields {
vec![("symbolData", o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]))]
}
/// Map and import `nodes`; the document and how long it took.
fn import(nodes: Vec<Kv<'static>>) -> (Document, Duration) {
let msg = o(vec![("nodeChanges", Kv::Array(nodes))]);
let start = Instant::now();
let (json, _, networks) = map::to_rest(&msg, "Crafted", 2_000_000);
let took = start.elapsed();
let (doc, _) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, 2_000_000).unwrap();
(doc, took)
}
fn page_children(doc: &Document) -> Vec<NodeId> {
let page = doc.first_page().unwrap();
doc.node(page).unwrap().children.clone()
}
#[test]
fn variable_nodes_under_a_main_are_not_walked_for_every_instance() {
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
nodes.push(node(10, Some(2), "SYMBOL", "Main", vec![]));
nodes.push(node(11, Some(10), "RECTANGLE", "R", vec![]));
nodes.extend((0..5_000).map(|i| node(100_000 + i, Some(10), "VARIABLE", "V", vec![])));
nodes.extend((0..1_000).map(|i| node(500_000 + i, Some(2), "INSTANCE", "I", instance_of(10, vec![]))));
let (doc, took) = import(nodes);
// Five million needless visits took seconds; now they are skipped.
assert!(took < Duration::from_secs(5), "{took:?}");
let kids = page_children(&doc);
assert_eq!(kids.len(), 1_001, "the main and its instances");
assert!(kids.iter().all(|k| doc.node(*k).unwrap().children.len() == 1), "each holds the rectangle and no variable layer");
}
#[test]
fn nested_instances_take_only_the_override_rows_below_them() {
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
nodes.push(node(10, Some(2), "SYMBOL", "Inner", vec![]));
nodes.push(node(11, Some(10), "RECTANGLE", "R", vec![]));
nodes.push(node(20, Some(2), "SYMBOL", "Outer", vec![]));
nodes.extend((0..1_000).map(|i| node(30_000 + i, Some(20), "INSTANCE", "Nested", instance_of(10, vec![]))));
// 20 000 rows spread over the nested instances (for layers they don't have), and one that
// hides the rectangle of nested instance 7.
let mut rows: Vec<Kv<'static>> = (0..20_000)
.map(|i| o(vec![("guidPath", o(vec![("guids", Kv::Array(vec![g(30_000 + i % 1_000), g(900_000 + i)]))])), ("visible", Kv::Bool(false))]))
.collect();
rows.push(o(vec![("guidPath", o(vec![("guids", Kv::Array(vec![g(30_007), g(11)]))])), ("visible", Kv::Bool(false))]));
nodes.push(node(40, Some(2), "INSTANCE", "Outer instance", instance_of(20, rows)));
let (doc, took) = import(nodes);
// Each nested instance copied all 20 000 rows: twenty million copies.
assert!(took < Duration::from_secs(5), "{took:?}");
let outer = *page_children(&doc).iter().find(|k| doc.node(**k).unwrap().name == "Outer instance").unwrap();
let nested = doc.node(outer).unwrap().children.clone();
assert_eq!(nested.len(), 1_000);
let shown: Vec<bool> = nested.iter().map(|n| doc.node(doc.node(*n).unwrap().children[0]).unwrap().visible).collect();
assert!(!shown[7], "the row for nested instance 7's rectangle still applies");
assert_eq!(shown.iter().filter(|v| **v).count(), 999, "and only there");
}
+4
View File
@@ -286,8 +286,12 @@ impl<'a> Mapper<'a> {
if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) {
continue;
}
// Variables and their collections are read from the node list (`of_type`), never
// as layers: out of the layer tree, an instance's expansion doesn't walk them again for
// every instance without counting them (#1077).
if let Some(p) = n.get("parentIndex")
&& let Some(parent) = p.get("guid").and_then(guid)
&& !matches!(n.get("type").and_then(Kv::str), Some("VARIABLE" | "VARIABLE_SET"))
{
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
}
+35 -10
View File
@@ -55,6 +55,28 @@ pub(super) struct OverrideScope {
aliases: BTreeMap<String, Option<String>>,
}
/// Values by key that nested instances add to their outer instances' (property values, slot
/// contents): an outer instance's value wins. Each instance keeps only what it adds and shares the
/// rest, instead of copying every outer value (#1077); lookups walk at most the nesting depth.
pub(super) struct Chain<V> {
own: BTreeMap<String, V>,
outer: Option<Rc<Chain<V>>>,
}
impl<V> Chain<V> {
/// `outer`'s values, then `own`'s for keys `outer` lacks (shared as it is when `own` adds nothing).
fn over(outer: Option<&Rc<Chain<V>>>, own: BTreeMap<String, V>) -> Rc<Self> {
match outer {
Some(o) if own.is_empty() => Rc::clone(o),
_ => Rc::new(Self { own, outer: outer.cloned() }),
}
}
pub(super) fn get(&self, key: &str) -> Option<&V> {
self.outer.as_ref().and_then(|o| o.get(key)).or_else(|| self.own.get(key))
}
}
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
/// instance (by id path), the path of the instance being expanded, and the property values.
#[derive(Clone)]
@@ -62,9 +84,9 @@ pub(super) struct Inst<'a> {
pub(super) root: String,
pub(super) path: Vec<String>,
pub(super) overrides: Rc<Overrides<'a>>,
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
props: Rc<Chain<&'a Kv<'a>>>,
/// Slot property GUID → private slot-content node GUID.
pub(super) slot_contents: Rc<BTreeMap<String, String>>,
pub(super) slot_contents: Rc<Chain<String>>,
/// Layers imported from a slot's independent content subtree.
pub(super) slot_content: bool,
/// Mains being expanded (an instance inside its own main stops there).
@@ -318,12 +340,14 @@ impl<'a> Mapper<'a> {
let scope = self.override_scope(&sym);
let mut overrides: Overrides<'a> = BTreeMap::new();
if let Some(inherited) = outer {
for (path, rows) in inherited.overrides.iter() {
// Only rows addressed below this instance can apply inside it (every path looked up in
// it starts with `own_path`); they sort together, so the others aren't even visited. A
// nested instance used to copy all of its outer instance's rows (#1077).
let below = inherited.overrides.range(own_path.clone()..).take_while(|(path, _)| path.starts_with(&own_path));
for (path, rows) in below.filter(|(path, _)| path.len() > own_path.len()) {
let mut path = path.clone();
// Rows an outer instance wrote for this instance's layers may use their old keys.
if path.starts_with(&own_path) && path.len() > own_path.len() {
normalize_override_segment(&mut path, own_path.len(), &scope);
}
normalize_override_segment(&mut path, own_path.len(), &scope);
overrides.entry(path).or_default().extend(rows.iter().cloned());
}
}
@@ -373,8 +397,9 @@ impl<'a> Mapper<'a> {
// last, then wire order.
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
}
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
// This instance's own property values and slot contents; the outer instances' win.
let mut all_props: BTreeMap<String, &'a Kv<'a>> = BTreeMap::new();
let mut slot_contents: BTreeMap<String, String> = BTreeMap::new();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
all_props.entry(self.prop_root(&d)).or_insert(val);
@@ -411,8 +436,8 @@ impl<'a> Mapper<'a> {
root,
path: own_path,
overrides: Rc::new(overrides),
props: Rc::new(all_props),
slot_contents: Rc::new(slot_contents),
props: Chain::over(outer.map(|i| &i.props), all_props),
slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents),
slot_content: false,
symbols,
},
+87 -13
View File
@@ -64,6 +64,16 @@ const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
/// Most images, and image bytes together, read from one file (members past it are left out).
const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 };
/// Most bytes one open of a `.figcraft` file unpacks, every area together: its document, images,
/// published library, version history and branches ([`Unpacked`], #1079). Each area keeps its own
/// cap; their sum was about 10 GB. Read in that order, the document (a 900 000-layer one unpacks
/// to 1.5 GB) and the images up to their own 4 GiB cap fit, so a file whose images pass that cap
/// (#1313: an 84 MiB document with 7.3 GiB of images) keeps as many as before; the history and
/// branches share what is left. 32-bit targets (wasm32) have 4 GB of address space in all.
#[cfg(not(target_pointer_width = "32"))]
const MAX_UNPACKED: u64 = 6 * 1024 * 1024 * 1024;
#[cfg(target_pointer_width = "32")]
const MAX_UNPACKED: u64 = 2 * 1024 * 1024 * 1024;
const BRANCH_INDEX: &str = "branches/index.json";
/// Most branches a file keeps: making more is refused, extra entries in a file are skipped.
pub const MAX_BRANCHES: usize = 100;
@@ -664,6 +674,42 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
Ok(data)
}
/// What one file open may still unpack, every area together ([`MAX_UNPACKED`]).
pub(crate) struct Unpacked {
left: u64,
total: u64,
}
impl Unpacked {
pub(crate) fn new(total: u64) -> Self {
Self { left: total, total }
}
/// Why a member is left out once the total is spent.
fn passed(&self) -> String {
match self.total / (1024 * 1024) {
0 => format!("too large: the file's contents together pass {} bytes", self.total),
mb => format!("too large: the file's contents together pass {mb} MB"),
}
}
/// Whether member `name` (by its recorded size) needs more than is left.
fn exceeded<R: Read + Seek>(&self, z: &mut zip::ZipArchive<R>, name: &str) -> bool {
z.by_name(name).is_ok_and(|f| f.size() > self.left)
}
/// [`read_member`] within `limit` (the area's own cap) and what is left of the total, which the
/// member then uses up.
pub(crate) fn read<R: Read + Seek>(&mut self, z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, FormatError> {
if self.exceeded(z, name) && z.by_name(name).is_ok_and(|f| f.size() <= limit) {
return Err(FormatError::Corrupt(format!("{name}: {}", self.passed())));
}
let data = read_member(z, name, limit.min(self.left))?;
self.left = self.left.saturating_sub(data.len() as u64);
Ok(data)
}
}
/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`.
pub(crate) fn document_limit(packed: u64, len: u64) -> u64 {
packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT)
@@ -683,6 +729,11 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
/// [`load_bytes_with_versions`] reading at most `budget` of images.
pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, FormatError> {
load_limited(bytes, budget, MAX_UNPACKED)
}
/// [`load_within`] unpacking at most `total` bytes from the archive, every area together.
pub(crate) fn load_limited(bytes: &[u8], budget: ImageBudget, total: u64) -> Result<Loaded, FormatError> {
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
if trimmed == Some(&b'{') {
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
@@ -692,18 +743,19 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
let limit = document_limit(packed, bytes.len() as u64);
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let mut unpacked = Unpacked::new(total);
let text = String::from_utf8(unpacked.read(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
let (mut doc, mut issues, own_images) = parse_json(&text)?;
let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget);
let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget, &mut unpacked);
report_lost_images(&mut issues, &unread, &past_budget, own_images.as_ref(), &images);
let images_past_budget = past_budget.len();
match library::read(&mut z, &images, u64::MAX) {
match library::read(&mut z, &images, &mut unpacked) {
Ok(lib) => doc.published = lib.map(Arc::new),
Err(e) => issues.push(format!("the published library was skipped: {e}")),
}
let mut budget = MAX_VERSION_BYTES;
let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget);
let branches = read_branches(&mut z, &images, &mut issues);
let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget, &mut unpacked);
let branches = read_branches(&mut z, &images, &mut issues, &mut unpacked);
// Images only the version history or branches use stay with them.
let branch_images =
branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys())));
@@ -720,7 +772,12 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
/// with why. The document's own images (`own`) are read first, so a file past the budget loses
/// the images only its history uses before its own.
fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet<String>>, budget: ImageBudget) -> ImagesRead {
fn read_images<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
own: Option<&BTreeSet<String>>,
budget: ImageBudget,
unpacked: &mut Unpacked,
) -> ImagesRead {
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect();
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
@@ -748,7 +805,14 @@ fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet
unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB"))));
continue;
}
match read_member(z, &name, left.min(MAX_MEMBER)) {
// Past the file's total, an image is left out as one past the images' own budget: it stays
// in the file, which can't then be saved over without losing it (#1313).
if unpacked.exceeded(z, &name) {
past_budget.insert(hash.clone());
unread.push((hash, FormatError::Corrupt(unpacked.passed())));
continue;
}
match unpacked.read(z, &name, left.min(MAX_MEMBER)) {
Ok(data) => {
left = left.saturating_sub(data.len() as u64);
images.insert(hash, image_data(data));
@@ -812,12 +876,14 @@ fn read_versions<R: Read + Seek>(
images: &BTreeMap<String, ImageData>,
issues: &mut Vec<String>,
budget: &mut u64,
unpacked: &mut Unpacked,
) -> Vec<Version> {
let index_name = format!("{dir}/index.json");
if z.index_for_name(&index_name).is_none() {
return Vec::new();
}
let index = match read_member(z, &index_name, MAX_VERSION_INDEX)
let index = match unpacked
.read(z, &index_name, MAX_VERSION_INDEX)
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
{
Ok(v) => v,
@@ -838,7 +904,7 @@ fn read_versions<R: Read + Seek>(
issues.push("a version with an invalid id was skipped".into());
continue;
}
let json = match read_member(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
let json = match unpacked.read(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
Ok(j) => j,
Err(err) => {
issues.push(format!("version `{id}` was skipped: {err}"));
@@ -869,11 +935,17 @@ fn read_versions<R: Read + Seek>(
/// The branches of an archive (none when it has no index), parsed. Damaged entries are skipped and
/// reported in `issues`; nothing here fails the load.
fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Branch> {
fn read_branches<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
images: &BTreeMap<String, ImageData>,
issues: &mut Vec<String>,
unpacked: &mut Unpacked,
) -> Vec<Branch> {
if z.index_for_name(BRANCH_INDEX).is_none() {
return Vec::new();
}
let index = match read_member(z, BRANCH_INDEX, MAX_VERSION_INDEX)
let index = match unpacked
.read(z, BRANCH_INDEX, MAX_VERSION_INDEX)
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
{
Ok(v) => v,
@@ -896,7 +968,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
continue;
}
let mut snapshot = |part: &str, list: &str| -> Result<Arc<Document>, FormatError> {
let json = read_member(z, &format!("branches/{id}/{part}.json"), budget.min(MAX_MEMBER))?;
let json = unpacked.read(z, &format!("branches/{id}/{part}.json"), budget.min(MAX_MEMBER))?;
budget = budget.saturating_sub(json.len() as u64);
let (mut doc, _) = from_json(std::str::from_utf8(&json).map_err(|e| FormatError::Corrupt(e.to_string()))?)?;
let hashes = e.get(list).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
@@ -916,7 +988,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0),
state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget, unpacked),
base,
head,
});
@@ -996,6 +1068,8 @@ mod staging_tests;
#[cfg(test)]
mod thumbnail_tests;
#[cfg(test)]
mod unpacked_total_tests;
#[cfg(test)]
mod version_tests;
#[cfg(test)]
+13 -14
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, json_options, put, put_json, read_member};
use crate::{FormatError, MAX_MEMBER, Unpacked, from_json, image_data, io_err, json_options, put, put_json};
const MANIFEST: &str = "library/manifest.json";
const SNAPSHOT: &str = "library/document.json";
@@ -52,22 +52,22 @@ pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedL
}
/// The published library in an archive (`None` when the file was never published). `own` are
/// the images already read from `images/`; `budget` caps the bytes its snapshot and images unpack
/// to, together (beyond the per-part caps).
/// the images already read from `images/`; what it unpacks comes out of `unpacked` (beyond the
/// per-part caps).
pub(crate) fn read<R: Read + Seek>(
z: &mut zip::ZipArchive<R>,
own: &BTreeMap<String, ImageData>,
budget: u64,
unpacked: &mut Unpacked,
) -> Result<Option<PublishedLibrary>, FormatError> {
if z.index_for_name(MANIFEST).is_none() {
return Ok(None);
}
let mut manifest: LibraryManifest = serde_json::from_slice(&read_member(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
let mut manifest: LibraryManifest = serde_json::from_slice(&unpacked.read(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
if !is_valid_library_id(&manifest.id) {
return Err(corrupt("the library has an invalid id"));
}
let text = String::from_utf8(read_member(z, SNAPSHOT, budget.min(MAX_MEMBER))?).map_err(corrupt)?;
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
let text = String::from_utf8(unpacked.read(z, SNAPSHOT, MAX_MEMBER)?).map_err(corrupt)?;
let mut budget = MAX_IMAGE_BYTES;
let (mut doc, _) = from_json(&text)?;
for hash in used_images(&doc) {
if !ImageData::is_valid_key(&hash) {
@@ -79,7 +79,7 @@ pub(crate) fn read<R: Read + Seek>(
}
let name = [format!("{IMAGES}{hash}"), format!("images/{hash}")].into_iter().find(|n| z.index_for_name(n).is_some());
// A missing or oversized image shows as a placeholder, like in any other file.
let Some(data) = name.and_then(|n| read_member(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
let Some(data) = name.and_then(|n| unpacked.read(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
budget = budget.saturating_sub(data.len() as u64);
doc.images.insert(hash, image_data(data));
}
@@ -100,16 +100,15 @@ pub(crate) fn read<R: Read + Seek>(
/// Read the published library of a `.figcraft` file (only its library entries are read): `None`
/// when the file was never published, an error when it isn't a FigCraft archive or is damaged.
pub fn read_library<R: Read + Seek>(r: R) -> Result<Option<PublishedLibrary>, FormatError> {
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
read(&mut z, &BTreeMap::new(), u64::MAX)
read_library_within(r, u64::MAX)
}
/// [`read_library`] unpacking at most `max_bytes` in all (snapshot and images: an archive that
/// would unpack to more is refused, or loses the images past it), for libraries from people the
/// reader doesn't trust (a server).
/// [`read_library`] unpacking at most `max_bytes` in all (manifest, snapshot and images: an
/// archive that would unpack to more is refused, or loses the images past it), for libraries from
/// people the reader doesn't trust (a server).
pub fn read_library_within<R: Read + Seek>(r: R, max_bytes: u64) -> Result<Option<PublishedLibrary>, FormatError> {
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
read(&mut z, &BTreeMap::new(), max_bytes)
read(&mut z, &BTreeMap::new(), &mut Unpacked::new(max_bytes))
}
#[cfg(test)]
+1 -1
View File
@@ -3,7 +3,7 @@
use std::io::{Read, Write};
use super::*;
use crate::{add_image, load_bytes, save_bytes};
use crate::{add_image, load_bytes, read_member, save_bytes};
use figcraft_doc::color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, LibraryAsset, NodeKind};
+40 -5
View File
@@ -397,6 +397,22 @@ impl Default for Space {
}
}
/// A layer [`Ctx::build`] has yet to import, with where it goes.
struct Layer<'a> {
j: &'a Value,
parent: NodeId,
space: Space,
parent_mode: Option<LayoutMode>,
depth: usize,
root_abs: bool,
}
/// [`Ctx::build`]'s work list: layers to import, and groups to fit once their children are in.
enum Build<'a> {
Layer(Layer<'a>),
Fit(NodeId),
}
pub(crate) struct Ctx<'a> {
doc: Document,
images: ImageResolver<'a>,
@@ -514,8 +530,26 @@ impl<'a> Ctx<'a> {
}
/// Import layer `j` (and its subtree) under `parent`. `root_abs`: place it by its absolute
/// bounds (roots of a nodes response, whose parent isn't in the file).
/// bounds (roots of a nodes response, whose parent isn't in the file). Depth first in the
/// file's order, on a work list rather than the call stack (#1101): a level took about 4 KB of
/// stack, so the deepest files filled the browser's 1 MB.
fn build(&mut self, j: &'a Value, parent: NodeId, space: Space, parent_mode: Option<LayoutMode>, depth: usize, root_abs: bool) {
let mut work = vec![Build::Layer(Layer { j, parent, space, parent_mode, depth, root_abs })];
while let Some(step) = work.pop() {
match step {
Build::Layer(layer) => self.build_layer(layer, &mut work),
// A group or boolean fits its children once all of them are in.
Build::Fit(id) => {
let _ = self.doc.fit_to_children(id);
}
}
}
}
/// One layer of [`Self::build`]: its node, then its children pushed on `work` (and, when its
/// bounds follow them, the fit after them).
fn build_layer(&mut self, layer: Layer<'a>, work: &mut Vec<Build<'a>>) {
let Layer { j, parent, space, parent_mode, depth, root_abs } = layer;
if depth > MAX_DEPTH {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return;
@@ -570,12 +604,13 @@ impl<'a> Ctx<'a> {
return;
}
let inner = Space { world, frame: if follows { space.frame } else { world } };
for c in kids {
self.build(c, id, inner, mode, depth + 1, false);
}
if follows {
let _ = self.doc.fit_to_children(id);
work.push(Build::Fit(id));
}
// Reversed, so the first child comes off the list first.
work.extend(
kids.iter().rev().map(|c| Build::Layer(Layer { j: c, parent: id, space: inner, parent_mode: mode, depth: depth + 1, root_abs: false })),
);
}
fn kind(&mut self, ty: &str, j: &'a Value) -> Option<NodeKind> {
+2 -1
View File
@@ -82,7 +82,8 @@ impl FileKind {
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"figcraft" => FileKind::FigCraft,
"fig" => FileKind::Fig,
// A FigJam `.jam` is the same archive.
"fig" | "jam" => FileKind::Fig,
"sketch" => FileKind::Sketch,
"svg" => FileKind::Svg,
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image,
+95
View File
@@ -0,0 +1,95 @@
//! One budget for everything a `.figcraft` file unpacks when it opens (#1079): the document,
//! images, version history and branches share it, beyond each area's own cap, and what passes it
//! is left out (or, for the document, refused) with a note that says so. Images left out this way
//! stay in the file like those past the images' own budget (#1313).
use std::io::Write;
use figcraft_doc::Document;
use sha2::Digest;
use crate::{IMAGE_BUDGET, MAX_UNPACKED, load_limited, to_json};
fn png(k: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn hash(bytes: &[u8]) -> String {
format!("{:x}", sha2::Sha256::digest(bytes))
}
fn zip_of(members: &[(String, Vec<u8>)]) -> Vec<u8> {
let mut buf = std::io::Cursor::new(Vec::new());
let mut z = zip::ZipWriter::new(&mut buf);
for (name, bytes) in members {
z.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap();
buf.into_inner()
}
/// A crafted file: a document, two images, and a version history with one snapshot, each well
/// within its own cap. The member sizes, in that order.
fn crafted() -> (Vec<u8>, [u64; 5]) {
let doc = serde_json::to_vec(&to_json(&Document::new("Crafted")).unwrap()).unwrap();
let (a, b) = (png(1), png(2));
let index = br#"{"versions": [{"id": "v1", "name": "First"}]}"#.to_vec();
let snapshot = doc.clone();
let sizes = [doc.len(), a.len(), b.len(), index.len(), snapshot.len()].map(|n| n as u64);
let members = vec![
("document.json".to_string(), doc),
(format!("images/{}", hash(&a)), a),
(format!("images/{}", hash(&b)), b),
("versions/index.json".to_string(), index),
("versions/v1.json".to_string(), snapshot),
];
(zip_of(&members), sizes)
}
#[test]
fn everything_within_the_total_loads() {
let (bytes, sizes) = crafted();
let loaded = load_limited(&bytes, IMAGE_BUDGET, sizes.iter().sum()).unwrap();
assert_eq!(loaded.doc.images.len(), 2);
assert_eq!(loaded.versions.len(), 1);
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
}
#[test]
fn what_passes_the_total_is_left_out_with_a_note() {
let (bytes, [doc, a, b, index, _]) = crafted();
// Room for the document and one image: the other image stays in the file, left out.
let loaded = load_limited(&bytes, IMAGE_BUDGET, doc + a.max(b)).unwrap();
assert_eq!(loaded.doc.images.len(), 1);
assert_eq!(loaded.images_past_budget, 1, "kept in the file, so saving over it is refused");
assert!(loaded.issues.iter().any(|i| i.contains("the file's contents together pass")), "{:?}", loaded.issues);
// Room for the document, the images and the index, not the snapshot: the version is skipped.
let loaded = load_limited(&bytes, IMAGE_BUDGET, doc + a + b + index).unwrap();
assert_eq!(loaded.doc.images.len(), 2);
assert!(loaded.versions.is_empty());
let note = loaded.issues.iter().find(|i| i.contains("version `v1` was skipped")).unwrap();
assert!(note.contains("the file's contents together pass"), "{note}");
}
#[test]
fn a_document_past_the_total_is_refused_with_the_reason() {
let (bytes, [doc, ..]) = crafted();
let Err(e) = load_limited(&bytes, IMAGE_BUDGET, doc - 1) else { panic!("loaded") };
assert!(e.to_string().contains("document.json: too large: the file's contents together pass"), "{e}");
}
#[test]
fn the_total_leaves_room_for_the_largest_real_files() {
// A 900 000-layer document unpacks to 1.5 GB; with images up to their own cap it fits, so a
// file whose images pass that cap (#1313) loads as many as before.
#[cfg(target_pointer_width = "64")]
const {
assert!(MAX_UNPACKED >= 1_500_000_000 + IMAGE_BUDGET.bytes)
};
// Below what the per-area caps added up to (about 10 GB).
const { assert!(MAX_UNPACKED < 10 * 1024 * 1024 * 1024) };
}
+134
View File
@@ -0,0 +1,134 @@
//! Grid auto-placement on an occupancy bitmap (#1083) places every child exactly where the plain
//! cell-by-cell search did, on randomised grids with anchored, half-anchored and spanning
//! children, while no longer testing every cell of a span at every position.
use figcraft_doc::{AutoLayout, Document, NodeId, NodeKind};
use crate::{GridCell, MAX_GRID_CELLS, MAX_TRACKS, grid_place};
/// The cell-by-cell placement `grid_place` had before #1083, kept as the reference.
fn reference(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> Vec<GridCell> {
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
let mut occ: Vec<Vec<bool>> = Vec::new();
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
};
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
while occ.len() < r + rs {
occ.push(vec![false; cols]);
}
for row in occ.iter_mut().skip(r).take(rs) {
for cell in row.iter_mut().skip(c).take(cs) {
*cell = true;
}
}
};
let spans = |id: NodeId| {
let cl = doc.node(id).unwrap().child_layout.clone();
let cs = (cl.column_span() as usize).min(cols);
let rs = (cl.row_span() as usize).min(max_rows);
(cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs)
};
let mut placed: Vec<Option<GridCell>> = vec![None; kids.len()];
for (i, id) in kids.iter().enumerate() {
if let (Some(c), Some(r), cs, rs) = spans(*id) {
mark(&mut occ, c, r, cs, rs);
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
}
let mut cursor = (0usize, 0usize);
for (i, id) in kids.iter().enumerate() {
if placed[i].is_some() {
continue;
}
let (col, row, cs, rs) = spans(*id);
let last_row = max_rows - rs;
let (c, r) = match (col, row) {
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
(None, None) => {
let mut found = None;
let (mut r, mut c) = cursor;
while r <= last_row {
if c + cs <= cols && free(&occ, c, r, cs, rs) {
found = Some((c, r));
break;
}
c += 1;
if c + cs > cols {
c = 0;
r += 1;
}
}
let (c, r) = found.unwrap_or((0, last_row));
cursor = (r, c + cs);
(c, r)
}
};
mark(&mut occ, c, r, cs, rs);
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
placed.into_iter().flatten().collect()
}
/// A small deterministic generator (xorshift).
struct Rng(u64);
impl Rng {
fn below(&mut self, n: u64) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0 % n.max(1)
}
}
/// A grid of `cols` columns with `n` children, each anchored, half-anchored or free, with spans
/// up to `span` (sometimes past the grid, which clamps them).
fn grid(rng: &mut Rng, cols: u32, n: usize, span: u64) -> (Document, AutoLayout, Vec<NodeId>) {
let mut d = Document::new("grid");
let page = d.first_page().unwrap();
let frame = d.create(page, NodeKind::Frame, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
let kids: Vec<NodeId> = (0..n)
.map(|_| {
let id = d.create(frame, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
let anchor = rng.below(4);
let (column, row) = (rng.below(u64::from(cols) + 3) as u32, rng.below(40) as u32);
let cl = &mut d.get_mut(id).unwrap().child_layout;
cl.grid_column = matches!(anchor, 1 | 3).then_some(column);
cl.grid_row = matches!(anchor, 2 | 3).then_some(row);
cl.grid_column_span = rng.below(span + 1) as u32;
cl.grid_row_span = rng.below(span + 1) as u32;
id
})
.collect();
let al = AutoLayout { grid_columns: cols, ..Default::default() };
(d, al, kids)
}
#[test]
fn placements_match_the_cell_by_cell_search_on_random_grids() {
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
for case in 0..400 {
let cols = [1, 2, 3, 7, 63, 64, 65, 130][case % 8];
let (n, span) = (rng.below(60) as usize + 1, [1, 3, 9, 70][case % 4]);
let (d, al, kids) = grid(&mut rng, cols, n, span);
let (cells, _) = grid_place(&d, &al, &kids);
assert_eq!(cells, reference(&d, &al, &kids), "case {case}: {cols} columns, {n} children, spans up to {span}");
}
}
#[test]
fn a_full_grid_puts_the_rest_on_its_last_rows_as_before() {
// 1000 columns leave 1000 rows; a child too tall for what is left goes to the last row.
let mut rng = Rng(7);
let (mut d, al, kids) = grid(&mut rng, 1000, 30, 1);
for (i, id) in kids.iter().enumerate() {
let cl = &mut d.get_mut(*id).unwrap().child_layout;
(cl.grid_column, cl.grid_row, cl.grid_column_span, cl.grid_row_span) = (Some(0), None, 1000, if i % 2 == 0 { 400 } else { 1 });
}
let (cells, _) = grid_place(&d, &al, &kids);
assert_eq!(cells, reference(&d, &al, &kids));
assert!(cells.iter().any(|c| c.row == 600 && c.row_span == 400), "a tall child past the free rows sits at the last row it fits");
}
+21 -29
View File
@@ -689,20 +689,7 @@ impl GridGeometry {
pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<GridCell>, usize) {
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
let mut occ: Vec<Vec<bool>> = Vec::new();
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
};
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
while occ.len() < r + rs {
occ.push(vec![false; cols]);
}
for row in occ.iter_mut().skip(r).take(rs) {
for cell in row.iter_mut().skip(c).take(cs) {
*cell = true;
}
}
};
let mut occ = occupancy::Occupancy::new(cols);
let spans = |id: NodeId| {
let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default();
let cs = (cl.column_span() as usize).min(cols);
@@ -713,7 +700,7 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
// 1. Fully anchored children.
for (i, id) in kids.iter().enumerate() {
if let (Some(c), Some(r), cs, rs) = spans(*id) {
mark(&mut occ, c, r, cs, rs);
occ.mark(c, r, cs, rs);
if let Some(slot) = placed.get_mut(i) {
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
@@ -721,6 +708,10 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
}
// 2. The rest, in order (a cursor keeps auto-placed children in reading order).
let mut cursor = (0usize, 0usize); // (row, column)
// Cells only ever get taken, so the first free row for a column span never moves up: each
// (column, spans) looks on from where it last found one.
let mut from_row: std::collections::HashMap<(usize, usize, usize), usize> = std::collections::HashMap::new();
let mut scratch = Vec::new();
for (i, id) in kids.iter().enumerate() {
if placed.get(i).is_some_and(Option::is_some) {
continue;
@@ -728,28 +719,26 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
let (col, row, cs, rs) = spans(*id);
let last_row = max_rows - rs;
let (c, r) = match (col, row) {
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
(Some(c), _) => {
let start = from_row.get(&(c, cs, rs)).copied().unwrap_or(0);
let found = occ.first_row(c, cs, rs, start, last_row);
from_row.insert((c, cs, rs), found.unwrap_or(last_row + 1));
(c, found.unwrap_or(last_row))
}
(None, Some(r)) => (occ.first_column(r, rs, 0, cs, &mut scratch).unwrap_or(0), r),
(None, None) => {
let mut found = None;
let (mut r, mut c) = cursor;
while r <= last_row {
if c + cs <= cols && free(&occ, c, r, cs, rs) {
found = Some((c, r));
break;
}
c += 1;
if c + cs > cols {
c = 0;
r += 1;
}
let mut found = None;
while r <= last_row && found.is_none() {
found = occ.first_column(r, rs, c, cs, &mut scratch).map(|c| (c, r));
(r, c) = (r + 1, 0);
}
let (c, r) = found.unwrap_or((0, last_row));
cursor = (r, c + cs);
(c, r)
}
};
mark(&mut occ, c, r, cs, rs);
occ.mark(c, r, cs, rs);
if let Some(slot) = placed.get_mut(i) {
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
}
@@ -1171,6 +1160,9 @@ mod content_align_tests;
#[cfg(test)]
mod depth_tests;
#[cfg(test)]
mod grid_place_tests;
#[cfg(test)]
mod nested_tests;
mod occupancy;
#[cfg(test)]
mod tests;
+102
View File
@@ -0,0 +1,102 @@
//! Which cells of a layout grid are taken, one bit per cell (#1083). Grid placement asks whether a
//! span of cells is free a 64-bit word at a time and jumps past the cell that blocks it, instead of
//! testing every cell of the span at every position.
/// The taken cells of a grid of `cols` columns, row by row.
pub(crate) struct Occupancy {
cols: usize,
words: usize,
bits: Vec<u64>,
}
/// The bits of columns `c..end` that fall in word `w`.
fn mask(w: usize, c: usize, end: usize) -> u64 {
let lo = c.max(w * 64) - w * 64;
let hi = end.min((w + 1) * 64) - w * 64;
match hi.saturating_sub(lo) {
0 => 0,
64 => u64::MAX,
n => ((1u64 << n) - 1) << lo,
}
}
/// The last taken column of `c..c + cs` in `row`, if any.
fn last_taken(row: &[u64], c: usize, cs: usize) -> Option<usize> {
let end = c + cs;
if cs == 0 {
return None;
}
(c / 64..=(end - 1) / 64).rev().find_map(|w| {
let v = row.get(w).copied().unwrap_or(0) & mask(w, c, end);
(v != 0).then(|| w * 64 + 63 - v.leading_zeros() as usize)
})
}
impl Occupancy {
/// An empty grid of `cols` columns. Rows are stored only once something takes a cell in them
/// (or a row below them), so an empty or sparse grid allocates little.
pub(crate) fn new(cols: usize) -> Self {
Self { cols, words: cols.div_ceil(64), bits: Vec::new() }
}
fn row(&self, r: usize) -> &[u64] {
self.bits.get(r * self.words..(r + 1) * self.words).unwrap_or(&[])
}
/// Take columns `c..c + cs` of rows `r..r + rs`.
pub(crate) fn mark(&mut self, c: usize, r: usize, cs: usize, rs: usize) {
let end = c + cs;
if cs == 0 || rs == 0 {
return;
}
let need = (r + rs) * self.words;
if self.bits.len() < need {
self.bits.resize(need, 0);
}
for ri in r..r + rs {
for w in c / 64..=(end - 1) / 64 {
if let Some(word) = self.bits.get_mut(ri * self.words + w) {
*word |= mask(w, c, end);
}
}
}
}
/// The first column from `from` where columns `c..c + cs` are free in rows `r..r + rs` and fit
/// the grid (`scratch`: a buffer for the rows' union).
pub(crate) fn first_column(&self, r: usize, rs: usize, from: usize, cs: usize, scratch: &mut Vec<u64>) -> Option<usize> {
scratch.clear();
scratch.resize(self.words, 0);
for ri in r..r + rs {
for (u, w) in scratch.iter_mut().zip(self.row(ri)) {
*u |= w;
}
}
let mut c = from;
while c + cs <= self.cols {
match last_taken(scratch, c, cs) {
None => return Some(c),
// No span starting at or before the taken column can include it free.
Some(t) => c = t + 1,
}
}
None
}
/// The first row from `from` (at most `last`) where columns `c..c + cs` are free in `rs` rows
/// in a row.
pub(crate) fn first_row(&self, c: usize, cs: usize, rs: usize, from: usize, last: usize) -> Option<usize> {
let mut run = 0;
for r in from..last + rs {
if last_taken(self.row(r), c, cs).is_some() {
run = 0;
continue;
}
run += 1;
if run == rs {
return Some(r + 1 - rs);
}
}
None
}
}
-3
View File
@@ -164,9 +164,6 @@ impl Headless {
"select.children" if self.single_text().is_some() => {
self.execute("text.edit.begin", &json!({"id": self.single_text()}))?;
}
"select.children" if self.session.tool.pen.is_some() => {
let _ = self.session.pen_end();
}
// Enter on shapes edits them as vectors (several at once).
"select.children" if self.vector_targets().is_some() => {
self.execute("vector.edit.begin", &json!({"ids": self.vector_targets()}))?;
+130
View File
@@ -0,0 +1,130 @@
//! Input caps for the sweep: a boolean, a simplification or an arrangement past them is refused
//! with [`PathOpsError::TooComplex`] instead of running for unbounded time and memory (a crafted
//! file can hold any number of segments, crossing each other as often as it likes).
//!
//! The caps sit far above real designs: the boolean groups in the example files sweep at most 55
//! segments with 155 possible crossings per step and 75 segments over a whole fold, and their
//! largest outline has about 4 000 segments. In a native release build the sweep takes 0.3 s for
//! 200 000 segments and 1.8 s for 2 million crossings, and a union of 1 000 squares one by one
//! (2 million segments swept) takes 1.8 s.
use figcraft_geom::kurbo::{BezPath, ParamCurveExtrema, Rect};
use crate::PathOpsError;
/// The most segments one sweep takes, over all its inputs.
pub const MAX_SEGMENTS: usize = 250_000;
/// The most crossings one sweep may have to split at, counted as the pairs of segments whose
/// bounds overlap (every crossing is one of them).
pub const MAX_CROSSINGS: u64 = 2_000_000;
/// The most segments a fold of several operands sweeps over all its steps ([`FoldBudget`]): each
/// step sweeps the result so far again.
pub const MAX_FOLD_SEGMENTS: usize = 4_000_000;
/// How complex the sweep of some paths is ([`complexity`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Complexity {
pub segments: usize,
/// Pairs of segments whose bounds overlap (touching counts), counted up to [`MAX_CROSSINGS`] + 1.
pub crossings: u64,
}
/// The segments of `paths` and the pairs of them that may cross; `None` past [`MAX_SEGMENTS`]
/// (counting stops there, so a huge input is refused at once).
pub fn complexity<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Option<Complexity> {
let mut boxes = Vec::new();
for seg in paths.into_iter().flat_map(BezPath::segments) {
if boxes.len() == MAX_SEGMENTS {
return None;
}
boxes.push(seg.bounding_box());
}
Some(Complexity { segments: boxes.len(), crossings: overlapping_pairs(&boxes, MAX_CROSSINGS) })
}
/// Refuse `paths` past [`MAX_SEGMENTS`] or [`MAX_CROSSINGS`].
pub(crate) fn check<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Result<(), PathOpsError> {
match complexity(paths) {
Some(c) if c.crossings <= MAX_CROSSINGS => Ok(()),
_ => Err(PathOpsError::TooComplex),
}
}
/// What a fold of several operands has swept so far, against [`MAX_FOLD_SEGMENTS`].
#[derive(Debug, Default)]
pub struct FoldBudget(usize);
impl FoldBudget {
/// Count the step combining `acc` with `p`: refused once the fold would sweep more than
/// [`MAX_FOLD_SEGMENTS`].
pub fn spend(&mut self, acc: &BezPath, p: &BezPath) -> Result<(), PathOpsError> {
self.0 = self.0.saturating_add(acc.segments().count()).saturating_add(p.segments().count());
if self.0 > MAX_FOLD_SEGMENTS { Err(PathOpsError::TooComplex) } else { Ok(()) }
}
}
/// How many pairs of `boxes` overlap (touching counts), counted up to `limit` + 1, in
/// O(n log n): a sweep across keeps the boxes it is inside, and a box it enters overlaps those
/// of them that neither end above it nor start below it.
fn overlapping_pairs(boxes: &[Rect], limit: u64) -> u64 {
// The y coordinates by rank, for counting the boxes the sweep is inside by their top and bottom.
let mut ys: Vec<f64> = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect();
ys.sort_unstable_by(f64::total_cmp);
ys.dedup();
let below = |y: f64| ys.partition_point(|v| *v < y);
let up_to = |y: f64| ys.partition_point(|v| *v <= y);
// Entering at x0 before leaving at x1, so boxes that only touch across count.
let mut events: Vec<(f64, bool, &Rect)> = boxes.iter().flat_map(|b| [(b.x0, false, b), (b.x1, true, b)]).collect();
events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let (mut tops, mut bottoms) = (Fenwick::new(ys.len()), Fenwick::new(ys.len()));
let (mut inside, mut pairs) = (0u64, 0u64);
for (_, leaving, b) in events {
if leaving {
tops.add(below(b.y0), -1);
bottoms.add(below(b.y1), -1);
inside = inside.saturating_sub(1);
continue;
}
let ending_above = bottoms.count_below(below(b.y0));
let starting_below = inside.saturating_sub(tops.count_below(up_to(b.y1)));
pairs += inside.saturating_sub(ending_above + starting_below);
if pairs > limit {
return pairs;
}
tops.add(below(b.y0), 1);
bottoms.add(below(b.y1), 1);
inside += 1;
}
pairs
}
/// Counts by rank, with the count below a rank in O(log n) (a Fenwick tree).
struct Fenwick(Vec<i64>);
impl Fenwick {
fn new(ranks: usize) -> Self {
Self(vec![0; ranks + 1])
}
fn add(&mut self, rank: usize, by: i64) {
let mut i = rank + 1;
while let Some(c) = self.0.get_mut(i) {
*c += by;
i += i.isolate_lowest_one();
}
}
/// How many were added at ranks below `rank`.
fn count_below(&self, rank: usize) -> u64 {
let (mut i, mut n) = (rank, 0i64);
while i > 0
&& let Some(c) = self.0.get(i)
{
n += c;
i -= i.isolate_lowest_one();
}
u64::try_from(n).unwrap_or(0)
}
}

Some files were not shown because too many files have changed in this diff Show More