mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
Merge main into #1678
This commit is contained in:
+3
-2
File diff suppressed because one or more lines are too long
@@ -105,6 +105,8 @@ mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod idle_io_tests;
|
||||
#[cfg(test)]
|
||||
mod library_detach_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Detach counts in a team library's analytics, end to end over loopback: a file that uses the
|
||||
//! library detaches instances of its components and its app reports them with its usage; the
|
||||
//! library's owner sees them beside the instances. Reports are cleaned and capped like usage, kept
|
||||
//! per file, and the usage list a server wrote before detach counts still reads.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, Detached, FileUsage, LibraryReply, LibraryReq, OpenLibrary, PROTOCOL, ServerMsg, Usage};
|
||||
use figcraft_collab_net::Invite;
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::hardening_tests::archive;
|
||||
use crate::store::{FILES, Store};
|
||||
use crate::test_util::{Raw, TempDir, config, ip, publish, session, snapshot};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// `a`'s analytics of its file's library once `done` holds for them (the reports arrive from
|
||||
/// another app's background requests).
|
||||
fn usage_when(a: &mut Session, done: impl Fn(&Value) -> bool) -> Value {
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
let u = ex(a, "library.usage", json!({}));
|
||||
if done(&u) {
|
||||
return u;
|
||||
}
|
||||
assert!(start.elapsed() < Duration::from_secs(30), "the reports never arrived: {u}");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(raw: &mut Raw) -> LibraryReply {
|
||||
match raw.next() {
|
||||
ServerMsg::Library(r) => *r,
|
||||
other => panic!("not a library answer: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detaches_are_reported_and_counted_beside_instances() {
|
||||
let dir = TempDir::new("library-detach");
|
||||
let cfg = config(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
// Ada's library on the server: a "Button".
|
||||
let mut a = session("Ada");
|
||||
let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
||||
ex(&mut a, "component.create", json!({"ids": [f]}));
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
ex(&mut a, "library.publishToServer", json!({}));
|
||||
|
||||
// Bo places two Buttons and detaches one.
|
||||
let mut b = session("Bo");
|
||||
let lid = ex(&mut b, "library.add", json!({"path": p.view}))["id"].as_str().unwrap().to_string();
|
||||
let place = |b: &mut Session, x: i32| id(&ex(b, "library.place", json!({"library": lid, "component": "Button", "x": x, "y": 0})));
|
||||
let (one, two) = (place(&mut b, 0), place(&mut b, 200));
|
||||
ex(&mut b, "component.detach", json!({"ids": [two]}));
|
||||
b.refresh_server_libraries();
|
||||
let u = usage_when(&mut a, |u| u["detached"] == 1 && u["instances"] == 1);
|
||||
assert_eq!((u["instances"].clone(), u["detachesKnown"].clone()), (json!(1), json!(true)), "{u}");
|
||||
assert_eq!(u["components"][0]["name"], "Button", "{u}");
|
||||
assert_eq!((u["components"][0]["instances"].clone(), u["components"][0]["detached"].clone()), (json!(1), json!(1)), "{u}");
|
||||
assert_eq!(u["files"][0]["detached"], 1, "{u}");
|
||||
|
||||
// Detaching the other one too: no instances left, still listed with its detaches.
|
||||
ex(&mut b, "component.detach", json!({"ids": [one]}));
|
||||
b.refresh_server_libraries();
|
||||
let u = usage_when(&mut a, |u| u["detached"] == 2 && u["instances"] == 0);
|
||||
assert_eq!(u["instances"], 0, "{u}");
|
||||
assert_eq!((u["components"][0]["name"].clone(), u["components"][0]["detached"].clone()), (json!("Button"), json!(2)), "{u}");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// Reports from the wire: names cleaned and counts capped as usage is, kept apart per file; a
|
||||
/// file that only reported detaches isn't listed as using the library; the requests from before
|
||||
/// detach counts are answered as they were.
|
||||
#[test]
|
||||
fn detach_reports_are_capped_per_file_and_older_requests_answer_as_before() {
|
||||
let dir = TempDir::new("library-detach-wire");
|
||||
let cfg = config(dir.path());
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
let f = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Button"})));
|
||||
ex(&mut a, "component.create", json!({"ids": [f]}));
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
ex(&mut a, "library.publishToServer", json!({}));
|
||||
let view = Invite::parse(&p.view).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&ClientMsg::OpenLibrary(OpenLibrary {
|
||||
protocol: PROTOCOL,
|
||||
file: view.file.clone(),
|
||||
token: view.token.clone(),
|
||||
client_version: String::new(),
|
||||
}));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View });
|
||||
let flood = Detached { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::ReportDetached(flood)));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Noted);
|
||||
raw.send(&ClientMsg::Library(LibraryReq::ReportDetached(Detached {
|
||||
file: "Shop".into(),
|
||||
components: vec![(NodeId(3), 4), (NodeId(3), 9), (NodeId(5), 0)],
|
||||
})));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Noted);
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Detached));
|
||||
let LibraryReply::Detached(files) = answer(&mut raw) else { panic!("no detaches") };
|
||||
let spam = files.iter().find(|f| f.detached.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.detached.file.chars().count() <= 256 && !spam.detached.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.detached.components.len(), 1_000, "reports are capped");
|
||||
let shop = files.iter().find(|f| f.detached.file == "Shop").unwrap();
|
||||
assert_eq!(shop.detached.components, [(NodeId(3), 4)], "one count per component, none of 0");
|
||||
// The usage list (what older apps ask for) is as it was: nobody reported using the library.
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Usage(Vec::new()));
|
||||
server.stop();
|
||||
}
|
||||
|
||||
/// A usage list written before detach counts reads as it did, and gains them beside it.
|
||||
#[test]
|
||||
fn a_usage_list_from_before_detach_counts_still_reads() {
|
||||
let dir = TempDir::new("library-detach-old");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
|
||||
store.publish_library(&file.id, &archive(0, 0)).unwrap();
|
||||
let old = vec![FileUsage { usage: Usage { file: "Checkout".into(), components: vec![(NodeId(7), 3)] }, updated: 1_700_000_000 }];
|
||||
std::fs::write(dir.path().join(FILES).join(&file.id).join(crate::disk::USAGE), serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
drop(store);
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
assert_eq!(store.library_usage(&file.id).unwrap(), old);
|
||||
let report = Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] };
|
||||
let r = store.library(ip("198.51.100.1"), &file.id, Access::View, LibraryReq::ReportDetached(report));
|
||||
assert_eq!(r, LibraryReply::Noted);
|
||||
assert_eq!(store.library_usage(&file.id).unwrap(), old, "the usage report is untouched");
|
||||
let detached = store.library_detached(&file.id).unwrap();
|
||||
assert_eq!(detached.len(), 1);
|
||||
assert_eq!(detached[0].detached.components, [(NodeId(7), 2)]);
|
||||
}
|
||||
@@ -39,10 +39,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use figcraft_collab::message::{
|
||||
Access, Admin, AdminReply, ClientMsg, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo, MAX_OWNED, NewLink, OwnedFile,
|
||||
PluginReply, PluginReq, ReportInfo, Usage,
|
||||
Access, Admin, AdminReply, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo,
|
||||
MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage,
|
||||
};
|
||||
use figcraft_collab::{Change, Replica, Server, same_secret};
|
||||
use figcraft_collab::{Change, NodeId, Replica, Server, same_secret};
|
||||
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
|
||||
use figcraft_collab_net::token;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -290,11 +290,28 @@ const TOUCH_EVERY: u64 = 3600;
|
||||
|
||||
/// A library's usage reports in memory, and whether (and when) they were written.
|
||||
struct UsageCache {
|
||||
list: Vec<FileUsage>,
|
||||
list: Vec<UsageEntry>,
|
||||
dirty: bool,
|
||||
written: u64,
|
||||
}
|
||||
|
||||
/// What one file reports about a library, as kept in `usage.json`: its last usage report (the
|
||||
/// same JSON as a [`FileUsage`]; `updated` 0: none yet) and its last detach report.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
struct UsageEntry {
|
||||
usage: Usage,
|
||||
#[serde(default)]
|
||||
updated: u64,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
detached: Vec<(NodeId, u32)>,
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
detached_updated: u64,
|
||||
}
|
||||
|
||||
fn is_zero(n: &u64) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
/// The data directory in use (it holds the directory's lock until dropped).
|
||||
pub struct Store {
|
||||
root: PathBuf,
|
||||
|
||||
@@ -270,6 +270,8 @@ impl Backend for Store {
|
||||
self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version })
|
||||
}
|
||||
LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage),
|
||||
LibraryReq::ReportDetached(d) => self.report_detached(file, d).map_or_else(failed, |()| LibraryReply::Noted),
|
||||
LibraryReq::Detached => self.library_detached(file).map_or_else(failed, LibraryReply::Detached),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
|
||||
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
|
||||
write_json,
|
||||
Action, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION,
|
||||
MAX_REPORTED, MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, NodeId, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache,
|
||||
UsageEntry, clean_name, disk, fs, now, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
@@ -103,31 +103,78 @@ impl Store {
|
||||
|
||||
/// The usage reports kept for a file's library (none when it has no library).
|
||||
pub fn library_usage(&mut self, file: &str) -> Result<Vec<FileUsage>, String> {
|
||||
Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default())
|
||||
let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default();
|
||||
Ok(list.iter().filter(|e| e.updated > 0).map(|e| FileUsage { usage: e.usage.clone(), updated: e.updated }).collect())
|
||||
}
|
||||
|
||||
/// The detach reports kept for a file's library (none when it has no library).
|
||||
pub fn library_detached(&mut self, file: &str) -> Result<Vec<FileDetached>, String> {
|
||||
let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default();
|
||||
Ok(list
|
||||
.iter()
|
||||
.filter(|e| !e.detached.is_empty())
|
||||
.map(|e| FileDetached { detached: Detached { file: e.usage.file.clone(), components: e.detached.clone() }, updated: e.detached_updated })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Keep how one file uses a file's library (in memory; written when it changed, at most every
|
||||
/// [`USAGE_WRITE_EVERY`] seconds).
|
||||
pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> {
|
||||
let name = clean_name(&usage.file);
|
||||
self.report(file, &usage.file, usage.components, |e, components| {
|
||||
if e.updated > 0 && e.usage.components == components {
|
||||
return false;
|
||||
}
|
||||
e.usage.components = components;
|
||||
e.updated = now();
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep how many instances of a file's library components one file has detached (as
|
||||
/// [`Self::report_usage`] keeps its usage, beside it).
|
||||
pub fn report_detached(&mut self, file: &str, detached: Detached) -> Result<(), String> {
|
||||
self.report(file, &detached.file, detached.components, |e, components| {
|
||||
if e.detached == components {
|
||||
return false;
|
||||
}
|
||||
e.detached = components;
|
||||
e.detached_updated = now();
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
/// Keep `counts` from the file called `name` (cleaned: positive counts, one per component, at
|
||||
/// most [`MAX_REPORTED`]) in its entry with `set` (which says whether anything changed),
|
||||
/// keeping at most [`MAX_USERS`] files, the ones heard from most recently.
|
||||
fn report(
|
||||
&mut self,
|
||||
file: &str,
|
||||
name: &str,
|
||||
counts: Vec<(NodeId, u32)>,
|
||||
set: impl FnOnce(&mut UsageEntry, Vec<(NodeId, u32)>) -> bool,
|
||||
) -> Result<(), String> {
|
||||
let name = clean_name(name);
|
||||
if name.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
|
||||
components.sort();
|
||||
components.dedup_by_key(|c| c.0);
|
||||
components.truncate(MAX_REPORTED);
|
||||
let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
|
||||
counts.sort();
|
||||
counts.dedup_by_key(|c| c.0);
|
||||
counts.truncate(MAX_REPORTED);
|
||||
let Some(u) = self.usage_of(file)? else { return Ok(()) };
|
||||
match u.list.iter_mut().find(|f| f.usage.file == name) {
|
||||
Some(f) if f.usage.components == components => return Ok(()),
|
||||
Some(f) => {
|
||||
f.usage.components = components;
|
||||
f.updated = now();
|
||||
let at = match u.list.iter().position(|e| e.usage.file == name) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
u.list.push(UsageEntry { usage: Usage { file: name, components: Vec::new() }, ..UsageEntry::default() });
|
||||
u.list.len() - 1
|
||||
}
|
||||
None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }),
|
||||
};
|
||||
let Some(entry) = u.list.get_mut(at) else { return Ok(()) };
|
||||
if !set(entry, counts) {
|
||||
return Ok(());
|
||||
}
|
||||
if u.list.len() > MAX_USERS {
|
||||
u.list.sort_by_key(|f| std::cmp::Reverse(f.updated));
|
||||
u.list.sort_by_key(|e| std::cmp::Reverse(e.updated.max(e.detached_updated)));
|
||||
u.list.truncate(MAX_USERS);
|
||||
}
|
||||
u.dirty = true;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use std::time::Duration;
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
use web_time::Instant;
|
||||
|
||||
@@ -25,6 +26,7 @@ use crate::exchange::{self, Exchange, Then};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Endpoint;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::limits::NetLimits;
|
||||
use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
@@ -55,6 +57,13 @@ impl Conn {
|
||||
fn new(now: Instant) -> Self {
|
||||
Self { started: now, opened: false, welcomed: false, seen: Seen::default(), last_ping: now, last_heard: now }
|
||||
}
|
||||
|
||||
/// How long the host may stay silent. Until the welcome is whole, the document is arriving as
|
||||
/// one message that doesn't count as heard before it is complete, so a large file on a slow
|
||||
/// link gets the long reply timeout instead of the idle one.
|
||||
fn silence_limit(&self, l: &NetLimits) -> Duration {
|
||||
if self.welcomed { l.idle_timeout } else { l.request_reply_timeout }
|
||||
}
|
||||
}
|
||||
|
||||
/// A joined (or joining) session over a [`Socket`].
|
||||
@@ -315,7 +324,7 @@ impl<S: Socket> Driver<S> {
|
||||
// A managing connection waits for each answer.
|
||||
(true, true) => [Some(c.last_heard + l.request_reply_timeout), None, None],
|
||||
(true, false) => {
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + l.idle_timeout), if c.welcomed { self.throttle.due() } else { None }]
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + c.silence_limit(l)), if c.welcomed { self.throttle.due() } else { None }]
|
||||
}
|
||||
});
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
|
||||
@@ -332,7 +341,7 @@ impl<S: Socket> Driver<S> {
|
||||
}
|
||||
let l = self.cfg.limits;
|
||||
let Some(c) = self.conn.as_ref() else { return };
|
||||
let (opened, started, last_heard, last_ping) = (c.opened, c.started, c.last_heard, c.last_ping);
|
||||
let (opened, started, last_heard, last_ping, silence) = (c.opened, c.started, c.last_heard, c.last_ping, c.silence_limit(&l));
|
||||
if !opened {
|
||||
if now >= started + self.cfg.connect_timeout {
|
||||
self.dropped(format!("no answer from {}", self.server().display()), now);
|
||||
@@ -345,7 +354,7 @@ impl<S: Socket> Driver<S> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if now >= last_heard + l.idle_timeout {
|
||||
if now >= last_heard + silence {
|
||||
return self.end(Outcome::Retry("the host stopped answering".into()), now);
|
||||
}
|
||||
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
|
||||
@@ -184,6 +184,38 @@ fn pings_go_out_while_presence_does_and_the_pongs_keep_the_connection() {
|
||||
assert!(pings >= 5, "a ping every 15 s ({pings})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_welcome_still_downloading_gets_the_long_reply_timeout() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
let limits = GuestConfig::new(invite(), "Ada".into()).limits;
|
||||
// The welcome (the whole document) is one message that is not heard until complete: past the
|
||||
// idle timeout the join must not be given up.
|
||||
let past_idle = t0 + limits.idle_timeout + Duration::from_secs(1);
|
||||
d.tick(past_idle);
|
||||
assert!(!d.done());
|
||||
assert_eq!(d.sock.closes, 0);
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Connecting]);
|
||||
// It does arrive; from then on a silent host is dropped at the idle timeout.
|
||||
d.message(&welcome(), past_idle);
|
||||
let silent = past_idle + limits.idle_timeout;
|
||||
d.tick(silent - ms(1));
|
||||
assert_eq!(d.sock.closes, 0);
|
||||
d.tick(silent);
|
||||
assert_eq!(d.sock.closes, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_host_that_never_welcomes_is_given_up_on_at_the_reply_timeout() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
let limit = t0 + GuestConfig::new(invite(), "Ada".into()).limits.request_reply_timeout;
|
||||
d.tick(limit);
|
||||
assert_eq!(d.sock.closes, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
|
||||
let t0 = Instant::now();
|
||||
|
||||
@@ -233,8 +233,12 @@ impl LiveLink for NetLink {
|
||||
let (inbox, fetching, limits) = (self.libraries.clone(), self.fetching.clone(), self.limits);
|
||||
let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || {
|
||||
for (link, req) in jobs {
|
||||
// A report's answer says nothing about the library (and an older server refuses it).
|
||||
let report = matches!(req, LibraryReq::ReportDetached(_));
|
||||
let reply = manage::library(&link, req, &limits, CONNECT);
|
||||
inbox.push(Event::Library { link, reply });
|
||||
if !report {
|
||||
inbox.push(Event::Library { link, reply });
|
||||
}
|
||||
}
|
||||
fetching.store(false, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@ use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
|
||||
use crate::change::{Change, Json, Prop};
|
||||
use crate::message::{
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, EditingText, FileInfo, FileRow, FileUsage, Hello, HostInfo, LibraryReply, LibraryReq,
|
||||
LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq, Presence, Processed,
|
||||
ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
Accepted, Access, Admin, AdminReply, CatchUp, ClientMsg, Detached, EditingText, FileDetached, FileInfo, FileRow, FileUsage, Hello, HostInfo,
|
||||
LibraryReply, LibraryReq, LinkInfo, Manage, NewLink, OpenHosting, OpenLibrary, OpenPlugins, PROTOCOL, Peer, PluginEntry, PluginReply, PluginReq,
|
||||
Presence, Processed, ReportInfo, Resume, ServerMsg, Signal, Usage, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim::Rng;
|
||||
use crate::{Error, Replica, diff};
|
||||
@@ -71,6 +71,8 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ClientMsg::Library(LibraryReq::Fetch { have: Some(3), usage: Some(Usage { file: "Checkout".into(), components: vec![(NodeId(7), 12)] }) }),
|
||||
ClientMsg::Library(LibraryReq::Publish { archive: vec![0x50, 0x4B, 3, 4] }),
|
||||
ClientMsg::Library(LibraryReq::Usage),
|
||||
ClientMsg::Library(LibraryReq::ReportDetached(Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] })),
|
||||
ClientMsg::Library(LibraryReq::Detached),
|
||||
ClientMsg::Signal(Signal::chat("Hi ☕")),
|
||||
ClientMsg::Signal(Signal::spotlight(true)),
|
||||
ClientMsg::OpenPlugins(OpenPlugins { protocol: PROTOCOL, key: String::new(), client_version: "0.3.1".into() }),
|
||||
@@ -139,6 +141,11 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
|
||||
ServerMsg::Admin(Box::new(AdminReply::Done)),
|
||||
ServerMsg::Admin(Box::new(AdminReply::Failed { reason: "no".into() })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Ready { access: Access::View })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Noted)),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Detached(vec![FileDetached {
|
||||
detached: Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] },
|
||||
updated: 1_700_000_000,
|
||||
}]))),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Library { version: 2, archive: vec![1, 2, 3] })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Unchanged { version: 2 })),
|
||||
ServerMsg::Library(Box::new(LibraryReply::Empty)),
|
||||
@@ -294,3 +301,16 @@ fn message_sizes() {
|
||||
assert!(cb <= 32, "a cursor update is tiny: {cb}");
|
||||
assert!(eb <= 96, "a move is small: {eb}");
|
||||
}
|
||||
|
||||
/// The library messages from before detach counts keep their bytes (the newer ones were added
|
||||
/// after them), so apps and servers on either side of the change still read each other.
|
||||
#[test]
|
||||
fn older_library_messages_keep_their_encoding() {
|
||||
let body = |b: Vec<u8>| b[1..].to_vec();
|
||||
assert_eq!(body(encode(&LibraryReq::Fetch { have: None, usage: None }).unwrap()), [0, 0, 0]);
|
||||
assert_eq!(body(encode(&LibraryReq::Usage).unwrap()), [2]);
|
||||
assert_eq!(body(encode(&LibraryReply::Usage(Vec::new())).unwrap()), [5, 0]);
|
||||
assert_eq!(body(encode(&LibraryReply::Failed { reason: "x".into() }).unwrap()), [6, 1, b'x']);
|
||||
let usage = FileUsage { usage: Usage { file: "A".into(), components: vec![(NodeId(7), 2)] }, updated: 3 };
|
||||
assert_eq!(body(encode(&usage).unwrap()), [1, b'A', 1, 7, 2, 3]);
|
||||
}
|
||||
|
||||
@@ -608,6 +608,21 @@ pub struct FileUsage {
|
||||
pub updated: u64,
|
||||
}
|
||||
|
||||
/// How many instances of each library component one file has detached, as its app reports it:
|
||||
/// the file's name and the counts (by the component's id in the library), each file's own total.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Detached {
|
||||
pub file: String,
|
||||
pub components: Vec<(NodeId, u32)>,
|
||||
}
|
||||
|
||||
/// A file's last report of its detaches, with when it came (seconds since 1970, UTC).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FileDetached {
|
||||
pub detached: Detached,
|
||||
pub updated: u64,
|
||||
}
|
||||
|
||||
/// A request on a library connection; each gets one [`LibraryReply`], in order.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -620,6 +635,13 @@ pub enum LibraryReq {
|
||||
Publish { archive: Vec<u8> },
|
||||
/// The usage reports of the files that use the library.
|
||||
Usage,
|
||||
// Variants below are newer: a server from before them can't read them (it refuses the
|
||||
// request), and the ones above keep their encoding.
|
||||
/// How the asking file has detached the library's components (answered with
|
||||
/// [`LibraryReply::Noted`]).
|
||||
ReportDetached(Detached),
|
||||
/// The detach reports of the files that use the library.
|
||||
Detached,
|
||||
}
|
||||
|
||||
/// The answer to a [`LibraryReq`] (the first one on a connection answers [`OpenLibrary`]).
|
||||
@@ -649,6 +671,10 @@ pub enum LibraryReply {
|
||||
Failed {
|
||||
reason: String,
|
||||
},
|
||||
// Newer replies, only ever sent to the newer requests above.
|
||||
/// A report was kept.
|
||||
Noted,
|
||||
Detached(Vec<FileDetached>),
|
||||
}
|
||||
|
||||
/// Opens a connection to a server's plugin directory (instead of [`Hello`]). Anyone who can reach
|
||||
|
||||
@@ -204,9 +204,36 @@ pub struct Libraries {
|
||||
/// Library variables copied into the document's variables.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub variables: Vec<ImportedRecord>,
|
||||
/// How many instances of each library component this document has detached (reported to
|
||||
/// the library's server with its usage); at most [`MAX_DETACH_COUNTS`] components.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub detached: Vec<DetachCount>,
|
||||
}
|
||||
|
||||
/// Most library components a document counts detaches for.
|
||||
pub const MAX_DETACH_COUNTS: usize = 1_000;
|
||||
|
||||
/// Instances of one library component a document has detached.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DetachCount {
|
||||
/// The library's id.
|
||||
pub library: String,
|
||||
/// The component's (or set's) id in the library.
|
||||
pub key: NodeId,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
impl Libraries {
|
||||
/// Count one more detached instance of component `key` of library `library` (a component not
|
||||
/// counted yet is left out once [`MAX_DETACH_COUNTS`] are).
|
||||
pub fn count_detach(&mut self, library: &str, key: NodeId) {
|
||||
if let Some(d) = self.detached.iter_mut().find(|d| d.library == library && d.key == key) {
|
||||
d.count = d.count.saturating_add(1);
|
||||
} else if self.detached.len() < MAX_DETACH_COUNTS {
|
||||
self.detached.push(DetachCount { library: library.to_string(), key, count: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.enabled.is_empty()
|
||||
&& self.store.is_none()
|
||||
@@ -214,6 +241,7 @@ impl Libraries {
|
||||
&& self.styles.is_empty()
|
||||
&& self.collections.is_empty()
|
||||
&& self.variables.is_empty()
|
||||
&& self.detached.is_empty()
|
||||
}
|
||||
|
||||
/// Serde helper: leave the field out of files that use no libraries.
|
||||
|
||||
@@ -239,6 +239,25 @@ fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str,
|
||||
card_inst
|
||||
}
|
||||
|
||||
/// Detaching an instance of a library component counts it, for the library's analytics: kept with
|
||||
/// the file and reported to the library's server with its usage.
|
||||
fn detaching_a_library_instance_counts_it(s: &mut Session, d: &std::path::Path, lid: &str, inst: NodeId) {
|
||||
ex(s, "component.detach", json!({"ids": [inst]}));
|
||||
let detached = s.doc().unwrap().libraries.detached.clone();
|
||||
assert_eq!(detached.len(), 1, "{detached:?}");
|
||||
assert_eq!((detached[0].library.as_str(), detached[0].count), (lid, 1));
|
||||
let report = crate::library_server::detached(s.doc().unwrap(), lid).expect("a report");
|
||||
assert_eq!(report.components, [(detached[0].key, 1)]);
|
||||
let again = path_str(&d.join("app-detached.figcraft"));
|
||||
ex(s, "file.saveAs", json!({"path": again}));
|
||||
let mut t = Session::empty();
|
||||
ex(&mut t, "file.open", json!({"path": again}));
|
||||
assert_eq!(t.doc().unwrap().libraries.detached, detached, "kept with the file");
|
||||
// A plain frame detaches nothing (and counts nothing).
|
||||
assert!(s.execute("component.detach", &json!({"ids": [inst]})).is_err());
|
||||
assert_eq!(s.doc().unwrap().libraries.detached, detached);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_place_and_update_keep_overrides() {
|
||||
let d = dir("flow");
|
||||
@@ -299,8 +318,9 @@ fn add_place_and_update_keep_overrides() {
|
||||
healthy(&t);
|
||||
assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
|
||||
|
||||
detaching_a_library_instance_counts_it(&mut s, &d, &lid, card_inst);
|
||||
|
||||
// Removing the library keeps the copies instances use and drops the rest.
|
||||
ex(&mut s, "component.detach", json!({"ids": [card_inst]}));
|
||||
ex(&mut s, "library.remove", json!({"id": lid}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.libraries.enabled.is_empty());
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq, Usage};
|
||||
use figcraft_collab::message::{Detached, LibraryReply, LibraryReq, Usage};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, PublishedLibrary};
|
||||
|
||||
use crate::live::{NetReply, NetRequest};
|
||||
@@ -91,6 +91,14 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
|
||||
Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() }
|
||||
}
|
||||
|
||||
/// How many instances of `library`'s components `doc` has detached, as its server keeps them;
|
||||
/// `None` when it has detached none.
|
||||
pub fn detached(doc: &Document, library: &str) -> Option<Detached> {
|
||||
let components: Vec<(NodeId, u32)> =
|
||||
doc.libraries.detached.iter().filter(|d| d.library == library && d.count > 0).map(|d| (d.key, d.count)).take(MAX_USAGE).collect();
|
||||
(!components.is_empty()).then(|| Detached { file: doc.name.clone(), components })
|
||||
}
|
||||
|
||||
/// A reply that doesn't answer the request.
|
||||
fn unexpected(r: &LibraryReply) -> EngineError {
|
||||
EngineError::Other(format!("the server answered {r:?}"))
|
||||
@@ -194,12 +202,15 @@ impl Session {
|
||||
.enabled
|
||||
.iter()
|
||||
.filter(|l| is_server_link(&l.path) && self.library_allowed(&l.path))
|
||||
.map(|l| {
|
||||
.flat_map(|l| {
|
||||
let have = match self.libraries.cached(&l.path) {
|
||||
Some(Ok(lib)) => Some(lib.manifest.version),
|
||||
_ => None,
|
||||
};
|
||||
(l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })
|
||||
// Then the detaches, when there are any (a server from before them refuses the
|
||||
// request, and nothing comes of it).
|
||||
let report = detached(doc, &l.id).map(|d| (l.path.clone(), LibraryReq::ReportDetached(d)));
|
||||
std::iter::once((l.path.clone(), LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) })).chain(report)
|
||||
})
|
||||
.collect();
|
||||
if let (false, Some(net)) = (jobs.is_empty(), self.live.link()) {
|
||||
|
||||
@@ -18,6 +18,13 @@ pub(super) fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if inside_instance(doc, i) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into()));
|
||||
}
|
||||
// A library component's instance counts as detached, for the library's analytics.
|
||||
if let NodeKind::Instance { component, .. } = doc.get(i)?.kind
|
||||
&& let Some(root) = crate::library::asset_root(doc, component)
|
||||
&& let Some((library, key)) = doc.libraries.components.iter().find(|r| r.root == root).map(|r| (r.library.clone(), r.key))
|
||||
{
|
||||
doc.libraries_mut().count_detach(&library, key);
|
||||
}
|
||||
// Own layers lose their link (slot content stays as it is); nested instances stay
|
||||
// instances of their own mains.
|
||||
let nested = unlink_layers(doc, i, Some(i));
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::sync::Arc;
|
||||
use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq};
|
||||
use figcraft_collab::message::{FileDetached, LibraryReply, LibraryReq};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::library::{
|
||||
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"library.usage",
|
||||
"Library usage",
|
||||
"{library? (an enabled team library: id, name or link; omit for this file's library on its server)} → what files report: {id, name, instances, files: [{name, updated, instances}], components: [{id, name, instances, files}]}",
|
||||
"{library? (an enabled team library: id, name or link; omit for this file's library on its server)} → what files report: {id, name, instances, detached, detachesKnown (false: the server keeps no detaches), files: [{name, updated, instances, detached}], components: [{id, name, instances, files, detached}]}; a detach is an instance of the component a file detached",
|
||||
false,
|
||||
usage_cmd
|
||||
),
|
||||
@@ -432,9 +432,10 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
LibraryReply::Usage(f) => f,
|
||||
other => return Err(unexpected(&other)),
|
||||
};
|
||||
let detached = detach_reports(s.library_request(&link, LibraryReq::Detached))?;
|
||||
let m = &lib.manifest;
|
||||
// Component → (instances, files using it).
|
||||
let mut per: std::collections::BTreeMap<NodeId, (u64, u64)> = std::collections::BTreeMap::new();
|
||||
// Component → (instances, files using it, instances detached).
|
||||
let mut per: std::collections::BTreeMap<NodeId, (u64, u64, u64)> = std::collections::BTreeMap::new();
|
||||
let mut rows = Vec::new();
|
||||
for f in &files {
|
||||
let mut n = 0u64;
|
||||
@@ -444,21 +445,39 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
e.1 += 1;
|
||||
n += u64::from(*k);
|
||||
}
|
||||
rows.push(json!({"name": f.usage.file, "updated": f.updated, "instances": n}));
|
||||
let gone = detached.iter().flatten().filter(|d| d.detached.file == f.usage.file).flat_map(|d| &d.detached.components);
|
||||
rows.push(json!({"name": f.usage.file, "updated": f.updated, "instances": n, "detached": gone.map(|(_, k)| u64::from(*k)).sum::<u64>()}));
|
||||
}
|
||||
let mut comps: Vec<(NodeId, u64, u64)> = per.into_iter().map(|(c, (n, f))| (c, n, f)).collect();
|
||||
comps.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
|
||||
let components: Vec<Value> =
|
||||
comps.iter().map(|(c, n, f)| json!({"id": c, "name": m.component(*c).map(|a| a.name.clone()), "instances": n, "files": f})).collect();
|
||||
for (c, k) in detached.iter().flatten().flat_map(|d| &d.detached.components) {
|
||||
per.entry(*c).or_default().2 += u64::from(*k);
|
||||
}
|
||||
let mut comps: Vec<(NodeId, (u64, u64, u64))> = per.into_iter().collect();
|
||||
comps.sort_by(|a, b| b.1.0.cmp(&a.1.0).then(b.1.2.cmp(&a.1.2)).then(a.0.cmp(&b.0)));
|
||||
let components: Vec<Value> = comps
|
||||
.iter()
|
||||
.map(|(c, (n, f, d))| json!({"id": c, "name": m.component(*c).map(|a| a.name.clone()), "instances": n, "files": f, "detached": d}))
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"instances": comps.iter().map(|c| c.1).sum::<u64>(),
|
||||
"instances": comps.iter().map(|c| c.1.0).sum::<u64>(),
|
||||
"detached": comps.iter().map(|c| c.1.2).sum::<u64>(),
|
||||
"detachesKnown": detached.is_some(),
|
||||
"files": rows,
|
||||
"components": components,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The detach reports a server keeps; `None` from a server from before they were kept (it refuses
|
||||
/// the request). Waiting for the answer is still waiting.
|
||||
fn detach_reports(reply: Result<LibraryReply>) -> Result<Option<Vec<FileDetached>>> {
|
||||
match reply {
|
||||
Ok(LibraryReply::Detached(d)) => Ok(Some(d)),
|
||||
Err(EngineError::Waiting) => Err(EngineError::Waiting),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "library.swap";
|
||||
let from = req_string(cmd, p, "from")?;
|
||||
@@ -703,3 +722,20 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::styles::apply(s, &with(p, &["ids", "id", "target"], json!({"style": local})))?;
|
||||
Ok(json!({"style": local}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod detach_report_tests {
|
||||
use super::*;
|
||||
use figcraft_collab::message::Detached;
|
||||
|
||||
/// A server that keeps detaches answers with them; one from before them refuses the request,
|
||||
/// which leaves the analytics without detaches instead of failing; waiting stays waiting.
|
||||
#[test]
|
||||
fn detaches_are_unknown_on_an_older_server() {
|
||||
let d = vec![FileDetached { detached: Detached { file: "Checkout".into(), components: vec![(NodeId(7), 2)] }, updated: 1 }];
|
||||
assert_eq!(detach_reports(Ok(LibraryReply::Detached(d.clone()))).unwrap(), Some(d));
|
||||
let old = Err(EngineError::Other("the server's message couldn't be read".into()));
|
||||
assert_eq!(detach_reports(old).unwrap(), None);
|
||||
assert!(matches!(detach_reports(Err(EngineError::Waiting)), Err(EngineError::Waiting)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply {
|
||||
LibraryReply::Published { version: 1 }
|
||||
}
|
||||
(LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()),
|
||||
(LibraryReq::ReportDetached(_), _) => LibraryReply::Noted,
|
||||
(LibraryReq::Detached, _) => LibraryReply::Detached(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ status = "draft — needs native review"
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Jedan sloj se poravnava prema svom okviru: odaberite više slojeva ili jedan sloj u okviru"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Responzivna stranica proizvoda: naslovni dio, mogućnosti, brojke, citat i podnožje"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Opruga traje dok se ne smiri: njena masa, krutost i prigušenje određuju koliko dugo"
|
||||
"A table takes at most {rows} rows and {columns} columns: the rest wasn't pasted" = "Tabela prima najviše {rows} redova i {columns} kolona: ostatak nije zalijepljen"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Naslov, dnevni red, velika brojka, grafikon i završni slajd"
|
||||
"A to Z" = "A do Ž"
|
||||
"About" = "O aplikaciji"
|
||||
@@ -822,6 +823,8 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Glavni meni"
|
||||
"Make a component" = "Napravi komponentu"
|
||||
"Make slot" = "Napravi slot"
|
||||
"Pasted design layers" = "Zalijepljeni slojevi dizajna"
|
||||
"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Zalijepljeni slojevi dizajna; međuspremnik nije prenio slike za: {layers}"
|
||||
"Scroll wheel zoom" = "Zumiranje kotačićem miša"
|
||||
"Slot name" = "Ime slota"
|
||||
"Mar" = "mar."
|
||||
@@ -1710,6 +1713,7 @@ status = "draft — needs native review"
|
||||
"connector label was skipped: text style data is missing" = "oznaka konektora je preskočena: nedostaju podaci o stilu teksta"
|
||||
"connector label was skipped: text transform is invalid" = "oznaka konektora je preskočena: transformacija teksta nije ispravna"
|
||||
"connector labels were skipped" = "oznake konektora su preskočene"
|
||||
"connector labels without a saved snapshot are centred on the line, without their background" = "oznake konektora bez sačuvanog snimka centrirane su na liniji, bez svoje pozadine"
|
||||
"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "slojevi konektora se uvoze kao statični snimci okvira/vektora s oznakama ako postoje; žive veze krajnjih tačaka se ne zadržavaju"
|
||||
"connector snapshot vector data is missing; connector was left empty" = "nedostaju vektorski podaci snimka konektora; konektor je ostavljen prazan"
|
||||
"connectors whose ends can't be placed are empty" = "konektori čiji se krajevi ne mogu postaviti su prazni"
|
||||
@@ -1731,6 +1735,7 @@ status = "draft — needs native review"
|
||||
"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista veličina traka mreže premašuje 1000 unosa; trake koriste zadanu jednaku veličinu"
|
||||
"hidden" = "skriveno"
|
||||
"iOS" = "iOS"
|
||||
"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "ispune slikom čija slika nije u datoteci prikazuju zamjenski prikaz nedostajuće slike i zadržavaju njeno ime"
|
||||
"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filteri slike su dvosmisleni jer su prisutna oba kontejnera filtera; pretvaranje filtera je preskočeno"
|
||||
"image filters use FigCraft's approximate renderer" = "filteri slike koriste FigCraftov približni renderer"
|
||||
"instance swap properties whose default component isn't in the file were skipped" = "preskočena su svojstva zamjene instance čija zadana komponenta nije u datoteci"
|
||||
@@ -1753,6 +1758,7 @@ status = "draft — needs native review"
|
||||
"layers without a type were skipped" = "slojevi bez vrste su preskočeni"
|
||||
"legacy image filter container is unsupported; filter conversion was skipped" = "stari kontejner filtera slike nije podržan; pretvaranje filtera je preskočeno"
|
||||
"linked vector region fill styles keep resolved appearance; live style links were left out" = "povezani stilovi ispune vektorskih oblasti zadržavaju razriješeni izgled; žive veze sa stilovima su izostavljene"
|
||||
"links to shared styles that aren't in the file kept the layers' own values" = "veze na dijeljene stilove koji nisu u datoteci zadržale su vlastite vrijednosti slojeva"
|
||||
"locked" = "zaključano"
|
||||
"malformed image filters container was skipped" = "neispravan kontejner filtera slike je preskočen"
|
||||
"malformed image paintFilter container was skipped" = "neispravan kontejner paintFilter slike je preskočen"
|
||||
|
||||
@@ -1529,6 +1529,7 @@ name = "English"
|
||||
"This frame isn't on the branch." = "This frame isn't on the branch."
|
||||
"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:"
|
||||
"This link opens your mail app to write a message:" = "This link opens your mail app to write a message:"
|
||||
"This server doesn't count detached instances" = "This server doesn't count detached instances"
|
||||
"This server is open to the public: no key needed." = "This server is open to the public: no key needed."
|
||||
"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)."
|
||||
"This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced."
|
||||
@@ -1873,6 +1874,7 @@ name = "English"
|
||||
"{n} components and styles have newer versions" = { one = "{n} component or style has a newer version", other = "{n} components and styles have newer versions" }
|
||||
"{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" }
|
||||
"{n} days ago" = { one = "{n} day ago", other = "{n} days ago" }
|
||||
"{n} detached" = { one = "{n} detached", other = "{n} detached" }
|
||||
"{n} files" = { one = "{n} file", other = "{n} files" }
|
||||
"{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" }
|
||||
"{n} instances" = { one = "{n} instance", other = "{n} instances" }
|
||||
@@ -1911,6 +1913,7 @@ name = "English"
|
||||
"{server} · No longer on the server" = "{server} · No longer on the server"
|
||||
"{server} · {shared} · Opened {date}" = "{server} · {shared} · Opened {date}"
|
||||
"{tool} hold {key}" = "{tool} hold {key}"
|
||||
"{used} · {detached}" = "{used} · {detached}"
|
||||
"{value} isn't a colour" = "{value} isn't a colour"
|
||||
"{when} · edited" = "{when} · edited"
|
||||
"← ↑ → ↓" = "← ↑ → ↓"
|
||||
|
||||
@@ -1010,13 +1010,28 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
|
||||
let instances = v["instances"].as_u64().unwrap_or(0);
|
||||
let files_n = |n: u64| trn("{n} files", n as usize, &[]);
|
||||
let instances_n = |n: u64| trn("{n} instances", n as usize, &[]);
|
||||
hint(ui, &trf("Used in {files} · {instances}", &[("files", &files_n(files as u64)), ("instances", &instances_n(instances))]));
|
||||
// Detached instances, beside what they are counted with (a server from before them keeps none).
|
||||
let known = v["detachesKnown"].as_bool().unwrap_or(false);
|
||||
let with_detached = |used: String, n: u64| {
|
||||
if known && n > 0 { trf("{used} · {detached}", &[("used", &used), ("detached", &trn("{n} detached", n as usize, &[]))]) } else { used }
|
||||
};
|
||||
let used = trf("Used in {files} · {instances}", &[("files", &files_n(files as u64)), ("instances", &instances_n(instances))]);
|
||||
hint(ui, &with_detached(used, v["detached"].as_u64().unwrap_or(0)));
|
||||
if !known {
|
||||
hint(ui, tr("This server doesn't count detached instances"));
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) {
|
||||
let name = c["name"].as_str().unwrap_or(tr("(no longer published)"));
|
||||
let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0));
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover());
|
||||
let right = trf("{instances} in {files}", &[("instances", &instances_n(n)), ("files", &files_n(f))]);
|
||||
let gone = c["detached"].as_u64().unwrap_or(0);
|
||||
// A component only detached (no instances left anywhere) says just that.
|
||||
let right = if n == 0 && known && gone > 0 {
|
||||
trn("{n} detached", gone as usize, &[])
|
||||
} else {
|
||||
with_detached(trf("{instances} in {files}", &[("instances", &instances_n(n)), ("files", &files_n(f))]), gone)
|
||||
};
|
||||
let rg = ui.painter().layout_no_wrap(right, regular(10.0), t.text_secondary);
|
||||
ui.painter().galley(pos2(r.max.x - PAD - rg.size().x, r.center().y - rg.size().y / 2.0), rg.clone(), t.text_secondary);
|
||||
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.center().y), name, regular(11.0), t.text, w - PAD * 3.0 - rg.size().x);
|
||||
|
||||
+10
-3
@@ -257,7 +257,7 @@ Client → server:
|
||||
| `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. |
|
||||
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
|
||||
| `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. |
|
||||
| `Library {fetch {have?, usage?} \| publish {archive} \| usage}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`. |
|
||||
| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. |
|
||||
| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). |
|
||||
| `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. |
|
||||
| `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. |
|
||||
@@ -279,7 +279,7 @@ Server → client:
|
||||
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
|
||||
| `Pong` | Keep-alive. |
|
||||
| `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. |
|
||||
| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason}}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not. |
|
||||
| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}]}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not; then (newer) a detach report kept, and the detach reports. |
|
||||
| `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. |
|
||||
| `Hosting {open, operator, terms, idle_secs, max_file_mb, expires?}` | The answer to `OpenHosting`, and after a welcome to clients whose hello announced `hosting`: anyone may publish, how to reach the operator, the terms (a URL), how long files published without the key are kept unopened, the largest file, and this file's expiry (seconds since 1970). |
|
||||
| `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. |
|
||||
@@ -718,7 +718,9 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
(resuming as the same client after a drop), presence held until the welcome and changes made
|
||||
for an earlier connection dropped (the sync client sends them again once caught up), presence throttled to about 30 updates a second
|
||||
(latest wins), a `Ping` after 15 s without sending (browsers don't expose WebSocket ping frames;
|
||||
the host answers `Pong`), a connection silent for 45 s counted as dropped, reconnects with the
|
||||
the host answers `Pong`), a connection silent for 45 s counted as dropped (5 minutes before the
|
||||
welcome: it carries the whole document as one message, which a large file on a slow link takes
|
||||
a while to download, and nothing counts as heard until it is complete), reconnects with the
|
||||
same growing pauses after a live connection drops, refusals and the end of the session final,
|
||||
and a first join that can't reach the host failing at once. The browser layer only feeds it the
|
||||
socket's events and one `setTimeout` for its next deadline, and every arrival wakes egui
|
||||
@@ -813,6 +815,11 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
- **Usage.** Each fetch carries the asking file's name and its instances of each library component;
|
||||
the server keeps the latest report per file name (`usage.json`, rewritten only when it changes,
|
||||
capped) and answers `usage` with them. `library.usage` adds them up with the components' names.
|
||||
After the fetch, a file that has detached instances of the library's components sends
|
||||
`reportDetached` with its counts; the server keeps them beside the file's usage report (in the
|
||||
same `usage.json` entry, capped the same way) and answers `detached` with them. The variants are
|
||||
appended to the enums, so the older messages keep their bytes; an older server refuses the new
|
||||
requests, and the app ignores that.
|
||||
|
||||
*Built* (the plugin directory; operating it: [`server.md`](server.md#plugin-directory), publishing and
|
||||
installing: [`plugins.md`](plugins.md#plugin-directories)):
|
||||
|
||||
@@ -52,7 +52,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, as apps report them; nothing over time, no detach counts |
|
||||
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; nothing over time, no insertion counts |
|
||||
| Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step |
|
||||
|
||||
## Styles and variables
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Library analytics count detaches: a file that detaches an instance of a library component counts it (kept with the file) and its app reports the counts to the library's server beside its usage; **Show usage** and `library.usage` list them per component and per file (`detached`, `detachesKnown`). Older servers and apps are unaffected: the reports are new messages and the old ones keep their encoding. Still nothing over time. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M3, #1613: a variable, collection or mode named like another one's id no longer takes its place where an id is stored or typed (expressions, prototype mode switches, font family variables, the panels' labels); names still work where a person types them. Grade unchanged. |
|
||||
| 2026-10-30 | minor | M3, #1280: a colour variable can be calculated from others, per mode: `{"alpha": {"variable", "opacity"}}` is that variable's colour with its alpha replaced (replace, not multiply, so re-editing the source's alpha never compounds), `{"mix": {"from", "to", "t"}}` blends two operands (`{"variable"}` or `{"literal"}`) per channel over the stored values in the document's colour profile, alpha included (converting the profile keeps a mix's ends exact and moves its colours in between slightly). Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose (a calculation of a calculation), a loop is refused when it is set, and one resolve consults at most 32 variables. Files keep them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes (other collections in their default modes, so a CSS calculation doesn't follow another collection's `data-` mode); deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The panel shows them read-only (swatch and formula) and can swap one for an alias or detach it to its colour. Tests: `engine/src/variables_colour_tests.rs`, `doc/src/tests.rs`. Left: editing calculations in the panel; files using them need this build to open. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged |
|
||||
|
||||
+3
-2
@@ -342,8 +342,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
### 10. Team libraries: no team-wide lists or accounts; analytics are counts
|
||||
- **Missing:** libraries listed for a whole team or organization (each file enables a library by
|
||||
its link), accounts and per-person library permissions, analytics beyond instance counts per
|
||||
component and file (insertions and detaches over time), update notices pushed by the server
|
||||
its link), accounts and per-person library permissions, analytics beyond instance and detach
|
||||
counts per component and file (insertions, and anything over time), update notices pushed by the server
|
||||
(apps ask every 30 seconds while the Assets panel shows).
|
||||
- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in
|
||||
other files by a share link, with per-asset versions, updates with overrides kept, usage counts
|
||||
@@ -573,6 +573,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Gap 10: library analytics count detached instances per component and file (reported by the file that detached them, kept by the server beside its usage, shown in Show usage and `library.usage`); insertions and counts over time remain. Estimate unchanged. Tests: `library_detach_tests` (server), `codec_tests::older_library_messages_keep_their_encoding`, `assets::library::tests::add_place_and_update_keep_overrides`, `detach_report_tests` |
|
||||
| 2026-10-30 | patch | Gap 15: `proto.linkNotice` (the Actions palette, the control channel, MCP) is saved with the preferences at once, as the notice's own "Don't show this again" is, instead of only when the app quits; that note leaves the missing list. Estimate unchanged. Test: `present_links_tests::skipping_the_notice_by_command_is_saved_at_once` |
|
||||
| 2026-10-30 | minor | Gap 6: plugin panels show pictures: an `image` control (`data` base64, at most 256 KB and 4,096 px a side; panel specs up to 1 MB) drawn at its size up to a field's width, named by its label, which shows instead when the picture can't be decoded. "Images" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::panel_images_show_named_or_fall_back_to_their_label` |
|
||||
| 2026-10-30 | minor | Gap 1: labels of connectors without a saved snapshot are kept, centred on their line (without their background), instead of skipped. Not checked on real files; estimate unchanged. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
|
||||
|
||||
@@ -105,7 +105,7 @@ Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), revi
|
||||
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu
|
||||
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header
|
||||
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,292 / 2,352 (97.4%), a draft
|
||||
with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,561 / 2,561 (100%: 1,901 UI
|
||||
with one/few/other plurals; Bosnian (`bs`, Bosanski, added 2026-10-30) 2,566 / 2,566 (100%: 1,906 UI
|
||||
strings, 629 command labels, 31 menu wordings), a machine draft awaiting native review, with
|
||||
one/few/other plurals (21 takes one). Figma ships none beyond the eight above.
|
||||
|
||||
@@ -125,7 +125,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,901), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
|
||||
| 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) |
|
||||
| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged |
|
||||
| 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed |
|
||||
| 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged |
|
||||
|
||||
+5
-3
@@ -924,9 +924,11 @@ most when it can't be reached).
|
||||
`library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about
|
||||
team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer,
|
||||
so opening a file that names another one contacts nothing (#1097).
|
||||
- `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}],
|
||||
components: [{id, name, instances, files}]}`: what the files using it reported (each fetch
|
||||
reports the asking document's name and its instances per component). `library` is an enabled
|
||||
- `library.usage {library?}` → `{id, name, instances, detached, detachesKnown, files: [{name,
|
||||
updated, instances, detached}], components: [{id, name, instances, files, detached}]}`: what the
|
||||
files using it reported (each fetch reports the asking document's name and its instances per
|
||||
component; then its detached instances per component, which `detached` adds up;
|
||||
`detachesKnown` is false from a server that doesn't keep them). `library` is an enabled
|
||||
team library; omit it for the active document's own library on its server.
|
||||
- `library.swap {from, to}` (any two enabled libraries, files or team) re-points every instance,
|
||||
style and variable copied from `from` to the published asset of the same name in `to` (variants
|
||||
|
||||
@@ -76,6 +76,7 @@ blockers.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M3, gap 10: library analytics count detached instances per component and file (reported to the server beside usage; Show usage and `library.usage`). Tests: `library_detach_tests`, `codec_tests` |
|
||||
| 2026-10-30 | minor | M12, #1596: the bar over a selected connector on a board had the line type and one arrow button. It now has, beside the board colours and the line type, a colour wheel opening the colour picker for any colour (when only connectors are selected), menus for the start and end points (every line end the Design panel offers, each shown), and a line style menu (solid or dashed, weights 1, 2, 4 and 6), each control naming itself on hover. The arrow toggle gave way to the end point menu. UI only: `board.setStyle` caps and the stroke's dashes and weight, one undo step each. Tests: `board_line_tests.rs` (headless). |
|
||||
| 2026-10-30 | minor | M12, gap 24: cells copied from a spreadsheet paste onto a board as a table. The clipboard carries them as text: a row per line, a tab between cells, and a cell holding a tab, a line break or a quote in quotes. `board_table::spreadsheet_cells` reads that into `board.table`'s `cells`. The table is centred in the view, or with Paste here its top-left goes at the point. Up to 50 × 20 cells are kept, with a note when there were more. Text without tabs, comma-separated text and any paste into a design file stay a text layer, as before. Tests: `board_table_paste_tests`, `table_paste_tests`. |
|
||||
| 2026-10-30 | minor | M6, #1650: a very long frame name made the Export section's **Export <name>** button wrap its label into a tall block centred on the button, painted over Position, Layout, Appearance, Fill and the rest of the Design panel. The button now shows one line ending with an ellipsis, with the full name on hover, and line breaks in a name show as spaces. Dev Mode's header, which names the layer, does the same instead of running off the panel's edge. Shortening text to fit now halves its way to the longest fit instead of trying one candidate per character: with a 4,000-character name that kept frames at about 0.4 ms instead of about 9.5 ms, and it finds exactly what the old search found. |
|
||||
|
||||
+5
-3
@@ -249,9 +249,11 @@ which other files use without anyone copying the library file around.
|
||||
checks it by itself from then on (#1097). Placing one of its components or applying one of its
|
||||
styles asks the server too, since you did that yourself.
|
||||
- **Usage.** Each time a file's app asks for the library it reports which of its components the
|
||||
file uses and how often. **Show usage** in the library file (`library.usage`) lists the files and
|
||||
the instances of each component. Files are told apart by their names; a file's report changes
|
||||
only when its usage does.
|
||||
file uses and how often, and then how many instances of each the file has detached (counted in
|
||||
the file when you detach one). **Show usage** in the library file (`library.usage`) lists the
|
||||
files and the instances and detaches of each component. Files are told apart by their names; a
|
||||
file's report changes only when its usage does. A server from before detach counts refuses
|
||||
those reports (nothing comes of it) and Show usage says it doesn't count them.
|
||||
- **Swapping.** `library.swap` (the swap button by an enabled library) moves a file's instances,
|
||||
styles and variables from one library to another's of the same names, keeping overrides.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user