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
+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",