diff --git a/ROADMAP.md b/ROADMAP.md index 01f59651..addde74e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | +| 2026-10-30 | minor | M8, #1074: on `figcraft-server` every join of an idle file read its snapshot and log back and every join and leave rewrote its `meta.json`, all on the hub thread, so join/leave loops amplified into disk I/O. A file everyone left (its log synced) now stays loaded for a minute (at most 16 such files, 256 MiB together), and a join in that time takes it as it was with nothing read; its open time, which expiry counts in days, is written at most hourly and when the server stops. Edits are saved as before (logged, compacted when everyone leaves). Measured: 20 join/leave cycles of an unchanged file did 0 reads and 0 writes (40 reads before, plus a `meta.json` write per join and leave). Numbers unchanged. Tests: `idle_io_tests`. | | 2026-10-30 | minor | M8, #1079: one budget for everything a `.figcraft` file unpacks when it opens. Each area had its own cap (document 4 GiB, images 4 GiB, library 1.5 GiB, versions 1 GiB, branches 1 GiB), so one file could make the app unpack about 10 GB. Now they also share one total, 6 GiB (2 GiB on 32-bit targets such as the browser), counted member by member in reading order: document, images, published library, version history, branches; the per-area caps stay. The document (a 900 000-layer one unpacks to 1.5 GB) and the images up to their own cap fit within it, so a file whose images pass 4 GiB (#1313) keeps as many as before, and images left out by the total are treated like those past the images' cap: they stay in the file and saving over it is refused rather than dropping them. History and branches past the total are skipped, and a document past it refused, each with the note "the file's contents together pass … MB". Numbers unchanged. Tests: `unpacked_total_tests`. | | 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | | 2026-10-30 | minor | M8, #1077: two quadratic costs in the `.fig` import's instance expansion. Variable and collection nodes sat in the layer tree, so every instance of a main walked the ones under it again, and as they never become layers the layer cap never stopped them; they are now left out of the tree (variables are read from the node list as before), so everything an expansion visits counts against the cap. A nested instance copied all of its outer instance's override rows, and its property values and slot contents; it now takes only the rows addressed below it (they sort together, so the rest aren't visited) and shares the outer values, keeping only its own. Normal files map as before. Crafted files: 20 000 variables under a main with 2 000 instances 13.8 s → 21 ms; 50 000 override rows over 2 000 nested instances 40.5 s → 98 ms (native release, the same output). Numbers unchanged. Tests: `expansion_cost_tests`. | diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index 312b4aea..cf56ea26 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -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, 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, 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() } diff --git a/apps/figcraft-server/src/idle_io_tests.rs b/apps/figcraft-server/src/idle_io_tests.rs new file mode 100644 index 00000000..9df754a4 --- /dev/null +++ b/apps/figcraft-server/src/idle_io_tests.rs @@ -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, 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 = + (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)); +} diff --git a/apps/figcraft-server/src/lib.rs b/apps/figcraft-server/src/lib.rs index ce5eefdc..c488d19f 100644 --- a/apps/figcraft-server/src/lib.rs +++ b/apps/figcraft-server/src/lib.rs @@ -101,6 +101,8 @@ mod directory_tests; #[cfg(test)] mod hardening_tests; #[cfg(test)] +mod idle_io_tests; +#[cfg(test)] mod library_tests; #[cfg(test)] mod owned_tests; diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 2108bfb8..9e12d328 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -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, + /// 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, @@ -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). diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs index fc36a337..91bdc3ac 100644 --- a/apps/figcraft-server/src/store/backend.rs +++ b/apps/figcraft-server/src/store/backend.rs @@ -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 = 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); diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs index 565916ed..f0d8ddcb 100644 --- a/apps/figcraft-server/src/store/files.rs +++ b/apps/figcraft-server/src/store/files.rs @@ -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 { 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 = 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; diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs index 5f8904fc..efab8ec7 100644 --- a/apps/figcraft-server/src/store/journal.rs +++ b/apps/figcraft-server/src/store/journal.rs @@ -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 { 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 { 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; diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index 242826f1..5513842b 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. | | 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry each thread's mentions as `comment.list` does. Numbers unchanged. | | 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. | | 2026-10-30 | minor | M9, part of #1415: `comment.list {mentions}` lists only the threads where a message @mentions a name, combined with the other filters, so an agent tagged in comments reads just its threads. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index e7d692c5..96384d32 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| | 2026-10-30 | minor | M8, #1114 (explicit variables files): `variable.import` and the `variables` of `file.open` and `file.import` read a path with the same 256 MiB cap and regular-file check as the sibling `variables.json`, so an oversized file, a folder or a device is refused before the document changes; inline objects and JSON text, access roots and the browser's error are unchanged. Primary file reads and `image.place` are still uncapped. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | +| 2026-10-30 | minor | M8, #1074: a server file everyone left stays loaded a minute (rejoining reads nothing) and its open time is written at most hourly, so join/leave cycles of an unchanged file do no disk I/O. Tests: `idle_io_tests` | | 2026-10-30 | minor | M8, #1079: one total for what a `.figcraft` file unpacks on open (6 GiB; 2 GiB on 32-bit), shared by the document, images, library, versions and branches beyond their own caps; images past it stay in the file like those past the image cap (#1313). Tests: `unpacked_total_tests` | | 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | | 2026-10-30 | minor | M8, #1077: `.fig` instance expansion near linear on crafted files: variable nodes are out of the layer tree, nested instances take only the override rows below them and share property values; 13.8 s → 21 ms and 40.5 s → 98 ms on crafted files. Tests: `expansion_cost_tests` | diff --git a/docs/server.md b/docs/server.md index 1dd2b9f1..f763b505 100644 --- a/docs/server.md +++ b/docs/server.md @@ -337,7 +337,8 @@ behaves as before (blocked addresses, report rates and the limits on ``` A file published without the key also has, in its `meta.json`, the SHA-256 of its owner token, -the address it came from and when it was last opened (for expiry). +the address it came from and when it was last opened (for expiry; written at most hourly while the +file is used, and when the server stops). - **Writes are atomic**: a snapshot, a description or a link list is written to a temporary file beside it, flushed, then renamed over the old one. The log is only ever appended to, and synced @@ -347,6 +348,8 @@ the address it came from and when it was last opened (for expiry). edited (the ids of people who only joined stay in the log), and when the server stops. A log left over from a compaction cut short is replayed over the snapshot, which already holds it all, so nothing changes. If a damaged generation field made a current log look like such a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported. +- **A file everyone left stays loaded for a minute** (at most 16 such files, 256 MiB together), + its log already synced: someone joining in that time gets it without anything being read again. - **Damage is skipped and reported, never deleted.** At startup a folder whose description, links or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged record loses only that end (logged when the file opens). Leftover temporary files are removed.