mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 08:21:06 +00:00
Merge remote-tracking branch 'origin/main' into feat/996-inspect-fields
# Conflicts: # docs/roadmap.md
This commit is contained in:
182 files changed
+5064
-578
No files matched your search
@@ -26,8 +26,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@fa5ef18780cf5e7d5d3b8de068de56cffc83cac5
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-dir: FigCraft
|
||||
|
||||
@@ -131,3 +131,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@@ -132,3 +132,8 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
Generated
+2
@@ -1848,6 +1848,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2100,6 +2101,7 @@ dependencies = [
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
"figcraft-engine",
|
||||
"figcraft-format",
|
||||
"figcraft-text",
|
||||
"figcraft-ui-egui",
|
||||
"js-sys",
|
||||
|
||||
+21
-1
@@ -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-29 · **Change:** minor (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 halfwidthLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); 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, withLine truncated
|
||||
|
||||
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
|
||||
@@ -355,6 +355,26 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M8, #1101: the importers' 256-level depth cap could overflow the browser's 1 MB stack before it was reached. Measured per level: the REST import's layer walk (also the second stage of every `.fig` import) took about 4 KB, a whole megabyte at 256, and overflowed a 1 MB thread; it now runs on a work list in the same order (groups fitted once their children are in), a few KB in all. The `.fig` mapping, which builds each layer from its children's results, stays recursive at about 1.8 KB a level, 455 KB at the cap. Desktop and browser keep the 256-level cap, so nothing that loads today is cut off. Numbers unchanged. Tests: `deep_import_tests` (256 and 400 levels on a 1 MB thread). |
|
||||
| 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. |
|
||||
| 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). |
|
||||
| 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`. |
|
||||
| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists every name a message @mentions besides those who wrote or reacted (once each in any case, a known name spelt as the file spells it, never an email address), so a name that has only been tagged shows up. `comment.next` takes the `mentions` filter and, like `comment.open`, returns the thread as `comment.list` lists it, with each message's `mentions`; `comment.prompt`'s bounded `comments` carry each thread's mentions (at most 32, names cut to 80 characters). One mention helper serves them all. Numbers unchanged. Tests: `comment_mentions_tests`. |
|
||||
| 2026-10-30 | minor | M8, #1083: grid auto-placement tested every cell of a child's span at every position it tried (children × rows × columns × span area), so a crafted grid of wide children kept every layout pass busy. The grid's taken cells are now a bitmap: a span is checked a 64-bit word at a time, the search jumps past the cell that blocks it, a row's search ORs the span's rows once, and a column-anchored child looks on from where the last one of the same column and spans found room (cells only ever get taken). Placements are the same, checked against the old search on 400 random grids. A crafted 1000 × 1000 grid with 1000 full-width children: 192 ms → 0.3 ms; 3000 column-anchored wide children: 4.2 ms → 0.7 ms; ordinary grids unchanged (20 000 1 × 1 children 5.2 ms either way). Numbers unchanged. Tests: `grid_place_tests`. |
|
||||
| 2026-10-30 | minor | M1, #1430: from the pen into vector edit mode. A double-click with the pen drew on: its second press added a point on top of the last one (a zero-length segment), a double-click meant to edit a layer drew a stray two-point vector on it, and Enter ran what Escape does. Now a press on the pen's last anchor adds nothing; a double-click while drawing ends the path at that point, and one that only started a path drops it and edits the layer under the pointer; Enter ends the path. Both go on editing the vector with the Move tool (points and handles); the next Enter leaves edit mode. In edit mode the pen's double-click and Enter take the Move tool too, a double-click taking back the point or new path its first press began. Escape still keeps the pen in edit mode for the next path (#760). Numbers unchanged. Tests: `pen_vector_edit_tests`. |
|
||||
| 2026-10-30 | minor | M1: desktop recovery copies made while a version is previewed held the version shown instead of the file, so a crash during a preview brought the old version back as the unsaved file. What a recovery copy holds is now one shared helper, `figcraft_engine::branches::recovery_copy`: the document itself (`DocState::document_itself`, the same document the unsaved-changes check compares, not the version or live text preview shown in its place), its version history and its branches. The desktop copies and the browser's (#1580, which already took the document itself) both use it. Tests: `recovery_tests.rs` in the desktop and web apps (a copy made during a version preview recovers the file and its history). |
|
||||
| 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). |
|
||||
| 2026-10-30 | minor | M6, #1342: FigJam `.jam` files open. A `.jam` is the same archive as a `.fig` (its canvas header reads `fig-jam.`), and the `.fig` reader already took it when a copy was renamed; now File ▸ Open, its file filter, drag and drop (home screen and canvas), `file.import` and the command line take `.jam` too, and the home screen's recent files show their thumbnails. What comes in is what the `.fig` reader makes of FigJam layers: stickies and shapes with text as frames with their generated layers, tables, stamps and the like as frames with an import note, connectors as vectors, and a layer type it doesn't know left out with a note naming it. The file opens as a design file, not a board, and its objects carry no board metadata yet. Tests: a synthetic `fig-jam.` canvas, bare and zipped (`board_import_tests.rs`), a `.jam` made from our own `.fig` export opened and imported (`jam_open_tests.rs`), the extension checks (`home_tests.rs`). |
|
||||
| 2026-10-30 | minor | M8, #1574: the Actions palette (Ctrl+K) offered engine commands that need parameters it can't give, such as `image.place` ("Place image", needing the image's `data` or `path`), which failed with "Invalid parameters" and sat above the menu's Place image… row with the same label. A search now leaves out a command no menu lists when its params doc names a parameter without `?` (`figcraft_engine::cmd::needs_params`), and a row no menu item gives whose label reads like a menu item's (any case, without the ellipsis). Place image runs File ▸ Place image…, which opens the file dialog. Tests: `palette_tests.rs` (no row needs parameters unless a menu item runs it, no duplicate label across several searches), `unknown_params_tests.rs`. |
|
||||
| 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 | M1, #1390: Preferences ▸ Scroll wheel zoom (`pref.scrollWheelZoom`, off by default, kept with the preferences). On, a mouse wheel zooms the canvas about the pointer at the rate Cmd/Ctrl+wheel zooms otherwise, and Cmd/Ctrl+wheel scrolls; Shift+wheel still scrolls sideways, and trackpad scrolls, pinches and touch screens are unchanged. A wheel is told from a trackpad by what the platform reports: macOS names the device; elsewhere a wheel scrolls by lines and a trackpad by pixels, so a mouse whose wheel reports pixels (Chromium-based browsers) keeps scrolling, and a trackpad that reports lines (some X11 setups) zooms. Numbers unchanged. Tests: `wheel_zoom_tests`. |
|
||||
| 2026-10-30 | minor | M9, part of #1415: `comment.list` takes `mentions` (a name, any case, a leading `@` allowed) and lists only the threads where a message, the first or a reply, @mentions it, combined with the other filters (`{"mentions": "Claude", "filter": "unread"}`). It matches the names each message's `mentions` reports, by the same parser the thread view highlights with (`figcraft_doc::mentions`), so `@Ada Lovelace` is Ada Lovelace, not Ada, and an email address mentions nobody; the name counts as one of the file's people, so an unknown name can be several words. Numbers unchanged. Tests: `comment_mentions_tests`. |
|
||||
| 2026-10-30 | minor | M8, #1123: a safety net for internal errors in the browser. wasm32 can't catch a panic (stable Rust has no unwinding std for it), so there the command guard and the frame guard do nothing and a panic stopped the page, losing every unsaved change. The page now follows its files with unsaved changes after every frame (shared copies, taken again only when a file changed); its panic hook writes them to the page's local storage as `.figcraft` copies, as many as fit (browsers give a page about 5 MB), and says on the page that it stopped and what reloading brings back; the next page opens them again as unsaved files (`file.recover` also takes the copy as base64 `data`). Numbers unchanged. Tests: `recovery_tests` (figcraft-web). |
|
||||
| 2026-10-30 | minor | M8, #1104: boolean operations had no input cap, so a crafted file could keep the sweep busy for unbounded time and memory (any number of segments, crossing as often as they like, and a fold re-sweeping its growing result for every operand). Each sweep now takes at most 250 000 segments and 2 million possible crossings (pairs of segments whose bounds overlap, counted exactly in O(n log n)), and a fold at most 4 million segments swept over all its steps; past them the operation is refused with `TooComplex` ("the shapes are too complex to combine") before it runs. The example files' boolean groups sweep at most 55 segments and 155 possible crossings per step. A boolean group past the caps draws without its result, with a warning, once, like layers past the render's memory budget; the refusal is remembered, so redrawing it costs nothing; Flatten refuses it instead of dropping it. Numbers unchanged. Tests: `budget_tests` (pathops), `boolean_budget_tests` (render), `boolean_cap_tests` (engine). |
|
||||
| 2026-10-30 | minor | M0.9, #1396: `figcraft-cli` parses its arguments with clap, following RFC-0012 (craft-contrib), so help comes from the same definitions the parser uses and bad arguments fail before any work starts. Changed syntax: an option given twice is a usage error (it used to take the last value; the automation roots still repeat), a value starting with `-` needs `=` (`--filter=--json`), `--o` for `-o` and the `version` subcommand are gone (`--version` stays), `--format` is checked before the file is opened, help and usage errors are clap's text (`Usage: figcraft-cli …`, `error: …` with a hint instead of the whole usage). Every subcommand, flag, value form and exit code is otherwise unchanged. Tests: `args::tests` (each documented form, every usage error, every subcommand's help) and the CLI integration tests. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M7, #1506: the hover and selection outline of a rectangle, frame, component, instance or section with rounded corners follows its geometry: each corner's own radius and the corner smoothing, as the fill draws them, whatever the stroke alignment (inside, centre or outside); square corners keep the box. It used to be the square box, so it matched only the square corners of a layer with mixed radii. Tests: `canvas::outline_tests`. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
|
||||
@@ -29,7 +29,7 @@ Usage:
|
||||
figcraft-server files [list] [--sort size|age|idle] the files kept (biggest, oldest or longest unopened first)
|
||||
figcraft-server files remove FILE remove a file and its links for good
|
||||
figcraft-server links list FILE a file's share links
|
||||
figcraft-server links create FILE --access edit|view [--label TEXT]
|
||||
figcraft-server links create FILE --access edit|comment|view [--label TEXT]
|
||||
figcraft-server links revoke FILE LINK revoke a link (whoever joined with it is disconnected)
|
||||
figcraft-server plugins [list] the plugin directory: every plugin and its versions
|
||||
figcraft-server plugins remove ID [--version V] unpublish a plugin (every version, or one)
|
||||
@@ -124,7 +124,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
"--listen" => config.listen = value("--listen")?,
|
||||
"--data" => config.data = PathBuf::from(value("--data")?),
|
||||
"--public-url" => config.public_url = Some(value("--public-url")?),
|
||||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit or view")?),
|
||||
"--access" => access = Some(Access::parse(&value("--access")?).ok_or("--access must be edit, comment or view")?),
|
||||
"--label" => label = value("--label")?,
|
||||
"--rotate" => rotate = true,
|
||||
"--sort" => {
|
||||
@@ -147,7 +147,7 @@ pub fn parse(args: &[String]) -> Result<Cli, String> {
|
||||
["files", "remove", file] => Command::RemoveFile { file: file.to_string() },
|
||||
["links", "list", file] => Command::Links { file: file.to_string() },
|
||||
["links", "create", file] => {
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit or --access view")?, label }
|
||||
Command::CreateLink { file: file.to_string(), access: access.ok_or("links create needs --access edit, comment or view")?, label }
|
||||
}
|
||||
["links", "revoke", file, link] => Command::Revoke { file: file.to_string(), link: link.to_string() },
|
||||
["plugins"] | ["plugins", "list"] => Command::Plugins,
|
||||
@@ -361,8 +361,7 @@ fn new_link(cfg: &Config, l: &NewLink) -> Result<String, String> {
|
||||
};
|
||||
let invite = Invite { server: public_endpoint(cfg, listen)?, file: l.link.file.clone(), token: l.token.clone() };
|
||||
let mut out = String::new();
|
||||
let what = if l.link.access == Access::View { "view" } else { "edit" };
|
||||
let _ = writeln!(out, "New {what} link {} for file {}:", l.link.id, l.link.file);
|
||||
let _ = writeln!(out, "New {} link {} for file {}:", l.link.access.id(), l.link.id, l.link.file);
|
||||
let _ = writeln!(out, " {}", invite.link());
|
||||
let _ = writeln!(out, " {}", invite.url());
|
||||
let _ = writeln!(out, "This is the only time the link is shown (the server keeps only a hash of its token).");
|
||||
|
||||
@@ -57,7 +57,7 @@ fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
assert_eq!(links.matches("\"code\"").count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -76,7 +76,8 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
assert_eq!(shared["code"], edit.as_str(), "the code shown lets people edit unless asked otherwise");
|
||||
let p = a.live().published.clone().unwrap();
|
||||
assert!(p.owner.is_some(), "the owner token is kept as before");
|
||||
// Others get links of their own (the owner keeps the file's first ones): four in all.
|
||||
// Others get links of their own (edit, view and comment; the owner keeps the file's first
|
||||
// ones): five in all.
|
||||
let edit_link = shared["share"]["editLink"].as_str().unwrap().to_string();
|
||||
assert!(edit_link != p.edit && shared["share"]["viewLink"] != p.view.as_str() && shared["share"]["shared"] == true);
|
||||
let ids = |dir: &std::path::Path| -> Vec<String> {
|
||||
@@ -84,7 +85,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
links.as_array().unwrap().iter().map(|l| l["id"].as_str().unwrap().to_string()).collect()
|
||||
};
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
assert_eq!(before_stop.len(), 5);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
|
||||
// Someone types the edit code, the way people type.
|
||||
@@ -142,7 +143,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let (edit2, link2) = (again["code"].as_str().unwrap().to_string(), again["share"]["editLink"].as_str().unwrap().to_string());
|
||||
assert!(is_code(&edit2) && edit2 != edit && link2 != edit_link && again["published"]["file"] == p.file.as_str(), "{again}");
|
||||
assert_eq!(dir.path().join("files").read_dir().unwrap().count(), 1, "still one file on the server");
|
||||
assert_eq!(ids(dir.path()).len(), 4);
|
||||
assert_eq!(ids(dir.path()).len(), 5);
|
||||
ex(&mut b, "collab.join", json!({"code": edit2}));
|
||||
pump(&mut [&mut a, &mut b], "Bo back in", |s| live(s[1]) && layers(s[1]) == before + 3 && s[0].live().peers.len() == 1);
|
||||
// The old code is still gone.
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Comment links end to end over loopback: sharing online makes a comment link and code beside the
|
||||
//! edit and view ones; someone who joins with it comments and everyone sees it, their app refuses
|
||||
//! their other edits, and the server refuses them too for a client that skips the check; a client
|
||||
//! that can't read comment links gets in as a viewer, and links listed to it read as view links.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, Manage, PROTOCOL, ServerMsg};
|
||||
use figcraft_collab::{COMMENT_ONLY, Change, Client, Entry, Json};
|
||||
use figcraft_collab_net::{Endpoint, Invite, NetLimits, manage};
|
||||
use figcraft_doc::{Comment, Message};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::test_util::{Raw, TempDir, config, join, key, live, publish, pump, session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// The welcome `raw` gets after saying hello with `client_version` through `link`.
|
||||
fn welcome(raw: &mut Raw, link: &str, client_version: &str) -> Client {
|
||||
let invite = Invite::parse(link).unwrap();
|
||||
let ClientMsg::Hello(mut h) = Client::hello(&invite.file, &invite.token, "Raw", "") else { panic!("not a hello") };
|
||||
h.client_version = client_version.into();
|
||||
raw.send(&ClientMsg::Hello(h));
|
||||
loop {
|
||||
if let w @ ServerMsg::Welcome { .. } = raw.next() {
|
||||
return Client::join(w).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the server says to the batch `raw` sends next.
|
||||
fn answer(raw: &mut Raw) -> ServerMsg {
|
||||
loop {
|
||||
if let m @ (ServerMsg::Reject { .. } | ServerMsg::Changes(_)) = raw.next() {
|
||||
return m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_link_comments_and_the_server_refuses_its_edits() {
|
||||
let dir = TempDir::new("comment-link");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let addr = server.addr();
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "shape.rectangle", json!({"x": 0, "y": 0, "width": 80, "height": 80}));
|
||||
let p = publish(&mut a, dir.path(), addr);
|
||||
let file = Invite::parse(&p.edit).unwrap().file;
|
||||
let endpoint = Endpoint::plain(addr.to_string());
|
||||
let ask = |req: Admin| manage::request(&endpoint, &key(dir.path()), vec![req], &NetLimits::default(), Duration::from_secs(5)).unwrap().remove(0);
|
||||
let AdminReply::Link(made) = ask(Admin::CreateLink { file: file.clone(), access: Access::Comment, label: "Client".into() }) else { panic!() };
|
||||
assert_eq!(made.link.access, Access::Comment);
|
||||
let link = Invite { server: endpoint.clone(), file: file.clone(), token: made.token.clone() }.link();
|
||||
// Cy joins with it and comments; Ada sees the thread.
|
||||
let mut c = join(&link, "Cy");
|
||||
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!((&c.live_summary()["access"], &c.live_summary()["readOnly"]), (&json!("comment"), &json!(true)));
|
||||
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Bigger logo?"}))["id"].as_u64().unwrap();
|
||||
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
|
||||
ex(&mut a, "comment.reply", json!({"id": thread, "text": "On it"}));
|
||||
pump(&mut [&mut a, &mut c], "the reply at Cy", |s| s[1].doc().unwrap().comment(thread).is_some_and(|t| t.replies.len() == 1));
|
||||
ex(&mut c, "comment.resolve", json!({"id": thread}));
|
||||
pump(&mut [&mut a, &mut c], "resolved at Ada", |s| s[0].doc().unwrap().comment(thread).is_some_and(|t| t.resolved));
|
||||
// Cy's app refuses anything else before it is sent.
|
||||
let e = c.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err().to_string();
|
||||
assert!(e.contains("Can comment"), "{e}");
|
||||
// A client that skips the app's check: its comment goes through, its edit is refused.
|
||||
let mut raw = Raw::connect(addr);
|
||||
let mut client = welcome(&mut raw, &link, &figcraft_collab::message::client_version());
|
||||
assert_eq!(client.access(), Access::Comment);
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
let id = d.new_comment_id();
|
||||
d.comments.push(Comment {
|
||||
id,
|
||||
page,
|
||||
x: 1.0,
|
||||
y: 1.0,
|
||||
anchor: None,
|
||||
first: Message::new("Raw", "Hi", 0),
|
||||
resolved: false,
|
||||
replies: vec![],
|
||||
read_by: None,
|
||||
});
|
||||
raw.send(&client.edit(&d).unwrap().unwrap());
|
||||
let ServerMsg::Changes(acc) = answer(&mut raw) else { panic!("refused") };
|
||||
client.receive(ServerMsg::Changes(acc), &figcraft_collab::NoRecompute).unwrap();
|
||||
let mut d = client.document().clone();
|
||||
d.node_mut(page).unwrap().name = "Sneaky".into();
|
||||
raw.send(&client.edit(&d).unwrap().unwrap());
|
||||
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
|
||||
// So is a forged batch that hides an edit among comment changes.
|
||||
let forged = Change::Put { entry: Entry::Comment(id), value: Json::of(&client.document().comments[0]).unwrap() };
|
||||
let rename = Change::SetDoc { prop: figcraft_collab::DocProp::Name, value: Json::of("Sneaky").unwrap() };
|
||||
raw.send(&ClientMsg::Changes { base_seq: 0, batch_id: 99, changes: vec![forged, rename] });
|
||||
assert!(matches!(answer(&mut raw), ServerMsg::Reject { reason, .. } if reason == COMMENT_ONLY));
|
||||
pump(&mut [&mut a], "the raw comment, nothing else", |s| {
|
||||
let d = s[0].doc().unwrap();
|
||||
d.comment(id).is_some() && d.node(page).is_some_and(|n| n.name != "Sneaky") && d.name != "Sneaky"
|
||||
});
|
||||
// A client from before comment links gets in as a viewer.
|
||||
let mut old = Raw::connect(addr);
|
||||
assert_eq!(welcome(&mut old, &link, "0.12.1 signals hosting").access(), Access::View);
|
||||
// And lists the link as a view link.
|
||||
let mut m = Raw::connect(addr);
|
||||
m.send(&ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key(dir.path()), client_version: "0.12.1".into() }));
|
||||
assert!(matches!(m.next(), ServerMsg::Admin(r) if *r == AdminReply::Ready));
|
||||
m.send(&ClientMsg::Admin(Admin::Links { file: file.clone() }));
|
||||
let ServerMsg::Admin(r) = m.next() else { panic!("no listing") };
|
||||
let AdminReply::Links(links) = *r else { panic!("{r:?}") };
|
||||
assert!(links.iter().all(|l| l.access != Access::Comment) && links.iter().any(|l| l.id == made.link.id && l.access == Access::View));
|
||||
let AdminReply::Links(links) = ask(Admin::Links { file }) else { panic!() };
|
||||
assert!(links.iter().any(|l| l.id == made.link.id && l.access == Access::Comment), "this build reads it");
|
||||
server.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_online_offers_a_comment_code() {
|
||||
let dir = TempDir::new("comment-code");
|
||||
let mut server = crate::start(&config(dir.path())).unwrap();
|
||||
let url = format!("ws://{}/?key={}", server.addr(), key(dir.path()));
|
||||
let mut a = session("Ada");
|
||||
ex(&mut a, "collab.share", json!({"server": url}));
|
||||
pump(&mut [&mut a], "the share", |s| live(s[0]) && s[0].live().published.is_some() && s[0].live_summary()["share"]["shared"] == true);
|
||||
let shared = a.live_summary()["share"].clone();
|
||||
let comment = shared["commentCode"].as_str().unwrap().to_string();
|
||||
assert!(comment != shared["editCode"] && comment != shared["viewCode"], "{shared}");
|
||||
assert_eq!(ex(&mut a, "collab.share", json!({"access": "comment"}))["code"], comment.as_str());
|
||||
// Someone types it: they can comment, not edit.
|
||||
let mut c = session("Cy");
|
||||
ex(&mut c, "pref.publishServer", json!({"url": format!("ws://{}/", server.addr())}));
|
||||
ex(&mut c, "collab.join", json!({"code": comment}));
|
||||
pump(&mut [&mut a, &mut c], "Cy in", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
assert_eq!(c.live_summary()["access"], "comment");
|
||||
let thread = ex(&mut c, "comment.add", json!({"x": 10, "y": 10, "text": "Looks good"}))["id"].as_u64().unwrap();
|
||||
pump(&mut [&mut a, &mut c], "the thread at Ada", |s| s[0].doc().unwrap().comment(thread).is_some());
|
||||
assert!(c.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_err());
|
||||
// Stopping revokes it with the others: Cy is out.
|
||||
ex(&mut a, "collab.stop", json!({}));
|
||||
pump(&mut [&mut a, &mut c], "Cy out", |s| !s[1].live().status.active() && s[0].live().peers.is_empty());
|
||||
server.stop();
|
||||
}
|
||||
@@ -78,6 +78,7 @@ fn write_atomic_as(path: &Path, bytes: &[u8], private: bool) -> io::Result<()> {
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
let mut f = o.open(&tmp)?;
|
||||
counted(true);
|
||||
f.write_all(bytes)?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
@@ -101,6 +102,7 @@ pub fn sync_dir(dir: Option<&Path>) {
|
||||
/// Read a file of at most `max` bytes (a longer one is refused without being read).
|
||||
pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
let f = File::open(path).map_err(|e| format!("can't open {}: {e}", name(path)))?;
|
||||
counted(false);
|
||||
let len = f.metadata().map_err(|e| e.to_string())?.len();
|
||||
if len > max {
|
||||
return Err(format!("{} is {} bytes, over the {max}-byte cap", name(path), len));
|
||||
@@ -110,6 +112,29 @@ pub fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Files read and written on this thread (tests: what an action costs, #1074).
|
||||
static IO: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) };
|
||||
}
|
||||
|
||||
/// Count a file read or write (a log append counts as a write).
|
||||
pub(crate) fn counted(write: bool) {
|
||||
#[cfg(test)]
|
||||
IO.with(|io| {
|
||||
let (r, w) = io.get();
|
||||
io.set(if write { (r, w + 1) } else { (r + 1, w) });
|
||||
});
|
||||
#[cfg(not(test))]
|
||||
let _ = write;
|
||||
}
|
||||
|
||||
/// The files read and written on this thread since the last call: `(reads, writes)`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn take_io() -> (u32, u32) {
|
||||
IO.with(|io| io.replace((0, 0)))
|
||||
}
|
||||
|
||||
fn name(path: &Path) -> String {
|
||||
path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Joining and leaving a file nobody is editing costs no disk I/O while it is kept loaded
|
||||
//! (#1074), and what changes is still saved as before.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg, Hello};
|
||||
use figcraft_collab::{Client, NoRecompute, Outgoing, Replica, Server};
|
||||
use figcraft_collab_net::hub::Backend;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use crate::disk::{self, META};
|
||||
use crate::store::{FILES, Store, StoreConfig};
|
||||
use crate::test_util::{TempDir, config};
|
||||
|
||||
fn hello() -> Hello {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!() };
|
||||
h
|
||||
}
|
||||
|
||||
/// Let `hello` into `server` and hand its messages to `client` (a new one when `None`); the
|
||||
/// client and its id there.
|
||||
fn enter(server: &mut Server, client: Option<Client>, hello: Hello) -> (Client, u32) {
|
||||
let (id, out) = server.join_as(hello, Access::Edit).unwrap();
|
||||
let mut msgs = out.into_iter().filter_map(|o| match o {
|
||||
Outgoing::To(to, m) if to == id => Some(m),
|
||||
_ => None,
|
||||
});
|
||||
let client = match client {
|
||||
Some(mut c) => {
|
||||
for m in msgs {
|
||||
c.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
None => Client::join(msgs.next().unwrap()).unwrap(),
|
||||
};
|
||||
(client, id)
|
||||
}
|
||||
|
||||
/// A file opened, joined and left once: the store, the file's id and the client, ready to resume.
|
||||
fn published(dir: &TempDir) -> (Store, String, Client, StoreConfig) {
|
||||
let c = StoreConfig { compact_after: 1000, ..config(dir.path()).store() };
|
||||
let mut store = Store::open(dir.path(), c.clone()).unwrap();
|
||||
let d = Document::new("Idle");
|
||||
let (info, _, _) = store.publish("Idle", &Replica::new(d).snapshot().unwrap()).unwrap();
|
||||
let id = info.id;
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let (client, cid) = enter(&mut server, None, hello());
|
||||
server.leave(cid);
|
||||
store.changed(&id, &mut server);
|
||||
store.flush();
|
||||
assert!(store.idle(&id, &mut server));
|
||||
(store, id, client, c)
|
||||
}
|
||||
|
||||
/// One rejoin and leave, as the hub drives it; `edit` renames the page on the way.
|
||||
fn cycle(store: &mut Store, id: &str, client: Client, edit: Option<&str>) -> Client {
|
||||
let mut server = store.open(id).unwrap();
|
||||
let (mut client, cid) = {
|
||||
let mut c = client;
|
||||
let ClientMsg::Hello(h) = c.reconnect(hello()) else { panic!() };
|
||||
enter(&mut server, Some(c), h)
|
||||
};
|
||||
if let Some(name) = edit {
|
||||
let mut d = client.document().clone();
|
||||
let page = d.first_page().unwrap();
|
||||
d.node_mut(page).unwrap().name = name.into();
|
||||
let m = client.edit(&d).unwrap().unwrap();
|
||||
for o in server.receive(cid, m) {
|
||||
if let Outgoing::All(m) | Outgoing::To(_, m) = o {
|
||||
client.receive(m, &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
server.leave(cid);
|
||||
store.changed(id, &mut server);
|
||||
store.flush();
|
||||
assert!(store.idle(id, &mut server));
|
||||
client
|
||||
}
|
||||
|
||||
fn page_name(server: &Server) -> String {
|
||||
let d = server.replica().document();
|
||||
d.first_page().and_then(|p| d.node(p)).map(|n| n.name.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejoining_an_unchanged_file_reads_and_writes_nothing() {
|
||||
let dir = TempDir::new("idle-io");
|
||||
let (mut store, id, mut client, _) = published(&dir);
|
||||
let log = dir.path().join(FILES).join(&id).join(crate::disk::LOG);
|
||||
let log_len = fs::metadata(&log).unwrap().len();
|
||||
disk::take_io();
|
||||
for _ in 0..20 {
|
||||
client = cycle(&mut store, &id, client, None);
|
||||
}
|
||||
assert_eq!(disk::take_io(), (0, 0), "20 join/leave cycles: no file read or written");
|
||||
assert_eq!(fs::metadata(&log).unwrap().len(), log_len);
|
||||
assert_eq!(store.compactions(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_while_kept_loaded_are_still_saved() {
|
||||
let dir = TempDir::new("idle-edits");
|
||||
let (mut store, id, mut client, c) = published(&dir);
|
||||
for i in 0..3 {
|
||||
client = cycle(&mut store, &id, client, Some(&format!("Page {i}")));
|
||||
}
|
||||
// A cycle with an edit compacts it on leaving, as before; the next one does nothing again.
|
||||
assert!(store.compactions() > 0);
|
||||
disk::take_io();
|
||||
let _ = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 0));
|
||||
// Saved: another store (a restart) reads it back.
|
||||
drop(store);
|
||||
let mut again = Store::open(dir.path(), c).unwrap();
|
||||
disk::take_io();
|
||||
let server = again.open(&id).unwrap();
|
||||
assert_eq!(page_name(&server), "Page 2");
|
||||
assert_eq!(disk::take_io().0, 2, "one load: the snapshot and the log");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_kept_loaded_longest_lets_go_first() {
|
||||
let dir = TempDir::new("idle-many");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let ids: Vec<String> =
|
||||
(0..17).map(|i| store.publish(&format!("F{i}"), &Replica::new(Document::new("F")).snapshot().unwrap()).unwrap().0.id).collect();
|
||||
for id in &ids {
|
||||
let mut server = store.open(id).unwrap();
|
||||
assert!(store.idle(id, &mut server));
|
||||
}
|
||||
disk::take_io();
|
||||
let _ = store.open(&ids[16]).unwrap();
|
||||
assert_eq!(disk::take_io(), (0, 0), "the latest is still loaded");
|
||||
let _ = store.open(&ids[0]).unwrap();
|
||||
assert_eq!(disk::take_io(), (2, 0), "past 16, the first let go: read again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_open_time_is_written_hourly_and_when_the_server_stops() {
|
||||
let dir = TempDir::new("idle-opened");
|
||||
let (mut store, id, client, _) = published(&dir);
|
||||
let opened = || -> u64 {
|
||||
let meta: serde_json::Value = serde_json::from_slice(&fs::read(dir.path().join(FILES).join(&id).join(META)).unwrap()).unwrap();
|
||||
meta["opened"].as_u64().unwrap()
|
||||
};
|
||||
// Last written half an hour ago: a join doesn't write it yet; stopping does.
|
||||
store.age(&id, 1800);
|
||||
let before = opened();
|
||||
disk::take_io();
|
||||
let client = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 0));
|
||||
assert_eq!(opened(), before);
|
||||
store.stop(Vec::new());
|
||||
assert!(opened() >= before + 1800);
|
||||
// Written over an hour ago: the next join writes it.
|
||||
store.age(&id, 4000);
|
||||
disk::take_io();
|
||||
let _ = cycle(&mut store, &id, client, None);
|
||||
assert_eq!(disk::take_io(), (0, 1));
|
||||
}
|
||||
@@ -95,10 +95,14 @@ mod cli_tests;
|
||||
#[cfg(test)]
|
||||
mod codes_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_link_tests;
|
||||
#[cfg(test)]
|
||||
mod directory_tests;
|
||||
#[cfg(test)]
|
||||
mod hardening_tests;
|
||||
#[cfg(test)]
|
||||
mod idle_io_tests;
|
||||
#[cfg(test)]
|
||||
mod library_tests;
|
||||
#[cfg(test)]
|
||||
mod owned_tests;
|
||||
|
||||
@@ -158,7 +158,7 @@ fn only_a_files_own_token_learns_about_it() {
|
||||
assert_eq!(list.len(), 5, "one answer per pair, in order");
|
||||
let first = list[0].as_ref().expect("its own token");
|
||||
assert_eq!(first.name, "Untitled");
|
||||
assert_eq!(first.links.len(), 4, "the owner's two links and the two made for others");
|
||||
assert_eq!(first.links.len(), 5, "the owner's two links and the three made for others");
|
||||
assert!(first.expires.is_some());
|
||||
assert!(list[1..].iter().all(Option::is_none), "a wrong token and a missing file read the same");
|
||||
// Nothing asked: nothing answered; too many: refused, and the connection stays usable.
|
||||
|
||||
@@ -241,8 +241,30 @@ struct Entry {
|
||||
bytes: u64,
|
||||
/// When it was last compacted to make room (seconds since 1970).
|
||||
forced: u64,
|
||||
/// Closed lately: its session kept loaded ([`PARK_FOR`]).
|
||||
parked: Option<Parked>,
|
||||
/// The `opened` time `meta.json` holds ([`TOUCH_EVERY`]).
|
||||
opened_written: u64,
|
||||
}
|
||||
|
||||
/// A file everyone left lately, kept loaded so a join soon after neither reads it nor rewrites it
|
||||
/// (#1074): its session and its log (synced, its state compacted as on any close).
|
||||
struct Parked {
|
||||
server: Server,
|
||||
log: Log,
|
||||
at: Instant,
|
||||
}
|
||||
|
||||
/// How long a file everyone left stays loaded.
|
||||
const PARK_FOR: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
/// Most files kept loaded after everyone left (the one parked longest goes first).
|
||||
const MAX_PARKED: usize = 16;
|
||||
/// Most bytes (on disk) of the files kept loaded after everyone left.
|
||||
const MAX_PARKED_BYTES: u64 = 256 << 20;
|
||||
/// How often, at most, a file's `opened` time is written while it keeps being opened (seconds):
|
||||
/// expiry counts in days, so it needn't be written at every join and leave.
|
||||
const TOUCH_EVERY: u64 = 3600;
|
||||
|
||||
/// A library's usage reports in memory, and whether (and when) they were written.
|
||||
struct UsageCache {
|
||||
list: Vec<FileUsage>,
|
||||
@@ -494,7 +516,8 @@ impl Store {
|
||||
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
|
||||
}
|
||||
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
|
||||
let opened_written = meta.opened;
|
||||
Ok(Entry { meta, links, log: None, bytes, forced: 0, parked: None, opened_written })
|
||||
}
|
||||
|
||||
/// The server key (for managing files).
|
||||
|
||||
@@ -62,12 +62,14 @@ impl Backend for Store {
|
||||
}
|
||||
|
||||
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
|
||||
let mut synced = false;
|
||||
let compact = self.files.get_mut(file).is_some_and(|entry| {
|
||||
let snapshot_seq = entry.meta.seq;
|
||||
entry.log.as_mut().is_some_and(|log| {
|
||||
// Issued client ids and refusals are durable in the log; only edits need a
|
||||
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
|
||||
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
|
||||
synced = flush_log(file, log).is_ok();
|
||||
!synced || (log.dirty && server.seq() > snapshot_seq)
|
||||
})
|
||||
});
|
||||
if compact {
|
||||
@@ -75,8 +77,12 @@ impl Backend for Store {
|
||||
}
|
||||
// It closes anyway, with its state in the snapshot and durable log.
|
||||
self.cut_short.retain(|f| f != file);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.log = None;
|
||||
let log = self.files.get_mut(file).and_then(|e| e.log.take());
|
||||
// Kept loaded a while, when its log was sound: a join soon after reads and rewrites
|
||||
// nothing (#1074). The hub drops its room as this returns: the session moves here.
|
||||
if let Some(log) = log.filter(|l| synced && l.pending.is_empty()) {
|
||||
let left = std::mem::replace(server, Server::new(figcraft_collab::Document::new(""), "", self.cfg.core));
|
||||
self.park(file, left, log);
|
||||
}
|
||||
// Everyone just left: idle time counts from now.
|
||||
self.touch(file);
|
||||
@@ -86,6 +92,11 @@ impl Backend for Store {
|
||||
fn stop(&mut self, open: Vec<(&str, &mut Server)>) {
|
||||
self.write_usage(true);
|
||||
self.flush_all();
|
||||
// The open times not written yet (they are written at most hourly while files are used).
|
||||
let unwritten: Vec<String> = self.files.iter().filter(|(_, e)| e.meta.opened > e.opened_written).map(|(id, _)| id.clone()).collect();
|
||||
for file in unwritten {
|
||||
self.write_opened(&file);
|
||||
}
|
||||
for (file, server) in open {
|
||||
if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) {
|
||||
self.compact(file, server);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{
|
||||
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
|
||||
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_PARKED, MAX_PARKED_BYTES, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, PARK_FOR, Parked, REPORTS, Replica,
|
||||
ReportInfo, SNAPSHOT, SWEEP_EVERY, Server, Store, TOUCH_EVERY, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now,
|
||||
same_secret, token, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
@@ -133,7 +134,9 @@ impl Store {
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
return Err(format!("couldn't keep the file: {e}"));
|
||||
}
|
||||
let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 };
|
||||
let opened_written = meta.opened;
|
||||
let (links, bytes_kept) = (vec![edit.clone(), view.clone()], (bytes.len() + LOG_HEADER) as u64);
|
||||
let entry = Entry { meta, links, log: None, bytes: bytes_kept, forced: 0, parked: None, opened_written };
|
||||
self.files.insert(id.clone(), entry);
|
||||
let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?;
|
||||
match ip {
|
||||
@@ -199,6 +202,12 @@ impl Store {
|
||||
pub fn sweep(&mut self) -> Vec<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
// Files kept loaded after everyone left let go once their time is up.
|
||||
for e in self.files.values_mut() {
|
||||
if e.parked.as_ref().is_some_and(|p| p.at.elapsed() > PARK_FOR) {
|
||||
e.parked = None;
|
||||
}
|
||||
}
|
||||
let due: Vec<String> =
|
||||
self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect();
|
||||
for id in &due {
|
||||
@@ -217,6 +226,7 @@ impl Store {
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
let m = &mut e.meta;
|
||||
(m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs));
|
||||
e.opened_written = m.opened;
|
||||
let _ = write_json(&path, m);
|
||||
}
|
||||
}
|
||||
@@ -232,17 +242,65 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that a file was opened now (it is used: it doesn't expire).
|
||||
/// Note that a file was opened now (it is used: it doesn't expire). Expiry counts in days, so
|
||||
/// `meta.json` gets the time at most every [`TOUCH_EVERY`] seconds (and when the server stops),
|
||||
/// not at every join and leave (#1074).
|
||||
pub(super) fn touch(&mut self, file: &str) {
|
||||
let t = now();
|
||||
let due = self.files.get_mut(file).is_some_and(|e| {
|
||||
e.meta.opened = t;
|
||||
t >= e.opened_written.saturating_add(TOUCH_EVERY)
|
||||
});
|
||||
if due {
|
||||
self.write_opened(file);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `file`'s `opened` time to its `meta.json`.
|
||||
pub(super) fn write_opened(&mut self, file: &str) {
|
||||
let path = self.dir(file).join(META);
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.meta.opened = now();
|
||||
if let Err(err) = write_json(&path, &e.meta) {
|
||||
log::warn!("file {file}: couldn't update {META}: {err}");
|
||||
match write_json(&path, &e.meta) {
|
||||
Ok(()) => e.opened_written = e.meta.opened,
|
||||
Err(err) => log::warn!("file {file}: couldn't update {META}: {err}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `file`'s session loaded for a while after everyone left ([`PARK_FOR`]), at most
|
||||
/// [`MAX_PARKED`] files of at most [`MAX_PARKED_BYTES`] together (the ones parked longest let
|
||||
/// go first; a file larger than that isn't kept).
|
||||
pub(super) fn park(&mut self, file: &str, server: Server, log: super::Log) {
|
||||
let size = self.bytes(file);
|
||||
if size > MAX_PARKED_BYTES {
|
||||
return;
|
||||
}
|
||||
let mut parked: Vec<(String, Instant, u64)> =
|
||||
self.files.iter().filter_map(|(id, e)| e.parked.as_ref().map(|p| (id.clone(), p.at, e.bytes))).collect();
|
||||
parked.sort_by_key(|(_, at, _)| *at);
|
||||
let mut held: u64 = parked.iter().map(|(_, _, b)| b).sum();
|
||||
let mut n = parked.len();
|
||||
for (id, _, b) in &parked {
|
||||
if n < MAX_PARKED && held.saturating_add(size) <= MAX_PARKED_BYTES {
|
||||
break;
|
||||
}
|
||||
if let Some(e) = self.files.get_mut(id) {
|
||||
e.parked = None;
|
||||
}
|
||||
n -= 1;
|
||||
held = held.saturating_sub(*b);
|
||||
}
|
||||
if let Some(e) = self.files.get_mut(file) {
|
||||
e.parked = Some(Parked { server, log, at: Instant::now() });
|
||||
}
|
||||
}
|
||||
|
||||
/// `file`'s session as it was left, while it is still kept loaded ([`PARK_FOR`]).
|
||||
pub(super) fn unpark(&mut self, file: &str) -> Option<(Server, super::Log)> {
|
||||
let p = self.files.get_mut(file)?.parked.take()?;
|
||||
(p.at.elapsed() <= PARK_FOR).then_some((p.server, p.log))
|
||||
}
|
||||
|
||||
/// What the server says about its hosting (with `file`'s expiry).
|
||||
pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo {
|
||||
let h = &self.cfg.hosting;
|
||||
|
||||
@@ -44,8 +44,13 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported).
|
||||
/// One everyone left lately is still loaded: it comes back as it was, nothing read (#1074).
|
||||
pub(super) fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
if let Some((server, log)) = self.unpark(file) {
|
||||
self.entry(file)?.log = Some(log);
|
||||
return Ok(server);
|
||||
}
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
@@ -258,6 +263,7 @@ pub(super) fn flush_log(id: &str, log: &mut Log) -> Result<u64, ()> {
|
||||
if log.pending.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
disk::counted(true);
|
||||
match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) {
|
||||
Ok(()) => {
|
||||
let n = log.pending.len() as u64;
|
||||
|
||||
@@ -15,9 +15,11 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
figcraft-engine = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -26,7 +28,6 @@ serde_json = { workspace = true }
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
base64 = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
|
||||
@@ -10,9 +10,13 @@
|
||||
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
|
||||
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
|
||||
/* Shown once the page stopped on an internal error (web.rs: show_stopped). */
|
||||
#figcraft_stopped { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; box-sizing: border-box;
|
||||
padding: 16px; text-align: center; color: #1e1e1e; background: rgba(245, 245, 245, 0.92); font: 14px/1.5 system-ui, sans-serif; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body { background: #2c2c2c; }
|
||||
#figcraft_loading { color: #b8b8b8; }
|
||||
#figcraft_stopped { color: #f0f0f0; background: rgba(44, 44, 44, 0.92); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
//! - no native file dialogs yet: the session starts with one new document;
|
||||
//! - links open in a new browser tab;
|
||||
//! - of the preferences, the page keeps only your name across reloads (`kept.rs`);
|
||||
//! - an internal error (a panic) can't be caught, so it stops the page: the files with unsaved
|
||||
//! changes are kept in the page's storage and open again on the next reload (`recovery.rs`);
|
||||
//! - fonts: the built-in ones, and on request (the font menu's "Use fonts installed on this
|
||||
//! computer…", Chromium's Local Font Access) the computer's, each read when text uses it
|
||||
//! (`local_fonts.rs`);
|
||||
@@ -37,6 +39,10 @@ mod query;
|
||||
#[cfg(test)]
|
||||
mod query_tests;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod recovery;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod saved_files;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Recovery copies in the browser. The browser build can't catch a panic (wasm32 always aborts),
|
||||
//! so a command or frame that panics ends the page instead of failing as on the desktop. The page
|
||||
//! follows its files with unsaved changes after every frame ([`Unsaved::follow`]: cheap shared
|
||||
//! copies, taken only when a file changed); the panic hook writes them to the page's local storage
|
||||
//! ([`store`]) before the page stops, and the next page opens them again as unsaved files
|
||||
//! ([`restore`], `file.recover`), as the desktop app does with its recovery copies.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
|
||||
use figcraft_engine::doc::Document;
|
||||
use figcraft_engine::{DocState, Session};
|
||||
use figcraft_format::{Branch, Version};
|
||||
use serde_json::json;
|
||||
|
||||
/// A file with unsaved changes as of the last frame.
|
||||
struct Copy {
|
||||
uid: u64,
|
||||
/// [`stamp`]: the revisions this copy is of.
|
||||
stamp: Vec<u64>,
|
||||
doc: Arc<Document>,
|
||||
versions: Vec<Version>,
|
||||
branches: Vec<Branch>,
|
||||
}
|
||||
|
||||
impl Copy {
|
||||
fn of(s: &Session, d: &DocState) -> Self {
|
||||
let (doc, versions, branches) = recovery_copy(s, d);
|
||||
Self { uid: d.uid, stamp: stamp(s, d), doc, versions, branches }
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's files with unsaved changes, as of the last frame.
|
||||
#[derive(Default)]
|
||||
pub struct Unsaved(Vec<Copy>);
|
||||
|
||||
impl Unsaved {
|
||||
/// Catch up with `s`: a file that changed is taken again, the others are kept as they are.
|
||||
pub fn follow(&mut self, s: &Session) {
|
||||
let mut before = std::mem::take(&mut self.0);
|
||||
for d in s.docs.iter().filter(|d| needs_copy(s, d)) {
|
||||
let now = stamp(s, d);
|
||||
let copy = match before.iter().position(|c| c.uid == d.uid && c.stamp == now) {
|
||||
Some(i) => before.swap_remove(i),
|
||||
None => Copy::of(s, d),
|
||||
};
|
||||
self.0.push(copy);
|
||||
}
|
||||
}
|
||||
|
||||
/// How many files have unsaved changes.
|
||||
pub fn count(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
|
||||
/// Each file as a saved `.figcraft` file in base64 (one that can't be written is left out).
|
||||
pub fn files(&self) -> Vec<String> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
self.0
|
||||
.iter()
|
||||
.filter_map(|c| figcraft_format::save_bytes_with_history(&c.doc, None, &c.versions, &c.branches).ok().map(|b| b64.encode(b)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep as many of `files` as the storage takes, in order: `put` stores the list's JSON and says
|
||||
/// whether it fitted (a refused write leaves what was stored before). Returns how many were kept.
|
||||
pub fn store(files: Vec<String>, mut put: impl FnMut(&str) -> bool) -> usize {
|
||||
let mut kept = Vec::new();
|
||||
for f in files {
|
||||
kept.push(f);
|
||||
if !put(&json!(kept).to_string()) {
|
||||
kept.pop();
|
||||
}
|
||||
}
|
||||
kept.len()
|
||||
}
|
||||
|
||||
/// Open the copies the last page kept (`kept`: what [`store`] stored, if anything) as files with
|
||||
/// unsaved changes. Returns how many came back; junk in the storage is skipped.
|
||||
pub fn restore(s: &mut Session, kept: Option<String>) -> usize {
|
||||
let files: Vec<String> = kept.and_then(|k| serde_json::from_str(&k).ok()).unwrap_or_default();
|
||||
files.iter().filter(|f| s.execute("file.recover", &json!({"data": f})).is_ok()).count()
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Recovery copies in the browser: what a page kept when it stopped opens again, unsaved, on the
|
||||
//! next page.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::browser_stopped;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::recovery::{Unsaved, restore, store};
|
||||
|
||||
fn edited(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": name})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The page's copies, stored as the panic hook stores them.
|
||||
fn stored(u: &Unsaved) -> Option<String> {
|
||||
let mut out = None;
|
||||
let kept = store(u.files(), |v| {
|
||||
out = Some(v.to_string());
|
||||
true
|
||||
});
|
||||
assert_eq!(kept, u.count());
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stopped_pages_unsaved_files_open_again_unsaved() {
|
||||
let s = edited("Poster");
|
||||
let mut u = Unsaved::default();
|
||||
u.follow(&s);
|
||||
assert_eq!(u.count(), 1);
|
||||
let mut next = Session::new();
|
||||
let before = next.docs.len();
|
||||
assert_eq!(restore(&mut next, stored(&u)), 1);
|
||||
assert_eq!(next.docs.len(), before + 1);
|
||||
let d = next.ds().unwrap();
|
||||
assert_eq!(d.doc.name, "Poster");
|
||||
assert!(d.is_dirty(), "a recovered file has unsaved changes");
|
||||
assert!(d.path.is_none(), "saving it asks where");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_latest_edit_is_kept_and_a_saved_file_is_not() {
|
||||
let mut s = edited("First");
|
||||
let mut u = Unsaved::default();
|
||||
u.follow(&s);
|
||||
u.follow(&s);
|
||||
assert_eq!(u.count(), 1, "following again without a change keeps the one copy");
|
||||
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
|
||||
u.follow(&s);
|
||||
let mut next = Session::new();
|
||||
assert_eq!(restore(&mut next, stored(&u)), 1);
|
||||
assert_eq!(next.ds().unwrap().doc.name, "Later");
|
||||
s.execute("file.markSaved", &json!({})).unwrap();
|
||||
u.follow(&s);
|
||||
assert_eq!(u.count(), 0);
|
||||
assert!(u.files().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_the_storage_has_no_room_for_is_left_out() {
|
||||
let files = vec!["a".repeat(10), "b".repeat(100), "c".repeat(10)];
|
||||
let mut kept = String::new();
|
||||
let n = store(files, |v| {
|
||||
let fits = v.len() < 60;
|
||||
if fits {
|
||||
kept = v.to_string();
|
||||
}
|
||||
fits
|
||||
});
|
||||
assert_eq!(n, 2);
|
||||
assert_eq!(kept, json!(["a".repeat(10), "c".repeat(10)]).to_string());
|
||||
assert_eq!(store(vec!["x".into()], |_| false), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_in_the_storage_opens_nothing() {
|
||||
let mut s = Session::new();
|
||||
let before = s.docs.len();
|
||||
for junk in [None, Some("not json"), Some("{}"), Some(r#"["!!"]"#), Some(r#"["aGVsbG8="]"#), Some("[1, null]")] {
|
||||
assert_eq!(restore(&mut s, junk.map(String::from)), 0, "{junk:?}");
|
||||
}
|
||||
assert_eq!(s.docs.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stopped_page_says_what_was_kept() {
|
||||
let says = |unsaved, kept, what: &str| {
|
||||
let text = browser_stopped(unsaved, kept);
|
||||
assert!(text.starts_with("FigCraft stopped because of an internal error. ") && text.contains(what), "{text}");
|
||||
};
|
||||
says(0, 0, "start again");
|
||||
says(2, 2, "get your unsaved files back");
|
||||
says(2, 0, "too large for the browser to keep");
|
||||
says(3, 1, "the browser had room for");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_taken_while_a_version_is_previewed_is_of_the_file() {
|
||||
let mut s = edited("Poster");
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("file.rename", &json!({"name": "Later"})).unwrap();
|
||||
// Version v1 (named Poster) shows in place of the file.
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, "Poster", "the preview shows");
|
||||
let mut u = Unsaved::default();
|
||||
u.follow(&s);
|
||||
let mut next = Session::new();
|
||||
assert_eq!(restore(&mut next, stored(&u)), 1);
|
||||
let d = next.ds().unwrap();
|
||||
assert_eq!(d.doc.name, "Later", "the file comes back, not the version shown");
|
||||
assert_eq!(d.versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
|
||||
}
|
||||
@@ -6,11 +6,23 @@ use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
const LOADING_ID: &str = "figcraft_loading";
|
||||
/// What the page shows once it stopped on an internal error (styled in `index.html`).
|
||||
const STOPPED_ID: &str = "figcraft_stopped";
|
||||
/// The local storage key your name is kept under ([`crate::kept`]).
|
||||
const NAME_KEY: &str = "figcraft.userName";
|
||||
/// The local storage key the recovery copies are kept under ([`crate::recovery`]): a JSON list of
|
||||
/// base64 `.figcraft` files.
|
||||
const RECOVERY_KEY: &str = "figcraft.recovery";
|
||||
|
||||
thread_local! {
|
||||
/// The files with unsaved changes as of the last frame, for the panic hook ([`crate::recovery`]).
|
||||
static UNSAVED: std::cell::RefCell<crate::recovery::Unsaved> = std::cell::RefCell::default();
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// Before the runner's own hook, which logs the panic and then calls this one.
|
||||
keep_unsaved_on_panic();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
|
||||
log::error!("no document");
|
||||
@@ -39,11 +51,13 @@ pub fn start() {
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
crate::kept::restore_name(&mut session, local_storage().and_then(|s| s.get_item(NAME_KEY).ok().flatten()));
|
||||
let recovered = restore_unsaved(&mut session);
|
||||
// Live sessions: the browser joins sessions and publishes files to servers;
|
||||
// whatever arrives on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
fig.note_recovered(recovered);
|
||||
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
|
||||
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
@@ -70,6 +84,41 @@ pub fn start() {
|
||||
});
|
||||
}
|
||||
|
||||
/// The browser can't catch a panic (wasm32 aborts), so the page ends with it: keep the files with
|
||||
/// unsaved changes in the page's storage for the next page, and say so on the page.
|
||||
fn keep_unsaved_on_panic() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
previous(info);
|
||||
// Callbacks that run after the page stopped can panic again: the files are kept once.
|
||||
ONCE.call_once(|| {
|
||||
let (unsaved, files) = UNSAVED.with(|u| u.try_borrow().map(|u| (u.count(), u.files())).unwrap_or_default());
|
||||
let kept = local_storage().map_or(0, |s| crate::recovery::store(files, |v| s.set_item(RECOVERY_KEY, v).is_ok()));
|
||||
show_stopped(&figcraft_ui_egui::browser_stopped(unsaved, kept));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/// Open the files the last page kept when it stopped; how many came back.
|
||||
fn restore_unsaved(session: &mut Session) -> usize {
|
||||
let Some(storage) = local_storage() else { return 0 };
|
||||
let kept = storage.get_item(RECOVERY_KEY).ok().flatten();
|
||||
// Gone before they open: a copy that stops the page again must not come back on every reload
|
||||
// (once a frame ran, the files it opened are followed as any unsaved file).
|
||||
let _ = storage.remove_item(RECOVERY_KEY);
|
||||
crate::recovery::restore(session, kept)
|
||||
}
|
||||
|
||||
/// Say on the page that it stopped (the canvas stays as it was).
|
||||
fn show_stopped(text: &str) {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let (Ok(el), Some(body)) = (document.create_element("div"), document.body()) else { return };
|
||||
el.set_id(STOPPED_ID);
|
||||
el.set_text_content(Some(text));
|
||||
let _ = body.append_child(&el);
|
||||
}
|
||||
|
||||
/// The page's local storage (`None` where the browser keeps none, e.g. storage turned off).
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window().and_then(|w| w.local_storage().ok().flatten())
|
||||
@@ -90,6 +139,8 @@ impl eframe::App for WebShell {
|
||||
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
|
||||
/// not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
|
||||
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use figcraft_engine::branches::{needs_copy, recovery_copy, stamp};
|
||||
use figcraft_engine::{DocState, Session};
|
||||
use figcraft_format::{Branch, Version};
|
||||
use serde_json::json;
|
||||
@@ -203,8 +204,7 @@ impl Recovery {
|
||||
if entry.done.as_ref() == Some(&stamp) || entry.flight.is_some() {
|
||||
continue;
|
||||
}
|
||||
let doc = d.doc.clone();
|
||||
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
|
||||
let (doc, versions, branches) = recovery_copy(s, d);
|
||||
// Which online file it is and what of the server's this very copy has (a line each:
|
||||
// the server, the file's id, then the session, client id, last change and the secret the
|
||||
// client id resumes with), while it
|
||||
@@ -321,23 +321,6 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64()
|
||||
}
|
||||
|
||||
/// A file whose changes would be lost: in its document, its history or one of its branch tabs
|
||||
/// (a branch tab's edits are saved with its main file, so the tab has no copy of its own).
|
||||
fn needs_copy(s: &Session, d: &DocState) -> bool {
|
||||
d.branch_of.is_none() && (d.is_dirty() || branch_tabs(s, d.uid).any(DocState::is_dirty))
|
||||
}
|
||||
|
||||
/// The tabs showing branches of the file in tab `uid`.
|
||||
fn branch_tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
|
||||
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
|
||||
}
|
||||
|
||||
/// The revisions a copy of `d` is of: its own and its branch tabs' (a branch tab's edits change
|
||||
/// only its own revision). A new copy is due when they change.
|
||||
fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
|
||||
std::iter::once(d.revision).chain(branch_tabs(s, d.uid).map(|t| t.revision)).collect()
|
||||
}
|
||||
|
||||
/// Write `doc` with its version history and branches to `path` through a temporary file; false
|
||||
/// when it couldn't be written (the copy before it is still there).
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
|
||||
@@ -388,3 +388,21 @@ fn a_background_copy_that_failed_is_tried_again_without_another_edit() {
|
||||
r.finish();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copy_made_while_a_version_is_previewed_is_of_the_file_not_the_version() {
|
||||
let root = root("preview");
|
||||
let mut s = edited();
|
||||
s.execute("file.saveVersion", &json!({"name": "v1"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
// The one-rectangle version shows in place of the two-rectangle file.
|
||||
s.execute("version.preview", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(rectangles(s.doc().unwrap()), 1, "the preview shows");
|
||||
assert!(s.ds().unwrap().is_dirty(), "the file itself still has unsaved changes");
|
||||
let mut r = Recovery::start(&root).unwrap();
|
||||
r.write(&s, true);
|
||||
let next = crash_and_recover(r, &root);
|
||||
assert_eq!(rectangles(next.doc().unwrap()), 2, "the file comes back, not the version shown");
|
||||
assert_eq!(next.docs[0].versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["v1"]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -53,9 +53,7 @@ impl Exchange {
|
||||
/// `file` with its `owner` token.
|
||||
pub(crate) fn new(server: Endpoint, key: Option<&str>, file: &str, owner: &str, requests: Vec<Admin>) -> Self {
|
||||
let open = match key {
|
||||
Some(key) => {
|
||||
ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() })
|
||||
}
|
||||
Some(key) => ClientMsg::Manage(Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() }),
|
||||
None => ClientMsg::OpenHosting(OpenHosting {
|
||||
protocol: PROTOCOL,
|
||||
client_version: message::client_version(),
|
||||
@@ -144,7 +142,7 @@ pub(crate) fn publish(job: Publish) -> Next {
|
||||
// The file's first links stay its owner's (this app joins with one): others get their
|
||||
// own, which stopping takes away again.
|
||||
let owner = published.owner.unwrap_or_default();
|
||||
let then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next> = Box::new(move |made| {
|
||||
let then: LinksThen = Box::new(move |made| {
|
||||
events.push(Event::Shared(made.and_then(|s| s.ok_or_else(|| "the server didn't make the links".to_string()))));
|
||||
Next::Done(events, Some(edit))
|
||||
});
|
||||
@@ -163,26 +161,45 @@ struct Sharing {
|
||||
create: bool,
|
||||
}
|
||||
|
||||
/// Revoke the links `s.revoke` names, then (to create) make new ones and ask for their codes on a
|
||||
/// connection of their own; `then` gets the links.
|
||||
fn share_links(s: Sharing, then: Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>) -> Next {
|
||||
/// What [`share_links`] hands the links to.
|
||||
type LinksThen = Box<dyn FnOnce(Result<Option<Shared>, String>) -> Next>;
|
||||
|
||||
/// Revoke the links `s.revoke` names, then (to create) make new ones, then a comment link and their
|
||||
/// codes, each on a connection of its own; `then` gets the links.
|
||||
fn share_links(s: Sharing, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::link_requests(&s.file, &s.revoke, s.create));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |answers| {
|
||||
let links = match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
|
||||
Ok(Some(links)) => links,
|
||||
other => return then(other.map(|_| None)),
|
||||
};
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |codes| {
|
||||
// A server from before join codes: the links are shared without them.
|
||||
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
|
||||
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
|
||||
}),
|
||||
)
|
||||
Box::new(move |answers| match answers.and_then(|a| requests::made_links(a.replies, s.create)) {
|
||||
Ok(Some(links)) => comment_link(s, links, then),
|
||||
other => then(other.map(|_| None)),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for a comment link beside `links`, then for their codes.
|
||||
fn comment_link(s: Sharing, mut links: requests::Links, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::comment_request(&s.file));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |made| {
|
||||
// A server from before comment links: the file is shared without one.
|
||||
let made = made.map_err(|e| log::info!("live session: no comment link from {}: {e}", s.server.display()));
|
||||
links.comment = made.ok().and_then(|a| requests::made_comment(a.replies));
|
||||
codes(s, links, then)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Ask for the codes of `links`; `then` gets them with their codes.
|
||||
fn codes(s: Sharing, links: requests::Links, then: LinksThen) -> Next {
|
||||
let ex = Exchange::new(s.server.clone(), s.key.as_deref(), &s.file, &s.owner, requests::code_requests(&s.file, &links));
|
||||
Next::Ask(
|
||||
Box::new(ex),
|
||||
Box::new(move |codes| {
|
||||
// A server from before join codes: the links are shared without them.
|
||||
let codes = codes.map_err(|e| log::info!("live session: no join codes from {}: {e}", s.server.display())).unwrap_or_default();
|
||||
then(Ok(Some(requests::shared(&s.server, &s.file, links, codes.replies))))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
|
||||
say(d, &admin(AdminReply::Link(new_link("e1", Access::Edit, "EditEditEditEditEditEd"))), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::View, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("v1", Access::View, "SeeSeeSeeSeeSeeSeeSeeS"))), t0);
|
||||
// The comment link, on another.
|
||||
d.opened(t0);
|
||||
say(d, &hosting(), t0);
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::CreateLink { access: Access::Comment, .. })));
|
||||
say(d, &admin(AdminReply::Link(new_link("c1", Access::Comment, "SaySaySaySaySaySaySayS"))), t0);
|
||||
// Their codes, on another.
|
||||
d.opened(t0);
|
||||
say(d, &ServerMsg::Pong, t0);
|
||||
@@ -136,6 +141,7 @@ fn make_links(d: &mut Driver<Script>, t0: Instant) {
|
||||
assert!(matches!(last(d), ClientMsg::Admin(Admin::Code { token, .. }) if token == "EditEditEditEditEditEd"));
|
||||
say(d, &admin(AdminReply::Code { code: "ABCDEFGHJ".into() }), t0);
|
||||
say(d, &admin(AdminReply::Code { code: "KLMNPQRST".into() }), t0);
|
||||
say(d, &admin(AdminReply::Code { code: "VWXYZ0123".into() }), t0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -153,15 +159,18 @@ fn publishing_without_the_key_uploads_shares_then_joins() {
|
||||
say(&mut d, &hosted(), t0);
|
||||
make_links(&mut d, t0);
|
||||
let events = inbox.drain();
|
||||
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view) }))] = events.as_slice() else {
|
||||
let [Event::Hosting(h), Event::Published(p), Event::Shared(Ok(Shared { edit, view: Some(view), comment: Some(comment) }))] = events.as_slice()
|
||||
else {
|
||||
panic!("{events:#?}")
|
||||
};
|
||||
assert_eq!(h.operator, "ops@example.com");
|
||||
assert_eq!((p.file.as_str(), p.owner.as_deref(), p.expires), (FILE, Some("owner-token"), Some(9)));
|
||||
assert_eq!((edit.id.as_str(), edit.code.as_deref(), view.code.as_deref()), ("e1", Some("ABCDEFGHJ"), Some("KLMNPQRST")));
|
||||
assert!(edit.link.contains("token=EditEditEditEditEditEd"));
|
||||
assert_eq!((comment.id.as_str(), comment.code.as_deref()), ("c1", Some("VWXYZ0123")));
|
||||
assert!(comment.link.contains("token=SaySaySaySaySaySaySayS"));
|
||||
// Then the file, with the owner's edit link.
|
||||
assert_eq!(d.sock.opened.len(), 4);
|
||||
assert_eq!(d.sock.opened.len(), 5);
|
||||
d.opened(t0);
|
||||
let ClientMsg::Hello(hello) = last(&d) else { panic!("{:?}", last(&d)) };
|
||||
assert_eq!((hello.file.as_str(), hello.token.as_str()), (FILE, TOKEN));
|
||||
@@ -270,7 +279,8 @@ fn sharing_that_fails_still_joins_the_file() {
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Err(e))) if e.contains("wrong owner token")), "{events:#?}");
|
||||
assert_eq!(d.sock.opened.len(), 3, "the file is joined anyway");
|
||||
// A server from before join codes shares the links without them.
|
||||
// A server from before join codes (and comment links) shares the edit and view links without
|
||||
// codes.
|
||||
let (mut d, inbox) = publish(job(None, true), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
@@ -279,11 +289,15 @@ fn sharing_that_fails_still_joins_the_file() {
|
||||
say(&mut d, &hosting(), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("e1", Access::Edit, TOKEN))), t0);
|
||||
say(&mut d, &admin(AdminReply::Link(new_link("v1", Access::View, TOKEN))), t0);
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
d.closed(1000, "that message couldn't be read", t0);
|
||||
for _ in ["the comment link", "the codes"] {
|
||||
d.opened(t0);
|
||||
say(&mut d, &hosting(), t0);
|
||||
d.closed(1000, "that message couldn't be read", t0);
|
||||
}
|
||||
let events = inbox.drain();
|
||||
assert!(matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none())));
|
||||
assert!(
|
||||
matches!(events.last(), Some(Event::Shared(Ok(s))) if s.edit.code.is_none() && s.view.as_ref().is_some_and(|v| v.code.is_none()) && s.comment.is_none())
|
||||
);
|
||||
}
|
||||
|
||||
/// Run background request `req` to its answer against `answers` (one per message the driver sends).
|
||||
|
||||
@@ -71,14 +71,15 @@ const BURST: usize = 256;
|
||||
enum Kind {
|
||||
/// In `file`'s session as client `id`, through link `link`.
|
||||
Joined { file: String, id: u32, link: Option<String> },
|
||||
/// Managing the server's files.
|
||||
Managing,
|
||||
/// Managing the server's files; `comments`: its client reads comment links.
|
||||
Managing { comments: bool },
|
||||
/// Using `file`'s team library through link `link` of `access` (revoking it disconnects).
|
||||
Library { file: String, access: Access, link: Option<String> },
|
||||
/// Using the plugin directory; `publisher`: with the server's key.
|
||||
Plugins { publisher: bool },
|
||||
/// Publishing without the key, or managing the file `owns` with its owner token.
|
||||
Hosting { owns: Option<String> },
|
||||
/// Publishing without the key, or managing the file `owns` with its owner token; `comments`:
|
||||
/// its client reads comment links.
|
||||
Hosting { owns: Option<String>, comments: bool },
|
||||
}
|
||||
|
||||
/// A connection whose first message the hub is looking at.
|
||||
@@ -234,7 +235,7 @@ impl<B: Backend> Hub<B> {
|
||||
let Some(c) = self.conns.get(&conn) else { return };
|
||||
let ip = c.ip;
|
||||
// The operator (with the server key) isn't held to the limits on addresses.
|
||||
if !matches!(c.kind, Kind::Managing) && !self.backend.message(ip, &msg) {
|
||||
if !matches!(c.kind, Kind::Managing { .. }) && !self.backend.message(ip, &msg) {
|
||||
log::info!("event=refused ip={ip} reason=messages_per_address");
|
||||
return self.kick(&[conn], "too many messages from your address: wait a minute and try again");
|
||||
}
|
||||
@@ -281,7 +282,7 @@ impl<B: Backend> Hub<B> {
|
||||
let (file, id) = (file.clone(), *id);
|
||||
self.client_message(&file, id, msg, Some(bytes));
|
||||
}
|
||||
Some(Kind::Hosting { owns }) => match msg {
|
||||
Some(Kind::Hosting { owns, .. }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = if owns.as_deref() == Some(file.as_str()) {
|
||||
self.removed_reply(&file, client)
|
||||
@@ -300,7 +301,7 @@ impl<B: Backend> Hub<B> {
|
||||
ClientMsg::Ping => self.answer(conn, &ServerMsg::Pong),
|
||||
_ => self.kick(&[conn], "only publishing and managing your own files are spoken on this connection"),
|
||||
},
|
||||
Some(Kind::Managing) => match msg {
|
||||
Some(Kind::Managing { .. }) => match msg {
|
||||
ClientMsg::Admin(Admin::RemovePeer { file, client }) => {
|
||||
let reply = self.removed_reply(&file, client);
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(reply)));
|
||||
@@ -475,7 +476,8 @@ impl<B: Backend> Hub<B> {
|
||||
return refuse(&c.out, &why);
|
||||
}
|
||||
log::info!("live session: connection {conn} manages files");
|
||||
self.take_in(conn, c, Kind::Managing, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
let comments = message::reads_comments(&m.client_version);
|
||||
self.take_in(conn, c, Kind::Managing { comments }, &ServerMsg::Admin(Box::new(AdminReply::Ready)));
|
||||
}
|
||||
|
||||
fn open_library(&mut self, conn: u64, o: OpenLibrary, c: Pending) {
|
||||
@@ -483,7 +485,7 @@ impl<B: Backend> Hub<B> {
|
||||
return refuse(&c.out, &unsupported(o.protocol));
|
||||
}
|
||||
let (access, link) = match self.backend.admit(&o.file, &o.token) {
|
||||
Ok(a) => (a.access, a.link),
|
||||
Ok(a) => (a.access.for_reader(message::reads_comments(&o.client_version)), a.link),
|
||||
Err(why) => return refuse(&c.out, &why),
|
||||
};
|
||||
self.take_in(conn, c, Kind::Library { file: o.file, access, link }, &ServerMsg::Library(Box::new(LibraryReply::Ready { access })));
|
||||
@@ -515,7 +517,8 @@ impl<B: Backend> Hub<B> {
|
||||
}
|
||||
};
|
||||
let owns = (!o.owner.is_empty()).then(|| o.file.clone());
|
||||
self.take_in(conn, c, Kind::Hosting { owns }, &ServerMsg::Hosting(Box::new(info)));
|
||||
let comments = message::reads_comments(&o.client_version);
|
||||
self.take_in(conn, c, Kind::Hosting { owns, comments }, &ServerMsg::Hosting(Box::new(info)));
|
||||
}
|
||||
|
||||
/// A join code: the file it opens and the token to join it with, then the connection closes
|
||||
@@ -545,9 +548,12 @@ impl<B: Backend> Hub<B> {
|
||||
self.handled(conn, handled);
|
||||
}
|
||||
|
||||
/// Answer a managing or hosting request, and disconnect whoever it affects.
|
||||
/// Answer a managing or hosting request (as its client reads it), and disconnect whoever it
|
||||
/// affects.
|
||||
fn handled(&mut self, conn: u64, handled: Handled) {
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply)));
|
||||
let comments =
|
||||
self.conns.get(&conn).is_some_and(|c| matches!(c.kind, Kind::Managing { comments: true } | Kind::Hosting { comments: true, .. }));
|
||||
self.answer(conn, &ServerMsg::Admin(Box::new(handled.reply.for_reader(comments))));
|
||||
if let Some((file, link)) = handled.revoked {
|
||||
let with = |joined: bool| -> Vec<u64> {
|
||||
let uses = |c: &Conn| match &c.kind {
|
||||
|
||||
@@ -85,7 +85,7 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
in_flight: InFlight(Default::default()),
|
||||
backend: Closes,
|
||||
rooms: HashMap::new(),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
|
||||
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing { comments: true }, ip })]),
|
||||
local: None,
|
||||
outbox: Vec::new(),
|
||||
barred: HashMap::new(),
|
||||
|
||||
@@ -65,7 +65,7 @@ fn the_biggest_consumer_is_shed_when_the_budget_runs_out() {
|
||||
for id in 1..=3u64 {
|
||||
let (l, rx) = line(&Arc::default(), &all, usize::MAX, 10_000);
|
||||
receivers.push(rx);
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing, ip });
|
||||
conns.insert(id, Conn { out: l, sock: socket(), kind: Kind::Managing { comments: true }, ip });
|
||||
}
|
||||
// Connection 1 doesn't read: it holds most of the budget.
|
||||
let big = Bytes::from(vec![0u8; 9_000]);
|
||||
|
||||
@@ -16,7 +16,7 @@ use tungstenite::{Bytes, Message, WebSocket};
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
use crate::net::{Duplex, NetLimits, config, open};
|
||||
pub(crate) use crate::requests::REFUSED;
|
||||
use crate::requests::{code_requests, hosting_error, link_requests, made_links, shared};
|
||||
use crate::requests::{code_requests, comment_request, hosting_error, link_requests, made_comment, made_links, shared};
|
||||
|
||||
type Ws = WebSocket<Duplex>;
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn request(server: &Endpoint, key: &str, requests: Vec<Admin>, limits: &NetL
|
||||
ServerMsg::Admin(r) => Some(*r),
|
||||
_ => None,
|
||||
};
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let manage = Manage { protocol: PROTOCOL, key: key.to_string(), client_version: message::client_version() };
|
||||
let mut ws = connect(server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::Manage(manage))?;
|
||||
match reply(&mut ws, limits, admin)? {
|
||||
@@ -97,8 +97,9 @@ pub fn resolve(server: &Endpoint, code: &str, limits: &NetLimits, timeout: Durat
|
||||
/// Change who else gets into `file`, as whoever manages it (with the server's `key`, else with the
|
||||
/// file's `owner` token): revoke the links with the ids in `revoke` (whoever joined with them is
|
||||
/// disconnected, and their join codes stop working; one already gone is fine), then, to `create`,
|
||||
/// make a new edit link and a new view link and ask for their join codes: what others join with.
|
||||
/// A server older than join codes makes the links without codes.
|
||||
/// make a new edit link, a new view link and a new comment link and ask for their join codes: what
|
||||
/// others join with. A server older than join codes makes the links without codes, and one older
|
||||
/// than comment links makes no comment link.
|
||||
pub fn share(
|
||||
server: &Endpoint,
|
||||
key: Option<&str>,
|
||||
@@ -113,7 +114,11 @@ pub fn share(
|
||||
Some(key) => request(server, key, requests, limits, timeout),
|
||||
None => hosting(server, file, owner, requests, limits, timeout).map(|(_, replies)| replies),
|
||||
};
|
||||
let Some(links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
|
||||
let Some(mut links) = made_links(ask(link_requests(file, revoke, create))?, create)? else { return Ok(None) };
|
||||
// The comment link on a connection of its own: a server from before comment links can't read
|
||||
// the request and closes it (the file is shared without one then).
|
||||
let comment = ask(comment_request(file)).map_err(|e| log::info!("live session: no comment link from {}: {e}", server.display()));
|
||||
links.comment = made_comment(comment.unwrap_or_default());
|
||||
// Their codes, on a connection of its own: a server from before join codes can't read the
|
||||
// request and closes it (the links are shared without codes then).
|
||||
let codes =
|
||||
@@ -130,7 +135,7 @@ pub fn library(link: &str, req: LibraryReq, limits: &NetLimits, timeout: Duratio
|
||||
_ => None,
|
||||
};
|
||||
let invite = Invite::parse(link)?;
|
||||
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: env!("CARGO_PKG_VERSION").to_string() };
|
||||
let open = OpenLibrary { protocol: PROTOCOL, file: invite.file, token: invite.token, client_version: message::client_version() };
|
||||
let mut ws = connect(&invite.server, limits, timeout)?;
|
||||
send(&mut ws, &ClientMsg::OpenLibrary(open))?;
|
||||
match reply(&mut ws, limits, lib)? {
|
||||
|
||||
@@ -63,18 +63,42 @@ pub(crate) fn uploaded(server: &Endpoint, reply: Option<AdminReply>) -> Result<(
|
||||
Ok((edit, published))
|
||||
}
|
||||
|
||||
/// The links made for others: an edit and a view link, and a comment link from a server that
|
||||
/// makes them.
|
||||
pub(crate) struct Links {
|
||||
pub edit: NewLink,
|
||||
pub view: NewLink,
|
||||
pub comment: Option<NewLink>,
|
||||
}
|
||||
|
||||
/// The request for a new link of `access` to `file`, for others.
|
||||
fn link_request(file: &str, access: Access) -> Admin {
|
||||
Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) }
|
||||
}
|
||||
|
||||
/// The requests that revoke `file`'s links with the ids in `revoke`, then, to `create`, make a new
|
||||
/// edit link and a new view link.
|
||||
pub(crate) fn link_requests(file: &str, revoke: &[String], create: bool) -> Vec<Admin> {
|
||||
let link = |access: Access| Admin::CreateLink { file: file.to_string(), access, label: format!("Shared ({})", access.id()) };
|
||||
let mut requests: Vec<Admin> = revoke.iter().map(|id| Admin::Revoke { file: file.to_string(), link: id.clone() }).collect();
|
||||
requests.extend(create.then(|| [link(Access::Edit), link(Access::View)]).into_iter().flatten());
|
||||
requests.extend(create.then(|| [link_request(file, Access::Edit), link_request(file, Access::View)]).into_iter().flatten());
|
||||
requests
|
||||
}
|
||||
|
||||
/// The request for a comment link to `file`, asked on a connection of its own once
|
||||
/// [`made_links`] gave the others: a server from before comment links can't read it and closes
|
||||
/// the connection (the file is shared without one then).
|
||||
pub(crate) fn comment_request(file: &str) -> Vec<Admin> {
|
||||
vec![link_request(file, Access::Comment)]
|
||||
}
|
||||
|
||||
/// The comment link the answers to [`comment_request`] made, if any.
|
||||
pub(crate) fn made_comment(replies: Vec<AdminReply>) -> Option<NewLink> {
|
||||
replies.into_iter().find_map(|r| if let AdminReply::Link(l) = r { Some(l) } else { None })
|
||||
}
|
||||
|
||||
/// The edit and view links the answers to [`link_requests`] made (`None`: none were asked for),
|
||||
/// or why the server didn't do it.
|
||||
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<(NewLink, NewLink)>, String> {
|
||||
/// or why the server didn't do it; the comment link comes after ([`made_comment`]).
|
||||
pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Option<Links>, String> {
|
||||
let mut made = Vec::new();
|
||||
for reply in replies {
|
||||
match reply {
|
||||
@@ -86,7 +110,7 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
|
||||
}
|
||||
}
|
||||
match (made.pop(), made.pop()) {
|
||||
(Some(view), Some(edit)) => Ok(Some((edit, view))),
|
||||
(Some(view), Some(edit)) => Ok(Some(Links { edit, view, comment: None })),
|
||||
_ if create => Err("the server didn't make the links".into()),
|
||||
_ => Ok(None),
|
||||
}
|
||||
@@ -94,21 +118,21 @@ pub(crate) fn made_links(replies: Vec<AdminReply>, create: bool) -> Result<Optio
|
||||
|
||||
/// The requests for the join codes of the links [`made_links`] gave (asked on a connection of
|
||||
/// their own: a server from before join codes can't read them and closes it).
|
||||
pub(crate) fn code_requests(file: &str, (edit, view): &(NewLink, NewLink)) -> Vec<Admin> {
|
||||
[edit, view].into_iter().map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
|
||||
pub(crate) fn code_requests(file: &str, links: &Links) -> Vec<Admin> {
|
||||
[&links.edit, &links.view].into_iter().chain(&links.comment).map(|l| Admin::Code { file: file.to_string(), token: l.token.clone() }).collect()
|
||||
}
|
||||
|
||||
/// What others join `file` on `server` with: the links, with the codes `codes` (the answers to
|
||||
/// [`code_requests`]; none from a server older than join codes).
|
||||
pub(crate) fn shared(server: &Endpoint, file: &str, (edit, view): (NewLink, NewLink), codes: Vec<AdminReply>) -> Shared {
|
||||
pub(crate) fn shared(server: &Endpoint, file: &str, Links { edit, view, comment }: Links, codes: Vec<AdminReply>) -> Shared {
|
||||
let mut codes = codes.into_iter().map(|r| if let AdminReply::Code { code } = r { Some(code) } else { None });
|
||||
let mut link = |l: NewLink| SharedLink {
|
||||
link: Invite { server: server.clone(), file: file.to_string(), token: l.token }.link(),
|
||||
id: l.link.id,
|
||||
code: codes.next().flatten(),
|
||||
};
|
||||
let edit = link(edit);
|
||||
Shared { edit, view: Some(link(view)) }
|
||||
let (edit, view) = (link(edit), Some(link(view)));
|
||||
Shared { edit, view, comment: comment.map(link) }
|
||||
}
|
||||
|
||||
/// A hosting answer as the app shows it: a new link whole, in place of its token.
|
||||
|
||||
@@ -318,6 +318,16 @@ impl Change {
|
||||
pub(crate) fn is_image(&self) -> bool {
|
||||
matches!(self, Change::Put { entry: Entry::Image(_), .. } | Change::Remove { entry: Entry::Image(_) })
|
||||
}
|
||||
|
||||
/// Whether this only changes comment threads (a thread, their order, or all of them): what a
|
||||
/// comment link may send.
|
||||
pub fn is_comment(&self) -> bool {
|
||||
match self {
|
||||
Change::Put { entry, .. } | Change::Remove { entry } => entry.list() == Some(Family::Comments),
|
||||
Change::SetDoc { prop, .. } => matches!(prop.list(), Some(Ok(Family::Comments) | Err(Family::Comments))),
|
||||
Change::Create { .. } | Change::Set { .. } | Change::Delete { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
|
||||
@@ -229,6 +229,12 @@ impl Client {
|
||||
Ok(self.flush(&NoRecompute).pop())
|
||||
}
|
||||
|
||||
/// Whether `doc` differs from what this client shows only in comment threads (what a comment
|
||||
/// link may send; nothing differing counts too).
|
||||
pub fn changes_only_comments(&self, doc: &Document) -> bool {
|
||||
diff(&self.view, doc).is_ok_and(|d| d.changes.iter().all(Change::is_comment))
|
||||
}
|
||||
|
||||
/// Undo the user's last step that still changes something: write back the values it replaced
|
||||
/// where nobody else wrote since (their later writes win), delete what it created (unless
|
||||
/// others put layers inside) and bring back what it deleted. Returns the batches to send now
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Comment links: a commenter sees everything and may change comment threads (add, reply, react,
|
||||
//! resolve, remove, reorder), but a batch that changes anything else is refused with the reason
|
||||
//! and rolled back; a client that can't read comment links joins with one as a viewer, managing
|
||||
//! clients that can't read them get them listed as view links, and a level this build doesn't know
|
||||
//! reads as view.
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message, NodeKind};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::message::{
|
||||
self, Access, AdminReply, ClientMsg, Hello, LinkInfo, NewLink, ServerMsg, WIRE_VERSION, decode, decode_json, encode, encode_json,
|
||||
};
|
||||
use crate::sim_tests::base;
|
||||
use crate::{COMMENT_ONLY, Client, Limits, NoRecompute, Outgoing, Server};
|
||||
|
||||
fn hello(client_version: &str) -> Hello {
|
||||
let ClientMsg::Hello(mut h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") };
|
||||
h.client_version = client_version.into();
|
||||
h
|
||||
}
|
||||
|
||||
fn join_as(s: &mut Server, access: Access, client_version: &str) -> Client {
|
||||
let (_, out) = s.join_as(hello(client_version), access).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
|
||||
Client::join(welcome).unwrap()
|
||||
}
|
||||
|
||||
/// `c` edits its copy with `f`; the server's answer, delivered to everyone in `all`.
|
||||
fn send(s: &mut Server, c: &mut Client, f: impl FnOnce(&mut Document), all: &mut [&mut Client]) -> Vec<Outgoing> {
|
||||
let mut d = c.document().clone();
|
||||
f(&mut d);
|
||||
let m = c.edit(&d).unwrap().expect("a change");
|
||||
let out = s.receive(c.id(), m);
|
||||
for o in &out {
|
||||
let m = match o {
|
||||
Outgoing::All(m) => m,
|
||||
Outgoing::To(t, m) if *t == c.id() => m,
|
||||
_ => continue,
|
||||
};
|
||||
c.receive(m.clone(), &NoRecompute).unwrap();
|
||||
for other in all.iter_mut() {
|
||||
if matches!(o, Outgoing::All(_)) {
|
||||
other.receive(m.clone(), &NoRecompute).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn accepted(out: &[Outgoing]) -> bool {
|
||||
matches!(out, [Outgoing::All(ServerMsg::Changes(_))])
|
||||
}
|
||||
|
||||
fn refused(out: &[Outgoing]) -> bool {
|
||||
matches!(out, [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == COMMENT_ONLY)
|
||||
}
|
||||
|
||||
fn comment(id: u64, d: &Document, text: &str) -> Comment {
|
||||
let page = d.first_page().unwrap();
|
||||
Comment { id, page, x: 10.0, y: 20.0, anchor: None, first: Message::new("Cy", text, 0), resolved: false, replies: vec![], read_by: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commenter_changes_comment_threads_and_nothing_else() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut editor = join_as(&mut s, Access::Edit, &message::client_version());
|
||||
let mut cy = join_as(&mut s, Access::Comment, &message::client_version());
|
||||
assert_eq!(cy.access(), Access::Comment, "the welcome says what the client may do");
|
||||
let page = s.document().first_page().unwrap();
|
||||
// A new thread reaches everyone; so do a reply, a resolve and a reaction to it.
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
let id = d.new_comment_id();
|
||||
let c = comment(id, d, "Bigger logo?");
|
||||
d.comments.push(c);
|
||||
},
|
||||
&mut [&mut editor],
|
||||
);
|
||||
assert!(accepted(&out), "{out:?}");
|
||||
assert_eq!(editor.document().comments.len(), 1);
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
let c = &mut d.comments[0];
|
||||
c.replies.push(Message::new("Cy", "Or a darker one", 1));
|
||||
c.resolved = true;
|
||||
c.first.react("👍", "Cy", None);
|
||||
},
|
||||
&mut [&mut editor],
|
||||
);
|
||||
assert!(accepted(&out), "{out:?}");
|
||||
assert_eq!(s.document().comments[0].replies.len(), 1);
|
||||
assert!(s.document().comments[0].resolved);
|
||||
// A layer, a page or any other change is refused with the reason and rolled back.
|
||||
let seq = s.seq();
|
||||
assert!(refused(&send(&mut s, &mut cy, |d| d.node_mut(page).unwrap().name = "Mine".into(), &mut [])));
|
||||
assert!(refused(&send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
d.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
},
|
||||
&mut []
|
||||
)));
|
||||
assert!(refused(&send(&mut s, &mut cy, |d| d.name = "Renamed".into(), &mut [])));
|
||||
// So is a batch that sneaks an edit in beside a comment.
|
||||
assert!(refused(&send(
|
||||
&mut s,
|
||||
&mut cy,
|
||||
|d| {
|
||||
d.comments[0].resolved = false;
|
||||
d.node_mut(page).unwrap().name = "Mine".into();
|
||||
},
|
||||
&mut []
|
||||
)));
|
||||
assert_eq!(s.seq(), seq, "nothing accepted");
|
||||
assert_eq!(cy.view(), s.replica(), "the commenter's copy is the server's again");
|
||||
assert!(s.document().comments[0].resolved);
|
||||
// Removing a thread is a comment change too.
|
||||
assert!(accepted(&send(&mut s, &mut cy, |d| d.comments.clear(), &mut [&mut editor])));
|
||||
assert!(editor.document().comments.is_empty());
|
||||
// The editor still edits.
|
||||
assert!(accepted(&send(&mut s, &mut editor, |d| d.node_mut(page).unwrap().name = "Home".into(), &mut [&mut cy])));
|
||||
assert_eq!(cy.document().node(page).unwrap().name, "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_that_cant_read_comment_links_joins_with_one_as_a_viewer() {
|
||||
let mut s = Server::new(base(), "", Limits::default());
|
||||
let mut old = join_as(&mut s, Access::Comment, "0.12.1 signals hosting");
|
||||
assert_eq!(old.access(), Access::View);
|
||||
let page = s.document().first_page().unwrap();
|
||||
let out = send(
|
||||
&mut s,
|
||||
&mut old,
|
||||
|d| {
|
||||
let id = d.new_comment_id();
|
||||
let c = comment(id, d, "Hi");
|
||||
d.comments.push(c);
|
||||
},
|
||||
&mut [],
|
||||
);
|
||||
assert!(matches!(out.as_slice(), [Outgoing::To(_, ServerMsg::Reject { reason, .. })] if reason == crate::VIEW_ONLY), "{out:?}");
|
||||
// Edit and view links are as they were for it.
|
||||
assert_eq!(join_as(&mut s, Access::Edit, "0.12.1").access(), Access::Edit);
|
||||
assert_eq!(join_as(&mut s, Access::View, "0.12.1").access(), Access::View);
|
||||
assert!(s.document().node(page).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_comment_thread_changes_count_as_comments() {
|
||||
let d = base();
|
||||
let c = comment(7, &d, "x");
|
||||
let value = Json::of(&c).unwrap();
|
||||
let page = d.first_page().unwrap();
|
||||
let comments = [
|
||||
Change::Put { entry: Entry::Comment(7), value: value.clone() },
|
||||
Change::Remove { entry: Entry::Comment(7) },
|
||||
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[7u64]).unwrap() },
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&[c]).unwrap() },
|
||||
];
|
||||
assert!(comments.iter().all(Change::is_comment));
|
||||
let others = [
|
||||
Change::Put { entry: Entry::Style("S:1".into()), value: value.clone() },
|
||||
Change::Remove { entry: Entry::Image("a".into()) },
|
||||
Change::SetDoc { prop: DocProp::Name, value: Json::of("x").unwrap() },
|
||||
Change::SetDoc { prop: DocProp::StyleOrder, value: Json::of(&["S:1"]).unwrap() },
|
||||
Change::Set { id: page, prop: Prop::Name, value: Json::of("x").unwrap() },
|
||||
Change::Delete { id: page },
|
||||
];
|
||||
assert!(!others.iter().any(Change::is_comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_access_reads_back_and_an_unknown_level_reads_as_view() {
|
||||
assert_eq!(Access::parse(" Comment "), Some(Access::Comment));
|
||||
assert_eq!(Access::Comment.id(), "comment");
|
||||
for a in [Access::Edit, Access::View, Access::Comment] {
|
||||
assert_eq!(decode::<Access>(&encode(&a).unwrap(), 64).unwrap(), a);
|
||||
assert_eq!(decode_json::<Access>(&encode_json(&a).unwrap(), 64).unwrap(), a);
|
||||
}
|
||||
// A level from a newer build.
|
||||
assert_eq!(decode::<Access>(&[WIRE_VERSION, 9], 64).unwrap(), Access::View);
|
||||
assert_eq!(decode_json::<Access>("\"owner\"", 64).unwrap(), Access::View);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managing_clients_that_cant_read_comment_links_get_view_links() {
|
||||
let link = |access| LinkInfo { id: "l".into(), file: "f".into(), access, label: String::new(), created: 0 };
|
||||
let listing = AdminReply::Links(vec![link(Access::Edit), link(Access::Comment), link(Access::View)]);
|
||||
let AdminReply::Links(old) = listing.clone().for_reader(false) else { panic!() };
|
||||
assert_eq!(old.iter().map(|l| l.access).collect::<Vec<_>>(), [Access::Edit, Access::View, Access::View]);
|
||||
assert_eq!(listing.clone().for_reader(true), listing);
|
||||
let made = AdminReply::Link(NewLink { link: link(Access::Comment), token: "t".into() });
|
||||
assert!(matches!(made.for_reader(false), AdminReply::Link(l) if l.link.access == Access::View));
|
||||
assert!(message::reads_comments(&message::client_version()) && !message::reads_comments("0.12.1 signals hosting"));
|
||||
}
|
||||
@@ -44,7 +44,7 @@ pub use figcraft_doc::{Document, NodeId};
|
||||
pub use history::{BatchKind, Notice};
|
||||
pub use message::{Accepted, Access, CatchUp, ClientMsg, EditingText, Hello, Peer, Presence, Processed, Resume, ServerMsg, Signal};
|
||||
pub use replica::{Applied, Mode, Replica};
|
||||
pub use server::{Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
pub use server::{COMMENT_ONLY, Journal, Outgoing, Server, SessionState, VIEW_ONLY};
|
||||
|
||||
/// Compare secrets (tokens, keys, their hashes) without stopping at the first difference.
|
||||
pub fn same_secret(a: &str, b: &str) -> bool {
|
||||
@@ -131,6 +131,8 @@ mod client_tests;
|
||||
#[cfg(test)]
|
||||
mod codec_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_access_tests;
|
||||
#[cfg(test)]
|
||||
mod diff_tests;
|
||||
#[cfg(test)]
|
||||
mod entry_ids_tests;
|
||||
|
||||
@@ -23,19 +23,33 @@ pub const SIGNALS: &str = "signals";
|
||||
/// [`ServerMsg::Reported`]: a server sends them only to clients that say so.
|
||||
pub const HOSTING: &str = "hosting";
|
||||
|
||||
/// The word a client puts in its [`Hello::client_version`] (or its managing connection's) when it
|
||||
/// reads [`Access::Comment`]: older ones get a comment link as a view link.
|
||||
pub const COMMENTS: &str = "comments";
|
||||
|
||||
/// The `client_version` this build announces: its version, then what it reads beyond the protocol.
|
||||
pub fn client_version() -> String {
|
||||
format!("{} {SIGNALS} {HOSTING}", env!("CARGO_PKG_VERSION"))
|
||||
format!("{} {SIGNALS} {HOSTING} {COMMENTS}", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Whether `client_version` announces `word`.
|
||||
fn announces(client_version: &str, word: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == word)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`SIGNALS`].
|
||||
pub fn reads_signals(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == SIGNALS)
|
||||
announces(client_version, SIGNALS)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`HOSTING`].
|
||||
pub fn reads_hosting(client_version: &str) -> bool {
|
||||
client_version.split_whitespace().any(|w| w == HOSTING)
|
||||
announces(client_version, HOSTING)
|
||||
}
|
||||
|
||||
/// A [`Hello::client_version`] that announces [`COMMENTS`].
|
||||
pub fn reads_comments(client_version: &str) -> bool {
|
||||
announces(client_version, COMMENTS)
|
||||
}
|
||||
|
||||
/// Join a file's session.
|
||||
@@ -56,22 +70,72 @@ pub struct Hello {
|
||||
pub resume: Option<Resume>,
|
||||
}
|
||||
|
||||
/// What a participant may do: edit, or only view (a view link). A viewer gets everyone's changes
|
||||
/// and presence, and the server refuses every batch it sends.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
/// What a participant may do: edit, only view (a view link), or view and comment (a comment
|
||||
/// link). A viewer gets everyone's changes and presence, and the server refuses every batch it
|
||||
/// sends; a commenter's batches are refused unless they only change comment threads. A level
|
||||
/// this build doesn't know reads as [`View`](Access::View), and a client that doesn't announce
|
||||
/// [`COMMENTS`] is welcomed with a comment link as a viewer.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Access {
|
||||
#[default]
|
||||
Edit,
|
||||
View,
|
||||
/// New levels go at the end: the binary form numbers them.
|
||||
Comment,
|
||||
}
|
||||
|
||||
/// Reads what [`Access`]'s derived `Serialize` writes (a variant's number in the binary form, its
|
||||
/// name in JSON); an unknown one reads as [`Access::View`].
|
||||
impl<'de> Deserialize<'de> for Access {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
use serde::de::{EnumAccess, VariantAccess, Visitor};
|
||||
struct Level(Access);
|
||||
impl<'de> Deserialize<'de> for Level {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
struct Id;
|
||||
impl Visitor<'_> for Id {
|
||||
type Value = Level;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an access level")
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Level, E> {
|
||||
Ok(Level(match v {
|
||||
0 => Access::Edit,
|
||||
2 => Access::Comment,
|
||||
_ => Access::View,
|
||||
}))
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Level, E> {
|
||||
Ok(Level(Access::parse(v).unwrap_or(Access::View)))
|
||||
}
|
||||
}
|
||||
d.deserialize_identifier(Id)
|
||||
}
|
||||
}
|
||||
struct Unit;
|
||||
impl<'de> Visitor<'de> for Unit {
|
||||
type Value = Access;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an access level")
|
||||
}
|
||||
fn visit_enum<A: EnumAccess<'de>>(self, data: A) -> Result<Access, A::Error> {
|
||||
let (Level(a), v) = data.variant()?;
|
||||
v.unit_variant()?;
|
||||
Ok(a)
|
||||
}
|
||||
}
|
||||
d.deserialize_enum("Access", &["edit", "view", "comment"], Unit)
|
||||
}
|
||||
}
|
||||
|
||||
impl Access {
|
||||
/// `"edit"` or `"view"`.
|
||||
/// `"edit"`, `"view"` or `"comment"`.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Access::Edit => "edit",
|
||||
Access::View => "view",
|
||||
Access::Comment => "comment",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +144,16 @@ impl Access {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"edit" => Some(Access::Edit),
|
||||
"view" => Some(Access::View),
|
||||
"comment" => Some(Access::Comment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// This access as a client can read it: a comment link only views for one that doesn't
|
||||
/// announce [`COMMENTS`] (`reads_comments` false).
|
||||
pub fn for_reader(self, reads_comments: bool) -> Access {
|
||||
if self == Access::Comment && !reads_comments { Access::View } else { self }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a reconnecting client tells the server in its [`Hello`].
|
||||
@@ -351,6 +422,18 @@ pub struct NewLink {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl AdminReply {
|
||||
/// This reply as a managing client can read it ([`Access::for_reader`]).
|
||||
pub fn for_reader(mut self, reads_comments: bool) -> AdminReply {
|
||||
match &mut self {
|
||||
AdminReply::Links(links) => links.iter_mut().for_each(|l| l.access = l.access.for_reader(reads_comments)),
|
||||
AdminReply::Link(l) => l.link.access = l.link.access.for_reader(reads_comments),
|
||||
_ => {}
|
||||
}
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The answer to an [`Admin`] request (the first one on a connection answers [`Manage`]).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
+22
-10
@@ -7,7 +7,9 @@ use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
|
||||
use crate::message::{
|
||||
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
|
||||
};
|
||||
use crate::replica::{Mode, Replica, create_change};
|
||||
use crate::{Error, Limits, docprops, same_secret};
|
||||
|
||||
@@ -25,6 +27,9 @@ pub enum Outgoing {
|
||||
/// Why a viewer's batch is refused.
|
||||
pub const VIEW_ONLY: &str = "you can only view this file (your link is view-only)";
|
||||
|
||||
/// Why a commenter's batch that changes more than comment threads is refused.
|
||||
pub const COMMENT_ONLY: &str = "you can only view and comment on this file (your link is a comment link)";
|
||||
|
||||
/// A new style, variable, collection or comment may not take an id in a space at or above this
|
||||
/// that isn't its creator's. Client ids start above every space in the document after a restart,
|
||||
/// so no member can use them up: at least this many always remain. Lower spaces stay open, for
|
||||
@@ -75,8 +80,8 @@ pub struct Server {
|
||||
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
|
||||
log_changes: usize,
|
||||
log_bytes: usize,
|
||||
/// The connected clients that may only view.
|
||||
viewers: BTreeSet<u32>,
|
||||
/// The connected clients that may not edit, and what they may do instead (view, or comment).
|
||||
limited: BTreeMap<u32, Access>,
|
||||
/// What changed the lasting state since it was last taken (`None`: not kept).
|
||||
journal: Option<Vec<Journal>>,
|
||||
/// The connected clients that read signals (cursor chat, spotlight).
|
||||
@@ -133,7 +138,7 @@ impl Server {
|
||||
log: VecDeque::new(),
|
||||
log_changes: 0,
|
||||
log_bytes: 0,
|
||||
viewers: BTreeSet::new(),
|
||||
limited: BTreeMap::new(),
|
||||
journal: None,
|
||||
signals: BTreeSet::new(),
|
||||
spotlight: None,
|
||||
@@ -291,8 +296,11 @@ impl Server {
|
||||
self.join_as(hello, Access::Edit)
|
||||
}
|
||||
|
||||
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused.
|
||||
/// [`join`](Self::join) with what the client may do: every batch of a viewer is refused, and
|
||||
/// every batch of a commenter that changes more than comment threads. A client that can't
|
||||
/// read comment links joins with one as a viewer.
|
||||
pub fn join_as(&mut self, hello: Hello, access: Access) -> Result<(u32, Vec<Outgoing>), ServerMsg> {
|
||||
let access = access.for_reader(reads_comments(&hello.client_version));
|
||||
let refuse = |reason: &str| ServerMsg::Refused { reason: reason.to_string() };
|
||||
if hello.protocol != PROTOCOL {
|
||||
return Err(refuse(&format!("protocol {} isn't supported (this server speaks {PROTOCOL})", hello.protocol)));
|
||||
@@ -338,10 +346,10 @@ impl Server {
|
||||
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
|
||||
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
|
||||
self.peers.insert(id, peer.clone());
|
||||
if access == Access::View {
|
||||
self.viewers.insert(id);
|
||||
if access == Access::Edit {
|
||||
self.limited.remove(&id);
|
||||
} else {
|
||||
self.viewers.remove(&id);
|
||||
self.limited.insert(id, access);
|
||||
}
|
||||
let mut outs = vec![Outgoing::To(id, welcome), Outgoing::Others(id, ServerMsg::PeerJoined(peer))];
|
||||
if signals {
|
||||
@@ -370,7 +378,7 @@ impl Server {
|
||||
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
|
||||
self.presence.remove(&id);
|
||||
self.acked.remove(&id);
|
||||
self.viewers.remove(&id);
|
||||
self.limited.remove(&id);
|
||||
self.signals.remove(&id);
|
||||
if self.spotlight == Some(id) {
|
||||
self.spotlight = None;
|
||||
@@ -392,7 +400,11 @@ impl Server {
|
||||
// client learned what happened to it from the catch-up.
|
||||
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
|
||||
ClientMsg::Changes { base_seq, batch_id, changes } => {
|
||||
let result = if self.viewers.contains(&from) { Err(Error::Invalid(VIEW_ONLY.into())) } else { self.accept(from, base_seq, &changes) };
|
||||
let result = match self.limited.get(&from) {
|
||||
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
|
||||
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
|
||||
_ => self.accept(from, base_seq, &changes),
|
||||
};
|
||||
if let Some(p) = self.issued.get_mut(&from) {
|
||||
p.note(batch_id, result.is_err());
|
||||
}
|
||||
|
||||
@@ -15,3 +15,4 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
@@ -78,3 +78,33 @@ fn spread_reaches_further_at_sharp_corners_only() {
|
||||
assert_eq!(d.node(r).unwrap().paint_reach(), 10.0);
|
||||
assert_eq!(d.node(s).unwrap().paint_reach(), 10.0 * SPREAD_MITER_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_layer_effects_reach_together_and_shadows_from_there() {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
|
||||
let blur = |r: f64| Effect { kind: EffectKind::LayerBlur(Blur::uniform(r)), visible: true };
|
||||
// One blur reaches as before; four of 10 as one of 20 (their variances add).
|
||||
n.effects = vec![blur(10.0)];
|
||||
assert_eq!(n.layer_effects_reach(), 15.0);
|
||||
n.effects = vec![blur(10.0), blur(10.0), blur(10.0), blur(10.0)];
|
||||
assert!((n.layer_effects_reach() - 30.0).abs() < 1e-9, "{}", n.layer_effects_reach());
|
||||
// A sharp shadow below is cast from the blurred layer: it reaches the blur's distance further.
|
||||
let shadow =
|
||||
Effect { kind: EffectKind::DropShadow(Shadow { radius: 0.0, offset: (0.0, 50.0), spread: 0.0, ..Shadow::default() }), visible: true };
|
||||
n.effects = vec![blur(10.0), shadow];
|
||||
let b = n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
assert_eq!((b.y0, b.y1), (-15.0, 75.0), "{b:?}");
|
||||
// Hidden effects reach nowhere.
|
||||
n.effects = vec![Effect { visible: false, ..blur(10.0) }];
|
||||
assert_eq!(n.layer_effects_reach(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaches_too_large_to_add_stay_finite() {
|
||||
let mut n = Node::new(NodeId(1), NodeKind::Rectangle, "R", 10.0, 10.0);
|
||||
let t = Texture { radius: 1e308, ..Default::default() };
|
||||
n.effects = vec![Effect { kind: EffectKind::Texture(t.clone()), visible: true }, Effect { kind: EffectKind::Texture(t), visible: true }];
|
||||
let (reach, b) = (n.paint_reach(), n.grow_by_effects(figcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
assert!(reach.is_finite() && reach > 1e6, "{reach}");
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() > 1e6, "{b:?}");
|
||||
}
|
||||
@@ -738,7 +738,8 @@ impl Document {
|
||||
|
||||
/// Bounds of everything painted by a node, effects and strokes included (page coordinates):
|
||||
/// a clipping frame's own area, else that and what its visible children show (masked
|
||||
/// children only within their mask). A group is what its children show, grown by its effects.
|
||||
/// children only within their mask). A group, or an unclipped frame past its box, is what its
|
||||
/// children show grown by its effects, which apply to all of it.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
self.render_bounds_at(id, 0)
|
||||
}
|
||||
@@ -751,7 +752,8 @@ impl Document {
|
||||
}
|
||||
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
(Some(kids), _) => own.union(kids),
|
||||
// An unclipped frame's effects apply to its children too, past its box included.
|
||||
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
|
||||
(None, _) => own,
|
||||
})
|
||||
}
|
||||
|
||||
+39
-8
@@ -828,8 +828,25 @@ impl Node {
|
||||
|
||||
/// Maximum distance (in local units) painting can reach outside the node box.
|
||||
pub fn paint_reach(&self) -> f64 {
|
||||
let fx = self.effects.iter().map(Effect::reach).fold(0.0, f64::max);
|
||||
self.outline_reach() + fx + self.spread_miter_reach()
|
||||
self.outline_reach() + self.effects_reach() + self.spread_miter_reach()
|
||||
}
|
||||
|
||||
/// How far (local units) this node's effects paint past what they apply to: the layer effects
|
||||
/// together (see [`Node::layer_effects_reach`]), then the largest of the others.
|
||||
pub fn effects_reach(&self) -> f64 {
|
||||
let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max);
|
||||
self.layer_effects_reach() + fx
|
||||
}
|
||||
|
||||
/// How far the layer blurs and texture move the layer's pixels together: they apply one after
|
||||
/// another, each moving what the one before it moved. Blurs one after another spread as one
|
||||
/// blur whose radius squared is the sum of theirs (Gaussians add their variances); texture
|
||||
/// scatters by up to its radius, added on. Drop shadows are cast from what they leave.
|
||||
pub fn layer_effects_reach(&self) -> f64 {
|
||||
let moving = || self.effects.iter().filter(|e| e.moves_the_layer());
|
||||
let blurs: f64 = moving().filter(|e| matches!(e.kind, EffectKind::LayerBlur(_))).map(|e| effect_reach(e.reach()).powi(2)).sum();
|
||||
let texture: f64 = moving().filter(|e| matches!(e.kind, EffectKind::Texture(_))).map(|e| effect_reach(e.reach())).sum();
|
||||
effect_reach(blurs.sqrt()) + texture
|
||||
}
|
||||
|
||||
/// The page-space area this node paints itself under `world` (its children aside): its box
|
||||
@@ -842,18 +859,23 @@ impl Node {
|
||||
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
|
||||
/// round, drop shadows towards their offset (in page units, as the renderer draws them), so a
|
||||
/// shadow falling down adds room below and none above.
|
||||
/// Layer blurs and texture grow it by what they reach together, and drop shadows are cast from
|
||||
/// that (see [`Node::layer_effects_reach`]).
|
||||
pub fn grow_by_effects(&self, b: figcraft_geom::Rect) -> figcraft_geom::Rect {
|
||||
let miter = self.spread_miter_reach();
|
||||
self.effects.iter().filter(|e| e.visible).fold(b, |r, e| match &e.kind {
|
||||
let layer = self.layer_effects_reach();
|
||||
let moved = if layer > 0.0 { b.inflate(layer, layer) } else { b };
|
||||
self.effects.iter().filter(|e| e.visible).fold(moved, |r, e| match &e.kind {
|
||||
EffectKind::DropShadow(s) => {
|
||||
let g = s.radius.abs() * 1.5 + s.spread.max(0.0) + miter;
|
||||
let (dx, dy) = s.offset;
|
||||
if g.is_finite() && dx.is_finite() && dy.is_finite() { r.union(b.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy)) } else { r }
|
||||
}
|
||||
_ => {
|
||||
let g = e.reach();
|
||||
if g.is_finite() && g > 0.0 { r.union(b.inflate(g, g)) } else { r }
|
||||
if g.is_finite() && dx.is_finite() && dy.is_finite() {
|
||||
r.union(moved.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy))
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
_ => r,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -899,3 +921,12 @@ impl Node {
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
}
|
||||
|
||||
/// The most one effect's reach counts for (document units) when reaches are added up: far past any
|
||||
/// canvas, and a sum of any number of them (or of their squares) stays finite.
|
||||
const MAX_EFFECT_REACH: f64 = 1e100;
|
||||
|
||||
/// An effect's reach made safe to add: finite, in 0..[`MAX_EFFECT_REACH`].
|
||||
fn effect_reach(r: f64) -> f64 {
|
||||
if r.is_finite() { r.clamp(0.0, MAX_EFFECT_REACH) } else { 0.0 }
|
||||
}
|
||||
@@ -869,6 +869,11 @@ impl Effect {
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
/// Whether this is a visible layer blur or texture: what moves the layer's own pixels, one
|
||||
/// after another in list order.
|
||||
pub fn moves_the_layer(&self) -> bool {
|
||||
self.visible && matches!(self.kind, EffectKind::LayerBlur(_) | EffectKind::Texture(_))
|
||||
}
|
||||
/// A layer or background blur's settings.
|
||||
pub fn blur(&self) -> Option<&Blur> {
|
||||
match &self.kind {
|
||||
|
||||
+30
-4
@@ -150,8 +150,12 @@ impl TextCase {
|
||||
} else {
|
||||
out.push(ch);
|
||||
// An apostrophe only continues a word right after a letter or digit.
|
||||
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
|
||||
start = !is_word_char(ch) || opening_apostrophe;
|
||||
let opening_apostrophe =
|
||||
matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c));
|
||||
// A combining mark attaches to the char before it and never changes word state.
|
||||
if !is_combining_mark(ch) {
|
||||
start = !is_word_char(ch) || opening_apostrophe;
|
||||
}
|
||||
}
|
||||
last = Some(ch);
|
||||
}
|
||||
@@ -166,9 +170,23 @@ impl TextCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Letters, digits and apostrophes continue a word (so "don't" stays "Don't").
|
||||
/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't"
|
||||
/// and decomposed accents stay inside their word).
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '\'' | '\u{2019}')
|
||||
c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}')
|
||||
}
|
||||
|
||||
/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that
|
||||
/// extend the grapheme of the char before them.
|
||||
fn is_combining_mark(c: char) -> bool {
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
if c.is_ascii() || c.is_alphanumeric() {
|
||||
return false;
|
||||
}
|
||||
let mut buf = [0u8; 8];
|
||||
buf[0] = b'a';
|
||||
let end = 1 + c.encode_utf8(&mut buf[1..]).len();
|
||||
std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
@@ -969,4 +987,12 @@ mod title_case_tests {
|
||||
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
|
||||
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_case_combining_marks_do_not_break_words() {
|
||||
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}");
|
||||
assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon");
|
||||
assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B");
|
||||
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Moving an auto layout frame (#1571): a frame that only moves keeps the layers inside it where
|
||||
//! they are, so a drag doesn't lay its contents out again on every pointer move; a change that can
|
||||
//! move or resize them still does, and its parent's layout still follows it.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
/// Two rectangles wrapped in an auto layout frame: the session, the frame and the rectangles.
|
||||
fn stack() -> (Session, NodeId, [NodeId; 2]) {
|
||||
let mut s = Session::new();
|
||||
let mut make = |y| id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": y, "width": 100, "height": 40})).unwrap()["id"]).unwrap();
|
||||
let kids = [make(0), make(60)];
|
||||
s.execute("select.set", &json!({"ids": kids})).unwrap();
|
||||
s.execute("layout.add", &json!({})).unwrap();
|
||||
let frame = s.doc().unwrap().node(kids[0]).and_then(|n| n.parent).unwrap();
|
||||
(s, frame, kids)
|
||||
}
|
||||
|
||||
fn local_y(s: &Session, id: NodeId) -> f64 {
|
||||
s.doc().unwrap().node(id).unwrap().transform.translation().y
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_an_auto_layout_frame_leaves_its_contents_alone() {
|
||||
let (mut s, frame, [_, second]) = stack();
|
||||
assert!(s.doc().unwrap().node(frame).unwrap().layout.is_some());
|
||||
let laid_out = local_y(&s, second);
|
||||
// Put the second rectangle out of place without anything noticing (as if the layout had
|
||||
// never run on it), then move the frame: its contents aren't laid out again.
|
||||
{
|
||||
let doc = s.ds_mut().unwrap().doc_mut();
|
||||
doc.node_mut(second).unwrap().transform = figcraft_doc::geom::Affine::translate((0.0, laid_out + 500.0));
|
||||
doc.take_dirty();
|
||||
}
|
||||
s.execute("node.set", &json!({"ids": [frame.0], "props": {"x": 300, "y": 200}})).unwrap();
|
||||
assert_eq!(local_y(&s, second), laid_out + 500.0, "a move doesn't lay the frame out");
|
||||
// A change that can move its contents does.
|
||||
s.execute("node.set", &json!({"ids": [frame.0], "props": {"width": 160}})).unwrap();
|
||||
assert_eq!(local_y(&s, second), laid_out, "a resize lays it out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_moved_inside_auto_layout_still_moves_its_siblings() {
|
||||
// A stack and a rectangle side by side in an outer auto layout frame: the outer layout still
|
||||
// follows when the stack grows.
|
||||
let (mut s, inner, [first, _]) = stack();
|
||||
let other = id_of(&s.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 100, "height": 40})).unwrap()["id"]).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [inner.0, other.0]})).unwrap();
|
||||
s.execute("layout.add", &json!({})).unwrap();
|
||||
let outer = s.doc().unwrap().node(inner).and_then(|n| n.parent).unwrap();
|
||||
assert!(s.doc().unwrap().node(outer).unwrap().layout.is_some());
|
||||
let before = s.doc().unwrap().world_bounds(other).unwrap();
|
||||
s.execute("node.set", &json!({"ids": [first.0], "props": {"width": 200}})).unwrap();
|
||||
let after = s.doc().unwrap().world_bounds(other).unwrap();
|
||||
assert!(after.x0 > before.x0, "the outer layout followed the inner frame's growth: {before:?} {after:?}");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
//! A boolean group past the path operations' caps: it stays a boolean group, flattening it is
|
||||
//! refused with the reason, and the document is unchanged.
|
||||
|
||||
use figcraft_doc::{NodeKind, VectorNetwork};
|
||||
use figcraft_geom::kurbo::{BezPath, Rect, Shape};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
|
||||
/// `n` long thin bars, across or down: 750 of each cross 2.25 million times, past the caps.
|
||||
fn bars(n: usize, across: bool) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for i in 0..n {
|
||||
let (at, len) = (i as f64 * 4.0, n as f64 * 4.0);
|
||||
let r = if across { Rect::new(0.0, at, len, at + 2.0) } else { Rect::new(at, 0.0, at + 2.0, len) };
|
||||
p.extend(r.path_elements(0.1));
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flattening_a_boolean_past_the_caps_is_refused() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let ids: Vec<_> = [true, false]
|
||||
.into_iter()
|
||||
.map(|across| {
|
||||
let network = VectorNetwork::from_path(&bars(750, across));
|
||||
s.doc_mut().unwrap().create(page, NodeKind::Vector { network }, "Bars", 0.0, 0.0, 3000.0, 3000.0).unwrap()
|
||||
})
|
||||
.collect();
|
||||
let g = id_of(&s.execute("bool.union", &json!({"ids": ids})).unwrap()["id"]).unwrap();
|
||||
let e = s.execute("vector.flatten", &json!({"ids": [g]})).unwrap_err().to_string();
|
||||
assert!(e.contains("too complex to combine"), "{e}");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(matches!(doc.node(g).map(|n| &n.kind), Some(NodeKind::BooleanOp { .. })), "the group is kept");
|
||||
assert_eq!(doc.node(g).map(|n| n.children.len()), Some(2));
|
||||
}
|
||||
@@ -557,6 +557,9 @@ pub struct ToolState {
|
||||
pub dev_mode: bool,
|
||||
/// Pen snap targets, kept while hovering (see `vectoredit::point_snap`).
|
||||
pub(crate) pen_targets: Option<crate::vectoredit::point_snap::PenTargets>,
|
||||
/// Vector edit mode's pen: where the last press that started something new (no path being
|
||||
/// drawn) was, and the undo depth before it, so a double-click there takes that edit back.
|
||||
pub(crate) pen_press: Option<(Point, usize)>,
|
||||
/// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the
|
||||
/// selection box that stays put (`tool.scaleAnchor`).
|
||||
pub scale_anchor: (u8, u8),
|
||||
|
||||
@@ -26,7 +26,7 @@ impl Session {
|
||||
let ds = self.ds()?;
|
||||
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
|
||||
if self.tool.tool == ToolId::Pen {
|
||||
return self.pen_finish();
|
||||
return self.pen_double_click(ev);
|
||||
}
|
||||
let edits = !self.tool.dev_mode;
|
||||
// A frame's or section's name label: rename it there (the canvas puts a field over it).
|
||||
|
||||
@@ -8,6 +8,11 @@ impl Session {
|
||||
if self.tool.dev_mode {
|
||||
return Ok(false);
|
||||
}
|
||||
// Enter while the pen draws ends the path and edits it with the Move tool (#1430).
|
||||
if name == "Enter" && !alt && !cmd && self.tool.pen.is_some() {
|
||||
self.pen_enter()?;
|
||||
return Ok(true);
|
||||
}
|
||||
if self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
|
||||
return crate::vectoredit::key(self, name, shift, alt, cmd);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use super::{
|
||||
Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2,
|
||||
VectorNetwork, at_45_degrees, frame_at, json, pen_handles,
|
||||
};
|
||||
use crate::vectoredit::VTool;
|
||||
|
||||
/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most
|
||||
/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no
|
||||
@@ -84,7 +85,7 @@ impl Session {
|
||||
if self.tool.pen.is_none()
|
||||
&& let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6))
|
||||
{
|
||||
return self.pen_edit(id, Some(v));
|
||||
return self.edit_vector_with(id, VTool::Pen, Some(v));
|
||||
}
|
||||
let pen = self.tool.pen.get_or_insert_with(PenPath::default);
|
||||
// Clicking the first anchor closes the path.
|
||||
@@ -103,6 +104,10 @@ impl Session {
|
||||
self.pen_update()?;
|
||||
return self.pen_finish();
|
||||
}
|
||||
// A press on the last anchor (a double-click's second press) adds no point on top of it.
|
||||
if pen.points.last().is_some_and(|(q, _, _)| near(q)) {
|
||||
return Ok(());
|
||||
}
|
||||
if pen.parent.is_none() {
|
||||
pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page));
|
||||
}
|
||||
@@ -200,29 +205,59 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting
|
||||
/// a new path in the same layer at the next click).
|
||||
fn pen_edit(&mut self, id: NodeId, anchor: Option<usize>) -> Result<()> {
|
||||
/// Edit `id` as a vector with `tool` (the pen going on from point `anchor`, or, without one,
|
||||
/// starting a new path in the same layer at the next click).
|
||||
fn edit_vector_with(&mut self, id: NodeId, tool: VTool, anchor: Option<usize>) -> Result<()> {
|
||||
self.set_tool(ToolId::Move);
|
||||
crate::vectoredit::begin(self, id)?;
|
||||
crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?;
|
||||
crate::vectoredit::set_tool(self, tool)?;
|
||||
crate::vectoredit::set_state(self, |e| {
|
||||
e.anchor = anchor;
|
||||
e.vertices = anchor.into_iter().collect();
|
||||
})
|
||||
}
|
||||
|
||||
/// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the
|
||||
/// pen, so the next path joins the same vector (another Escape leaves edit mode).
|
||||
/// Escape while the pen draws: the path ends but its layer stays in edit mode with the pen, so
|
||||
/// the next path joins the same vector (another Escape leaves edit mode).
|
||||
pub fn pen_end(&mut self) -> Result<()> {
|
||||
self.end_pen_path(VTool::Pen)
|
||||
}
|
||||
|
||||
/// Enter (or a double-click) while the pen draws: the path ends and its layer is edited with
|
||||
/// the Move tool, its points and handles selectable.
|
||||
pub fn pen_enter(&mut self) -> Result<()> {
|
||||
self.end_pen_path(VTool::Move)
|
||||
}
|
||||
|
||||
/// End the pen's path and edit its layer with `tool` (nothing to edit before a segment).
|
||||
fn end_pen_path(&mut self, tool: VTool) -> Result<()> {
|
||||
let id = self.tool.pen.as_ref().and_then(|p| p.id);
|
||||
self.pen_finish()?;
|
||||
match id {
|
||||
Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None),
|
||||
Some(id) if self.doc()?.contains(id) => self.edit_vector_with(id, tool, None),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click with the pen (#1430). While a path is drawn it ends there, as Enter does.
|
||||
/// Otherwise the double-click only started a path (its second press adds nothing): that is
|
||||
/// dropped, and the layer under the pointer is edited with the Move tool, as a double-click on
|
||||
/// it with the Move tool would.
|
||||
pub(super) fn pen_double_click(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
if self.tool.pen.as_ref().is_some_and(|p| p.id.is_some()) {
|
||||
return self.pen_enter();
|
||||
}
|
||||
self.pen_finish()?;
|
||||
let (doc, page) = {
|
||||
let ds = self.ds()?;
|
||||
(ds.doc.clone(), ds.page)
|
||||
};
|
||||
match self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|id| crate::vectoredit::enter_targets(&doc, &[*id]).is_some()) {
|
||||
Some(id) => self.edit_vector_with(id, VTool::Move, None),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch).
|
||||
pub fn pen_finish(&mut self) -> Result<()> {
|
||||
let pen = self.tool.pen.take();
|
||||
|
||||
@@ -741,13 +741,10 @@ pub fn key(s: &mut Session, name: &str, shift: bool, alt: bool, cmd: bool) -> Re
|
||||
}
|
||||
match (name, shift, alt, cmd) {
|
||||
("Escape", ..) => escape(s)?,
|
||||
("Enter", ..) => {
|
||||
if ve.anchor.is_some() {
|
||||
escape(s)?;
|
||||
} else {
|
||||
end(s)?;
|
||||
}
|
||||
}
|
||||
// With the pen, Enter ends its path and goes on editing with the Move tool (#1430); the next
|
||||
// Enter leaves edit mode.
|
||||
("Enter", ..) if ve.tool == VTool::Pen => set_tool(s, VTool::Move)?,
|
||||
("Enter", ..) => end(s)?,
|
||||
("Backspace" | "Delete", false, false, false) if ve.tool == VTool::Width => {
|
||||
// The selected width point (nothing else is selected with the Variable width tool).
|
||||
if let Some(i) = ve.width_point {
|
||||
|
||||
@@ -205,6 +205,7 @@ impl Session {
|
||||
PointerKind::Down => self.vector_down(&ve, ev),
|
||||
PointerKind::Move => self.vector_drag(&ve, ev).map(|()| true),
|
||||
PointerKind::Up => self.vector_up(&ve, ev).map(|()| true),
|
||||
PointerKind::DoubleClick if ve.tool == VTool::Pen => self.pen_double_click_editing(ve.id, ev).map(|()| true),
|
||||
PointerKind::DoubleClick => {
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
let doc = self.doc()?.clone();
|
||||
@@ -218,6 +219,21 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// A double-click with the pen in edit mode (#1430): the path ends and the layer `id` goes on
|
||||
/// being edited with the Move tool. When the double-click began something new (a point split
|
||||
/// into a segment, or a new path's first point), that edit of its first press is taken back.
|
||||
fn pen_double_click_editing(&mut self, id: NodeId, ev: &PointerEvent) -> Result<()> {
|
||||
if let Some((at, depth)) = self.tool.pen_press.take()
|
||||
&& (at - ev.p()).hypot() * ev.zoom < 8.0
|
||||
{
|
||||
while self.undo_depth().0 > depth && self.undo()? {}
|
||||
}
|
||||
if editing(self).is_err() {
|
||||
super::begin(self, id)?;
|
||||
}
|
||||
super::set_tool(self, VTool::Move)
|
||||
}
|
||||
|
||||
fn vgesture(&mut self, g: VGesture) {
|
||||
self.tool.gesture = Gesture::Vector(g);
|
||||
}
|
||||
@@ -743,6 +759,7 @@ impl Session {
|
||||
VTool::Pen => {
|
||||
let at = if h.is_none() && !ev.shift { self.snap_pen(ev, Some(id), &[])?.0 } else { ev.p() };
|
||||
let Some(plan) = self.pen_plan(ve, h, w.inverse() * at, ev.shift) else { return Ok(true) };
|
||||
self.tool.pen_press = ve.anchor.is_none().then(|| (ev.p(), self.undo_depth().0));
|
||||
self.begin_interaction("Pen")?;
|
||||
let out = ve.pen_out;
|
||||
let mut v = None;
|
||||
|
||||
@@ -65,6 +65,9 @@ struct Registry {
|
||||
index: HashMap<&'static str, usize>,
|
||||
/// The parameter names each command's doc lists (`list` order; see [`doc_param_names`]).
|
||||
names: Vec<Option<Vec<&'static str>>>,
|
||||
/// Whether each command's doc lists a parameter it can't run without (`list` order; see
|
||||
/// [`doc_needs_params`]).
|
||||
needs: Vec<bool>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Registry {
|
||||
@@ -127,7 +130,8 @@ fn registry() -> &'static Registry {
|
||||
}
|
||||
}
|
||||
let names = list.iter().map(|s| doc_param_names(s.params)).collect();
|
||||
Registry { list, index, names }
|
||||
let needs = list.iter().map(|s| doc_needs_params(s.params)).collect();
|
||||
Registry { list, index, names, needs }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -147,6 +151,25 @@ pub fn param_names(id: &str) -> Option<&'static [&'static str]> {
|
||||
r.index.get(id).and_then(|i| r.names.get(*i)).and_then(|n| n.as_deref())
|
||||
}
|
||||
|
||||
/// Whether command `id` can't run without parameters: its doc lists one that isn't optional (no
|
||||
/// `?`), so a call with none is refused (`image.place` needs `data` or `path`). A UI that runs
|
||||
/// commands without parameters (the Actions palette) leaves these out. `false` for an unknown id
|
||||
/// or a doc that isn't a `{…}` list.
|
||||
pub fn needs_params(id: &str) -> bool {
|
||||
let r = registry();
|
||||
r.index.get(id).and_then(|i| r.needs.get(*i)).is_some_and(|n| *n)
|
||||
}
|
||||
|
||||
/// Whether a `{…}` params doc lists an item whose name has no `?` (`{data (base64) | path, x?}`:
|
||||
/// yes; `{ids?, direction? (h|v)}`, `{}` or a doc of another form: no).
|
||||
fn doc_needs_params(doc: &'static str) -> bool {
|
||||
let required = |item: &str| {
|
||||
let first = item.trim().split(|c: char| c.is_whitespace() || c == '(').next().unwrap_or_default();
|
||||
first.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') && !first.contains('?')
|
||||
};
|
||||
doc_items(doc).is_some_and(|items| items.into_iter().any(required))
|
||||
}
|
||||
|
||||
/// Refuse params naming something the command doesn't take: a misspelt name would otherwise be
|
||||
/// ignored and the command answer as if it hadn't been given. `ids` and `id` are always taken:
|
||||
/// the layers a command acts on (`params::targets` reads either).
|
||||
|
||||
@@ -332,17 +332,79 @@ fn mentionable<'a>(people: &'a [String], user: &'a str) -> Vec<&'a str> {
|
||||
people.iter().map(String::as_str).chain([user]).collect()
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
for (r, who) in mentions(&m.text, people) {
|
||||
let name = who.or_else(|| m.text.get(r.start + 1..r.end)).unwrap_or_default();
|
||||
let v = json!({"name": name, "known": who.is_some(), "you": who.is_some_and(|w| w.eq_ignore_ascii_case(user))});
|
||||
/// The names `text` @mentions by the rule the thread view highlights them with
|
||||
/// ([`figcraft_doc::mentions`]), and whether each is one of `people` (else it is the letters and
|
||||
/// digits after the `@`).
|
||||
fn mentioned<'a>(text: &'a str, people: &[&'a str]) -> impl Iterator<Item = (&'a str, bool)> {
|
||||
mentions(text, people).into_iter().map(move |(r, who)| (who.or_else(|| text.get(r.start + 1..r.end)).unwrap_or_default(), who.is_some()))
|
||||
}
|
||||
|
||||
/// Whether a message of `c` @mentions `name` (any case), as `comment.list` reports mentions;
|
||||
/// `name` resolves like one of `people`, so a name nobody in the file has can be several words.
|
||||
fn thread_mentions(c: &Comment, people: &[&str], name: &str) -> bool {
|
||||
let people: Vec<&str> = people.iter().copied().chain([name]).collect();
|
||||
c.messages().any(|m| mentioned(&m.text, &people).any(|(n, _)| n.eq_ignore_ascii_case(name)))
|
||||
}
|
||||
|
||||
/// A list's `mentions` filter: a name (a leading `@` is fine), or none.
|
||||
fn mentions_param(cmd: &str, p: &Value) -> crate::Result<Option<String>> {
|
||||
match get(p, "mentions") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(s)) if !s.trim().trim_start_matches('@').trim().is_empty() => {
|
||||
Ok(Some(s.trim().trim_start_matches('@').trim().to_string()))
|
||||
}
|
||||
Some(_) => Err(bad(cmd, "`mentions` must be a name")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threads a list's params pick (`list_params`, `frame`, `mentions`), in list order, as
|
||||
/// `who` sees them; mentions resolve against `people` (`mentionable`).
|
||||
fn listed<'s>(cmd: &str, s: &'s Session, p: &Value, who: &str, people: &[&str]) -> crate::Result<Vec<&'s Comment>> {
|
||||
let (page, filter, sort, text) = list_params(cmd, s, p)?;
|
||||
let want = mentions_param(cmd, p)?;
|
||||
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
|
||||
Ok(query(s.doc()?, &q, who).into_iter().filter(|c| want.as_deref().is_none_or(|w| thread_mentions(c, people, w))).collect())
|
||||
}
|
||||
|
||||
/// Add the names `text` @mentions to `named` as `{name, known, you}`, each once.
|
||||
fn push_mentions(named: &mut Vec<Value>, text: &str, people: &[&str], user: &str) {
|
||||
for (name, known) in mentioned(text, people) {
|
||||
let v = json!({"name": name, "known": known, "you": known && name.eq_ignore_ascii_case(user)});
|
||||
if !named.contains(&v) {
|
||||
named.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The most mentions `comment.prompt` lists for one thread (its list stays bounded).
|
||||
const PROMPT_MAX_MENTIONS: usize = 32;
|
||||
|
||||
/// `comment.people`: everyone who wrote or reacted in a comment, and every name a message
|
||||
/// @mentions (as `comment.list` reports it), once each in any case, sorted as
|
||||
/// `Document::comment_people` sorts.
|
||||
fn people_and_mentioned(doc: &Document, you: &str) -> Vec<String> {
|
||||
let people = doc.comment_people();
|
||||
let resolve = mentionable(&people, you);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let names = people
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.chain(doc.comments.iter().flat_map(Comment::messages).flat_map(|m| mentioned(&m.text, &resolve).map(|(n, _)| n)));
|
||||
for name in names {
|
||||
if seen.insert(name.to_lowercase()) {
|
||||
out.push(name.to_string());
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
|
||||
out
|
||||
}
|
||||
|
||||
/// A message as JSON, with the names it @mentions: `{name, known, you}` each, resolved against
|
||||
/// `people` (`mentionable`); `known: false` is a name nobody in the file has.
|
||||
fn message_json(m: &Message, people: &[&str], user: &str) -> Value {
|
||||
let mut named: Vec<Value> = Vec::new();
|
||||
push_mentions(&mut named, &m.text, people, user);
|
||||
json!({
|
||||
"author": m.author, "agent": m.agent, "text": m.text, "created": m.created, "edited": m.edited,
|
||||
"reactions": m.reactions.iter().map(|r| json!({"emoji": r.emoji, "users": r.users})).collect::<Vec<_>>(),
|
||||
@@ -371,9 +433,23 @@ pub fn comment_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) ->
|
||||
|
||||
/// Bounded metadata for `comment.prompt`'s `comments` list. Full messages and replies stay in
|
||||
/// `comment.list`; a prompt over thousands of threads must not repeat all of them here.
|
||||
fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
fn comment_brief_json(doc: &Document, c: &Comment, user: &str, people: &[&str]) -> Value {
|
||||
let (x, y) = c.position(doc);
|
||||
let frame = frame_of(doc, c);
|
||||
// The names the whole thread @mentions, as `comment.list` reports them per message.
|
||||
let mut named = Vec::new();
|
||||
for m in c.messages() {
|
||||
push_mentions(&mut named, &m.text, people, user);
|
||||
if named.len() >= PROMPT_MAX_MENTIONS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
named.truncate(PROMPT_MAX_MENTIONS);
|
||||
for v in &mut named {
|
||||
if let Some(name) = v.get("name").and_then(Value::as_str).map(|n| clip_chars(n, 80)) {
|
||||
v["name"] = json!(name);
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"id": c.id, "page": c.page,
|
||||
"pageName": doc.node(c.page).map(|n| clip_chars(&n.name, 80)),
|
||||
@@ -381,7 +457,7 @@ fn comment_brief_json(doc: &Document, c: &Comment, user: &str) -> Value {
|
||||
"frame": frame, "frameName": frame.and_then(|f| doc.node(f)).map(|n| clip_chars(&n.name, 80)),
|
||||
"resolved": c.resolved, "unread": c.is_unread(user), "link": link(c.id),
|
||||
"author": clip_chars(&c.first.author, 80), "agent": c.first.agent, "preview": clip_chars(&c.first.text, 160),
|
||||
"replies": c.replies.len(),
|
||||
"replies": c.replies.len(), "mentions": named,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -760,15 +836,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"comment.next",
|
||||
"Next comment",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved? (the list, as comment.list), author?}",
|
||||
"{id? (the thread to step from; omitted: the first or last), dir? (1 next, -1 previous; default 1), page?, filter?, sort?, query?, frame?, resolved?, mentions? (the list, as comment.list), author?} → the thread, as comment.list lists it",
|
||||
false,
|
||||
|s, p| {
|
||||
let dir = num(p, "dir").unwrap_or(1.0);
|
||||
let doc = s.doc()?;
|
||||
let who = author(s, p);
|
||||
let (page, filter, sort, text) = list_params("comment.next", s, p)?;
|
||||
let ids: Vec<u64> =
|
||||
query(doc, &Query { page, frame: id(p, "frame"), filter, sort, text: &text }, &who).iter().map(|c| c.id).collect();
|
||||
let people = s.doc()?.comment_people();
|
||||
let ids: Vec<u64> = listed("comment.next", s, p, &who, &mentionable(&people, &who))?.iter().map(|c| c.id).collect();
|
||||
let at = id(p, "id").and_then(|i| ids.iter().position(|x| *x == i.0));
|
||||
let next = match at {
|
||||
Some(i) if dir < 0.0 => ids.get(i.saturating_sub(1)),
|
||||
@@ -801,22 +875,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let id = req_comment("comment.link", s, p)?;
|
||||
Ok(json!({"link": link(id)}))
|
||||
}),
|
||||
spec!("comment.people", "People in comments", "{}", false, |s, _| {
|
||||
Ok(json!({"people": s.doc()?.comment_people(), "you": acting_name(s)}))
|
||||
}),
|
||||
spec!(
|
||||
"comment.people",
|
||||
"People in comments",
|
||||
"{} → {people (everyone who wrote or reacted in a comment, and every name a message @mentions), you}",
|
||||
false,
|
||||
|s, _| {
|
||||
let you = acting_name(s);
|
||||
Ok(json!({"people": people_and_mentioned(s.doc()?, &you), "you": you}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"comment.list",
|
||||
"List comments",
|
||||
"{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state)}",
|
||||
"{page? (default: current; \"all\" for every page), filter? (all|open|resolved|mine|unread; default all), sort? (date|page; default date), query? (search text), frame? (a top-level frame id), resolved? (false hides resolved ones), author? (whose threads and read state), mentions? (a name, any case: only threads where a message @mentions it, as each message's `mentions` reports)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let doc = s.doc()?;
|
||||
let (page, filter, sort, text) = list_params("comment.list", s, p)?;
|
||||
let who = author(s, p);
|
||||
let q = Query { page, frame: id(p, "frame"), filter, sort, text: &text };
|
||||
let people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let list: Vec<Value> = query(doc, &q, &who).into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
let list: Vec<Value> =
|
||||
listed("comment.list", s, p, &who, &people)?.into_iter().map(|c| comment_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"comments": list}))
|
||||
}
|
||||
),
|
||||
@@ -877,7 +957,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
};
|
||||
let count = selected.len();
|
||||
let (text, truncated) = prompt_text(doc, path.as_deref(), &selected);
|
||||
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who)).collect();
|
||||
let people = doc.comment_people();
|
||||
let people = mentionable(&people, &who);
|
||||
let comments: Vec<Value> = selected.iter().take(PROMPT_MAX_THREADS).map(|c| comment_brief_json(doc, c, &who, &people)).collect();
|
||||
Ok(json!({"count": count, "truncated": truncated, "text": text, "comments": comments}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -33,7 +33,7 @@ use load::font_report;
|
||||
#[cfg(test)]
|
||||
pub(crate) use load::{installed_family, sketch_line};
|
||||
pub use load::{load, load_bytes, load_path};
|
||||
pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write};
|
||||
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
@@ -181,11 +181,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
"{path} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
|
||||
"{path | data (base64: the copy's bytes, as the browser keeps them)} open a recovery copy (written while the app runs, read back after a crash) as a document with unsaved changes and no path: saving asks where → {index}",
|
||||
false,
|
||||
|s, p| {
|
||||
let path = req_string("file.recover", p, "path")?;
|
||||
s.docs.push(recovered(&path)?);
|
||||
let ds = match string(p, "data") {
|
||||
Some(d) => recovered_from(
|
||||
&base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.recover", format!("`data`: {e}")))?,
|
||||
)?,
|
||||
None => recovered(&req_string("file.recover", p, "path")?)?,
|
||||
};
|
||||
s.docs.push(ds);
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,13 @@ use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Resul
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?;
|
||||
recovered_from(&read(path)?)
|
||||
}
|
||||
|
||||
/// A recovery copy's bytes as a document with unsaved changes and no path (the browser keeps its
|
||||
/// copies in the page's storage, not in files).
|
||||
pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
|
||||
let loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
|
||||
}
|
||||
|
||||
@@ -693,6 +699,10 @@ pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result<Option<Value>> {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?,
|
||||
// Capped like the sibling file; the browser has no file system, which `read` says.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Some(Value::String(path)) => parse(&read_capped(path, MAX_VARIABLES_FILE)?)?,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Some(Value::String(path)) => parse(&read(path)?)?,
|
||||
Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")),
|
||||
};
|
||||
|
||||
@@ -183,7 +183,7 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
"figcraft" | "json" | "fig" | "sketch" | "penpot" => {
|
||||
"figcraft" | "json" | "fig" | "jam" | "sketch" | "penpot" => {
|
||||
let resolver = path.as_deref().map(rest_images);
|
||||
let images: figcraft_format::rest::ImageResolver = match &resolver {
|
||||
Some(r) => r,
|
||||
|
||||
@@ -475,13 +475,7 @@ fn collect_flatten_parts(
|
||||
part
|
||||
}
|
||||
_ => {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else { return Ok(()) };
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
let Some(part) = outline_part(doc, node)? else { return Ok(()) };
|
||||
part
|
||||
}
|
||||
};
|
||||
@@ -490,6 +484,25 @@ fn collect_flatten_parts(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a layer other than a vector or a container flattens into: its outline (`None`: it has
|
||||
/// none). A boolean group has no outline only past the path operations' caps; flattening it would
|
||||
/// drop it, so that is refused.
|
||||
fn outline_part(doc: &Document, node: &Node) -> Result<Option<VectorNetwork>> {
|
||||
let Some((path, rule)) = figcraft_render::node_outline(doc, node) else {
|
||||
if matches!(node.kind, NodeKind::BooleanOp { .. }) {
|
||||
return Err(bad("vector.flatten", figcraft_pathops::PathOpsError::TooComplex.to_string()));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
let mut part = VectorNetwork::from_path(&path);
|
||||
for region in &mut part.regions {
|
||||
if rule == figcraft_render::FillRule::EvenOdd {
|
||||
region.winding = WindingRule::EvenOdd;
|
||||
}
|
||||
}
|
||||
Ok(Some(part))
|
||||
}
|
||||
|
||||
fn append_flatten_part(
|
||||
node: &Node,
|
||||
fallback_fills: Option<&[Paint]>,
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path to a regular file of at most 256 MiB; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -201,8 +201,9 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
}
|
||||
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only, and
|
||||
// commenting with a comment link.
|
||||
if let Some(why) = s.active_read_only().filter(|why| !crate::live::tool_left(why, t)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
}
|
||||
s.set_tool(t);
|
||||
@@ -369,6 +370,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.renameDuplicates", "Rename duplicated layers", "{on?}", false, |s, p| pref(s, p, |x| &mut x.rename_duplicates)),
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!(
|
||||
"pref.scrollWheelZoom",
|
||||
"Scroll wheel zoom",
|
||||
"{on?} a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls it (Shift+wheel still scrolls sideways; trackpads are unchanged) → {on}",
|
||||
false,
|
||||
|s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom)
|
||||
),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A server file joined with a comment link, against a scripted link: comments (new threads,
|
||||
//! replies, resolving, reactions) are made and sync through the server; every other edit, the
|
||||
//! editing tools and gestures are refused as with a view link, and a change made outside a command
|
||||
//! is put back unless it only touches comment threads.
|
||||
|
||||
use figcraft_collab::message::ClientMsg;
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::live::{Access, COMMENT_ONLY, Event, Role, tool_left};
|
||||
use crate::live_tests::{info, push, relay, serve_as, session};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn refused(s: &mut Session, cmd: &str, params: serde_json::Value) {
|
||||
match s.execute(cmd, ¶ms) {
|
||||
Err(EngineError::Disabled(_, reason)) => assert_eq!(reason, COMMENT_ONLY, "{cmd}"),
|
||||
other => panic!("{cmd}: expected a refusal, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_link_lets_you_comment_and_nothing_else() {
|
||||
let (mut s, script) = session();
|
||||
s.execute("tool.set", &json!({"tool": "rectangle"})).unwrap();
|
||||
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
|
||||
let mut doc = Document::new("Shared");
|
||||
let page = doc.first_page().unwrap();
|
||||
let card = doc.create(page, NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (mut server, id, welcome) = serve_as(&doc, Access::Comment);
|
||||
let mut me = info(Role::Guest, id);
|
||||
me.access = Access::Comment;
|
||||
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, welcome]);
|
||||
s.live_poll();
|
||||
assert!(s.live().syncing());
|
||||
assert_eq!(s.active_read_only(), Some(COMMENT_ONLY));
|
||||
let summary = s.live_summary();
|
||||
assert_eq!((summary["access"].as_str(), summary["readOnly"].as_bool()), (Some("comment"), Some(true)));
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "the editing tool was put away");
|
||||
// The comment tool is left, and commenting syncs through the server.
|
||||
s.execute("tool.set", &json!({"tool": "comment"})).unwrap();
|
||||
let thread = s.execute("comment.add", &json!({"x": 5, "y": 5, "text": "Bigger logo?"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("comment.reply", &json!({"id": thread, "text": "Or darker"})).unwrap();
|
||||
s.execute("comment.react", &json!({"id": thread, "emoji": "👍"})).unwrap();
|
||||
s.execute("comment.resolve", &json!({"id": thread})).unwrap();
|
||||
let answers = relay(&script, &mut server, id);
|
||||
let on_server = server.document().comment(thread).expect("the thread is on the server");
|
||||
assert!(on_server.resolved && on_server.replies.len() == 1 && !on_server.first.reactions.is_empty());
|
||||
push(&script, answers);
|
||||
s.live_poll();
|
||||
// Everything else is refused before anything is sent, as with a view link.
|
||||
let before = s.doc().unwrap().clone();
|
||||
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}));
|
||||
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}));
|
||||
refused(&mut s, "edit.delete", json!({"ids": [card.0]}));
|
||||
refused(&mut s, "tool.set", json!({"tool": "text"}));
|
||||
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
assert!(script.lock().unwrap().msgs.iter().all(|m| !matches!(m, ClientMsg::Changes { .. })), "nothing sent");
|
||||
// A change made outside a command: put back, unless it only touches comment threads.
|
||||
s.ds_mut().unwrap().doc_mut().node_mut(card).unwrap().name = "Outside".into();
|
||||
s.live_sync();
|
||||
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Card");
|
||||
s.ds_mut().unwrap().doc_mut().comment_mut(thread).unwrap().resolved = false;
|
||||
s.live_sync();
|
||||
relay(&script, &mut server, id);
|
||||
assert!(!server.document().comment(thread).unwrap().resolved, "sent");
|
||||
// Your other tabs stay yours.
|
||||
s.active = 0;
|
||||
assert_eq!(s.active_read_only(), None);
|
||||
assert!(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_comment_link_leaves_the_comment_tool() {
|
||||
for t in [ToolId::Move, ToolId::Hand, ToolId::Comment] {
|
||||
assert!(tool_left(COMMENT_ONLY, t), "{t:?}");
|
||||
}
|
||||
assert!(!tool_left(COMMENT_ONLY, ToolId::Rectangle));
|
||||
assert!(!tool_left(crate::live::VIEW_ONLY, ToolId::Comment) && tool_left(crate::live::VIEW_ONLY, ToolId::Hand));
|
||||
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_share_has_no_comment_code() {
|
||||
let (mut s, _) = session();
|
||||
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can edit it"), "{e}");
|
||||
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
|
||||
assert!(e.contains("\"comment\""), "{e}");
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `comment.list` reports the names each message @mentions, by the rule the thread view highlights
|
||||
//! them with: resolved against the file's people where it can be, and whether the name is you.
|
||||
//! Its `mentions` param lists only the threads where a message mentions a name.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -42,3 +43,101 @@ fn the_mention_rule_matches_the_highlighting_rule() {
|
||||
assert_eq!(figcraft_doc::mention_ranges("@Sam Lee, @sam, x@Sam, @Zoe!", &people), vec![0..8, 10..14]);
|
||||
assert!(figcraft_doc::mentions("@ and a@b.com", &people).is_empty());
|
||||
}
|
||||
|
||||
/// The ids of the threads `comment.list` returns for `p`.
|
||||
fn listed(s: &mut Session, p: Value) -> Vec<u64> {
|
||||
let l = s.execute("comment.list", &p).unwrap();
|
||||
l["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, text: &str, author: &str) -> u64 {
|
||||
s.execute("comment.add", &json!({"x": 0, "y": 0, "text": text, "author": author})).unwrap()["id"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mentions_param_lists_only_the_threads_that_mention_a_name() {
|
||||
let mut s = Session::new();
|
||||
let claude = add(&mut s, "@Claude make this bigger", "Sam");
|
||||
let in_reply = add(&mut s, "Spacing looks off", "Sam");
|
||||
s.execute("comment.reply", &json!({"id": in_reply, "text": "agreed, @claude: tighten it", "author": "Ada Lovelace"})).unwrap();
|
||||
let other = add(&mut s, "@Ada Lovelace and @Zoe Smith, mail claude@example.com", "Sam");
|
||||
add(&mut s, "No mentions here", "Sam");
|
||||
let mut both = listed(&mut s, json!({"mentions": "CLAUDE"}));
|
||||
both.sort_unstable();
|
||||
assert_eq!(both, vec![claude, in_reply], "any case, in a reply too, never an email address");
|
||||
assert_eq!(listed(&mut s, json!({"mentions": " @Claude "})), listed(&mut s, json!({"mentions": "claude"})), "a leading @ is fine");
|
||||
assert_eq!(listed(&mut s, json!({"mentions": "ada lovelace"})), vec![other]);
|
||||
assert!(listed(&mut s, json!({"mentions": "Ada"})).is_empty(), "@Ada Lovelace names Ada Lovelace, not Ada");
|
||||
assert_eq!(listed(&mut s, json!({"mentions": "Zoe Smith"})), vec![other], "a name nobody in the file has, several words");
|
||||
assert_eq!(listed(&mut s, json!({"mentions": null})).len(), 4);
|
||||
// Combined with the other filters: resolved threads drop out.
|
||||
s.execute("comment.resolve", &json!({"id": claude})).unwrap();
|
||||
assert_eq!(listed(&mut s, json!({"mentions": "Claude", "filter": "open"})), vec![in_reply]);
|
||||
for junk in [json!(""), json!("@"), json!(5), json!(["Claude"])] {
|
||||
assert!(s.execute("comment.list", &json!({"mentions": junk})).is_err(), "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn people_lists_the_names_only_mentioned_too() {
|
||||
let mut s = Session::new();
|
||||
let id = add(&mut s, "@ada lovelace, @Zoe and @zoe: see me@example.com", "Sam");
|
||||
s.execute("comment.reply", &json!({"id": id, "text": "On it", "author": "Ada Lovelace"})).unwrap();
|
||||
s.execute("comment.react", &json!({"id": id, "emoji": "👍", "author": "Lin"})).unwrap();
|
||||
let p = s.execute("comment.people", &json!({})).unwrap();
|
||||
// Ada once, spelt as the file spells her; Zoe once in any case; no email address.
|
||||
assert_eq!(p["people"], json!(["Ada Lovelace", "Lin", "Sam", "Zoe"]));
|
||||
assert_eq!(p["you"], "You");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn people_who_write_in_two_cases_are_listed_once() {
|
||||
let mut s = Session::new();
|
||||
let id = add(&mut s, "Hi", "Ada");
|
||||
s.execute("comment.reply", &json!({"id": id, "text": "Hi @ADA", "author": "ada"})).unwrap();
|
||||
let p = s.execute("comment.people", &json!({})).unwrap();
|
||||
assert_eq!(p["people"].as_array().map(Vec::len), Some(1), "{}", p["people"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_and_prompt_carry_the_mentions_too() {
|
||||
let mut s = Session::new();
|
||||
let first = add(&mut s, "@Claude make this bigger", "Sam");
|
||||
let second = add(&mut s, "Spacing looks off", "Sam");
|
||||
s.execute("comment.reply", &json!({"id": second, "text": "@Claude and @Bob, tighten it", "author": "Ada"})).unwrap();
|
||||
let plain = add(&mut s, "No mentions here", "Sam");
|
||||
let claude = json!({"name": "Claude", "known": false, "you": false});
|
||||
// comment.next returns the thread as comment.list lists it, mentions and all.
|
||||
let list = s.execute("comment.list", &json!({})).unwrap();
|
||||
let ids: Vec<u64> = list["comments"].as_array().unwrap().iter().map(|c| c["id"].as_u64().unwrap()).collect();
|
||||
let next = s.execute("comment.next", &json!({"id": ids[0]})).unwrap();
|
||||
let listed = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == next["id"]).unwrap();
|
||||
assert_eq!(next["mentions"], listed["mentions"]);
|
||||
assert_eq!(next["replies"], listed["replies"]);
|
||||
// Stepping through the threads that mention Claude skips the others.
|
||||
let n = s.execute("comment.next", &json!({"mentions": "claude"})).unwrap();
|
||||
let m = s.execute("comment.next", &json!({"mentions": "claude", "id": n["id"]})).unwrap();
|
||||
let mut both = vec![n["id"].as_u64().unwrap(), m["id"].as_u64().unwrap()];
|
||||
both.sort_unstable();
|
||||
assert_eq!(both, vec![first, second]);
|
||||
assert!(s.execute("comment.next", &json!({"mentions": 5})).is_err());
|
||||
// comment.prompt: each thread's mentions, the first message's and the replies' together.
|
||||
let prompt = s.execute("comment.prompt", &json!({})).unwrap();
|
||||
let mentions = |id: u64| prompt["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap()["mentions"].clone();
|
||||
assert_eq!(mentions(first), json!([claude]));
|
||||
assert_eq!(mentions(second), json!([claude, {"name": "Bob", "known": false, "you": false}]));
|
||||
assert_eq!(mentions(plain), json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prompt_lists_a_bounded_number_of_short_mentions() {
|
||||
let mut s = Session::new();
|
||||
let many: String = (0..100).map(|i| format!("@n{i} ")).collect();
|
||||
let id = add(&mut s, &format!("{many} @{}", "x".repeat(500)), "Sam");
|
||||
let prompt = s.execute("comment.prompt", &json!({"ids": [id]})).unwrap();
|
||||
let mentions = prompt["comments"][0]["mentions"].as_array().unwrap().clone();
|
||||
assert_eq!(mentions.len(), 32);
|
||||
let id = add(&mut s, &format!("@{}", "y".repeat(500)), "Sam");
|
||||
let long = s.execute("comment.prompt", &json!({"ids": [id]})).unwrap();
|
||||
assert!(long["comments"][0]["mentions"][0]["name"].as_str().unwrap().chars().count() <= 81);
|
||||
}
|
||||
@@ -137,7 +137,8 @@ fn mentions_and_people() {
|
||||
let c = add(&mut s, 0.0, 0.0, "Ask @Ada", "Sam");
|
||||
s.execute("comment.react", &json!({"id": c, "emoji": "👀", "author": "Lin"})).unwrap();
|
||||
let p = s.execute("comment.people", &json!({})).unwrap();
|
||||
assert_eq!(p["people"], json!(["Lin", "Sam"]));
|
||||
// Ada was only @mentioned, and is listed too (#1415).
|
||||
assert_eq!(p["people"], json!(["Ada", "Lin", "Sam"]));
|
||||
assert_eq!(p["you"], "You");
|
||||
// A thread you're mentioned in is yours.
|
||||
assert!(comment(&s, c).involves("Ada"));
|
||||
|
||||
@@ -128,9 +128,10 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
if !doc.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let mut chain = vec![id];
|
||||
chain.extend(doc.ancestors(id));
|
||||
for c in chain {
|
||||
// A frame that only moved (or turned) keeps its contents where they are: its own layout
|
||||
// runs again only for a change that can move or resize them. Its parent's still runs.
|
||||
let own = (!moved_only(before, doc, id)).then_some(id);
|
||||
for c in own.into_iter().chain(doc.ancestors(id)) {
|
||||
if doc.node(c).is_some_and(|n| n.layout.is_some()) {
|
||||
frames.insert(c);
|
||||
}
|
||||
@@ -177,9 +178,19 @@ fn keeps_right_edge(doc: &Document, before: &Document, n: &Node) -> bool {
|
||||
/// layout settings.
|
||||
fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
|
||||
let (Some(a), Some(b)) = (before.node(id), doc.node(id)) else { return true };
|
||||
a.transform != b.transform || reshaped(a, b)
|
||||
}
|
||||
|
||||
/// Whether layer `id` only moved (or turned) since `before`: its transform is all that changed of
|
||||
/// what [`affects_layout`] looks at, so the layers inside it stay where they are.
|
||||
fn moved_only(before: &Document, doc: &Document, id: NodeId) -> bool {
|
||||
before.node(id).zip(doc.node(id)).is_some_and(|(a, b)| !reshaped(a, b))
|
||||
}
|
||||
|
||||
/// What [`affects_layout`] looks at, but where the layer sits in its parent.
|
||||
fn reshaped(a: &Node, b: &Node) -> bool {
|
||||
a.width != b.width
|
||||
|| a.height != b.height
|
||||
|| a.transform != b.transform
|
||||
|| a.visible != b.visible
|
||||
|| a.parent != b.parent
|
||||
|| a.children != b.children
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! The last line of defence: a panic inside a command becomes an [`EngineError::Internal`] and
|
||||
//! the caller rolls the document back. It exists for bugs and doesn't replace `Result`.
|
||||
//!
|
||||
//! The browser build can't catch a panic (wasm32 always aborts, and stable Rust has no unwinding
|
||||
//! std for it), so there a panicking command stops the page: its panic hook keeps the files with
|
||||
//! unsaved changes for the next page instead (`apps/figcraft-web/src/recovery.rs`).
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
|
||||
@@ -94,6 +94,63 @@ fn open_reads_variables_from_param_or_next_to_the_file() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Every document, the active tab and the recent files, compared before and after a refused command.
|
||||
fn state(s: &Session) -> (String, usize, Vec<String>) {
|
||||
(format!("{:?}", s.docs), s.active, s.prefs.recent_files.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
|
||||
let dir = temp_dir("restvars-cap");
|
||||
let path = dir.join("proto.json");
|
||||
std::fs::write(&path, serde_json::to_vec(&file()).unwrap()).unwrap();
|
||||
let path = path.to_string_lossy().to_string();
|
||||
let vars = dir.join("theme.json");
|
||||
std::fs::write(&vars, serde_json::to_vec(&variables()).unwrap()).unwrap();
|
||||
let vars = vars.to_string_lossy().to_string();
|
||||
|
||||
// A small file opens and imports as before.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"path": path, "variables": vars})).unwrap();
|
||||
assert_eq!(named(&s, "Button").bound.get("fill").map(String::as_str), Some(BRAND));
|
||||
|
||||
// One byte over the limit (sparse, so nothing is written), a folder, and a device are refused
|
||||
// before the document changes, by the same check the sibling file goes through.
|
||||
let big = dir.join("big.json");
|
||||
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
|
||||
let mut refused = vec![big.to_string_lossy().to_string(), dir.to_string_lossy().to_string()];
|
||||
if cfg!(unix) {
|
||||
refused.push("/dev/null".to_string());
|
||||
}
|
||||
s.prefs.recent_files = vec![path.clone()];
|
||||
s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 20, "height": 10})).unwrap();
|
||||
for bad in &refused {
|
||||
for cmd in ["file.open", "file.import"] {
|
||||
let before = state(&s);
|
||||
let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}");
|
||||
assert_eq!(state(&s), before, "{cmd} {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
// Access roots still apply: inside them the file reads, outside it is rejected.
|
||||
let inside = temp_dir("restvars-cap-root");
|
||||
std::fs::copy(&vars, inside.join("theme.json")).unwrap();
|
||||
std::fs::copy(&path, inside.join("proto.json")).unwrap();
|
||||
let roots = std::sync::Arc::new(crate::file_access::Roots::new(&[inside.to_string_lossy().to_string()], &[]).unwrap());
|
||||
let inside_vars = inside.join("theme.json").to_string_lossy().to_string();
|
||||
let inside_path = inside.join("proto.json").to_string_lossy().to_string();
|
||||
crate::file_access::confined(Some(&roots), || {
|
||||
s.execute("file.import", &json!({"path": inside_path, "variables": inside_vars})).unwrap();
|
||||
let before = state(&s);
|
||||
let e = s.execute("file.import", &json!({"path": inside_path, "variables": vars})).unwrap_err().to_string();
|
||||
assert!(e.contains("automation path rejected"), "{e}");
|
||||
assert_eq!(state(&s), before);
|
||||
});
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = std::fs::remove_dir_all(&inside);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_appends_flows_interactions_and_variables() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
//! FigJam `.jam` files (#1342) open and import like `.fig` files: File ▸ Open (`file.open`), the
|
||||
//! command line (the same command) and File ▸ Import / drag and drop (`file.import`). The file is
|
||||
//! made here from our own `.fig` export with the canvas header FigJam writes (`fig-jam.`).
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A `.jam` holding one page with a rectangle named `Card`, written to a temporary file.
|
||||
fn jam_file(tag: &str) -> std::path::PathBuf {
|
||||
let mut doc = figcraft_doc::Document::new("Synthetic board");
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 10.0, 10.0, 80.0, 40.0).unwrap();
|
||||
let (fig, _) = figcraft_format::fig::export(&doc, None).unwrap();
|
||||
let mut from = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap();
|
||||
let mut to = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..from.len() {
|
||||
let mut member = from.by_index(i).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
member.read_to_end(&mut bytes).unwrap();
|
||||
if member.name() == "canvas.fig" {
|
||||
assert!(bytes.starts_with(b"fig-kiwi"));
|
||||
bytes.splice(..8, *b"fig-jam.");
|
||||
}
|
||||
to.start_file(member.name(), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
to.write_all(&bytes).unwrap();
|
||||
}
|
||||
let path = std::env::temp_dir().join(format!("figcraft-jam-{tag}-{}.jam", std::process::id()));
|
||||
std::fs::write(&path, to.finish().unwrap().into_inner()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn cards(s: &Session) -> usize {
|
||||
s.doc().unwrap().nodes().filter(|n| n.name == "Card").count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_jam_file_opens_and_imports_like_a_fig() {
|
||||
let path = jam_file("open");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", &json!({"path": path})).unwrap();
|
||||
assert_eq!(cards(&s), 1, "opened");
|
||||
assert!(crate::board::is_board(s.doc().unwrap()), "a whiteboard opens as a board");
|
||||
// Imported into a design file, it adds its pages to it (not an image of its bytes), which
|
||||
// stays a design file.
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
let before = s.doc().unwrap().pages.len();
|
||||
s.execute("file.import", &json!({"path": path})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().pages.len(), before + 1);
|
||||
assert_eq!(cards(&s), 1);
|
||||
assert!(!crate::board::is_board(s.doc().unwrap()));
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
@@ -187,6 +187,8 @@ mod arc_fields_tests;
|
||||
#[cfg(test)]
|
||||
mod arc_handles_tests;
|
||||
#[cfg(test)]
|
||||
mod auto_layout_move_tests;
|
||||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_continue_events_tests;
|
||||
@@ -207,6 +209,8 @@ mod board_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_cap_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod bound_alpha_tests;
|
||||
@@ -233,6 +237,8 @@ mod collab_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_agent_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_link_tests;
|
||||
#[cfg(test)]
|
||||
mod comment_mentions_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_polish_tests;
|
||||
@@ -349,6 +355,8 @@ mod interaction_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod jam_open_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_path_tests;
|
||||
@@ -411,6 +419,8 @@ mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_vector_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod penpot_export_tests;
|
||||
#[cfg(test)]
|
||||
mod pixel_preview_tests;
|
||||
|
||||
@@ -118,7 +118,7 @@ fn links(n: usize, codes: Option<(&str, &str)>) -> Shared {
|
||||
link: format!("figcraft://join?server=wss://share.example:443/&file=f1&token=Share{what}Share{what}0{n}"),
|
||||
code: code.map(str::to_string),
|
||||
};
|
||||
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))) }
|
||||
Shared { edit: link("edit", codes.map(|c| c.0)), view: Some(link("view", codes.map(|c| c.1))), comment: None }
|
||||
}
|
||||
|
||||
/// The file is on the server and joined: its owner's links, and what others join with (codes
|
||||
@@ -264,7 +264,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
|
||||
assert_eq!((&r["status"], &r["syncing"].is_boolean(), &r["code"]), (&json!("live"), &true, &Value::Null));
|
||||
assert_eq!(
|
||||
r["share"],
|
||||
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "editLink": null, "viewLink": null})
|
||||
json!({"via": "online", "access": "edit", "shared": false, "editCode": null, "viewCode": null, "commentCode": null, "editLink": null, "viewLink": null, "commentLink": null})
|
||||
);
|
||||
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
|
||||
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
|
||||
|
||||
@@ -108,7 +108,7 @@ fn links(file: &str, n: usize) -> Shared {
|
||||
link: format!("figcraft://join?server={SERVER}&file={file}&token=Shared{what}Shared{what}0{n}"),
|
||||
code: Some(code),
|
||||
};
|
||||
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))) }
|
||||
Shared { edit: link("edit", format!("NEWED1T0{n}")), view: Some(link("view", format!("NEWV1EW0{n}"))), comment: None }
|
||||
}
|
||||
|
||||
fn session() -> (Session, Arc<Mutex<Script>>) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Ending a pen path with Escape or Enter (#760): the path's vector stays in edit mode with the pen,
|
||||
//! so the next path is drawn into the same layer; another Escape leaves edit mode.
|
||||
//! Ending a pen path with Escape (#760): the path's vector stays in edit mode with the pen, so the
|
||||
//! next path is drawn into the same layer; another Escape leaves edit mode. (Enter goes on with the
|
||||
//! Move tool instead: `pen_vector_edit_tests`.)
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
@@ -41,8 +42,8 @@ fn drawn(end: fn(&mut Session)) -> (Session, figcraft_doc::NodeId) {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_and_enter_keep_the_vector_in_edit_mode_with_the_pen() {
|
||||
// Escape, and Enter (its key runs `pen_end`).
|
||||
fn escape_keeps_the_vector_in_edit_mode_with_the_pen() {
|
||||
// Escape, and `pen_end` that it runs.
|
||||
let ends: [fn(&mut Session); 2] = [|s| s.escape().unwrap(), |s| s.pen_end().unwrap()];
|
||||
for end in ends {
|
||||
let (mut s, id) = drawn(end);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! From the pen into vector edit mode (#1430): a double-click or Enter while the pen is out ends
|
||||
//! the path and edits the vector with the Move tool's point handles, instead of drawing on (a
|
||||
//! double-click used to leave a zero-length segment, or a stray two-point vector on the layer it
|
||||
//! was meant to edit). Escape still keeps the pen (#760).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use crate::vectoredit::VTool;
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
fn press(s: &mut Session, kinds: &[PointerKind], x: f64, y: f64) {
|
||||
for kind in kinds {
|
||||
s.pointer(&PointerEvent::at(*kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
press(s, &[PointerKind::Down, PointerKind::Up], x, y);
|
||||
}
|
||||
|
||||
/// A double-click as the canvas sends it: two clicks, then the double-click.
|
||||
fn double_click(s: &mut Session, x: f64, y: f64) {
|
||||
press(s, &[PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick], x, y);
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> Vec<NodeId> {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.clone()
|
||||
}
|
||||
|
||||
/// (points, segments) of vector `id`.
|
||||
fn shape(s: &Session, id: NodeId) -> (usize, usize) {
|
||||
match &s.doc().unwrap().node(id).unwrap().kind {
|
||||
NodeKind::Vector { network } => (network.vertices.len(), network.segments.len()),
|
||||
k => panic!("not a vector: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layer and tool vector edit mode has (with no pen path open).
|
||||
fn editing(s: &Session) -> Option<(NodeId, VTool)> {
|
||||
s.ds().unwrap().vector_edit.as_ref().filter(|v| v.anchor.is_none()).map(|v| (v.id, v.tool))
|
||||
}
|
||||
|
||||
/// A pen path from (100, 100) to (200, 100), still being drawn; its layer.
|
||||
fn drawing() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_with_the_pen_on_a_layer_edits_it_instead_of_drawing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
let [rect] = <[NodeId; 1]>::try_from(layers(&s)).unwrap();
|
||||
s.set_tool(ToolId::Pen);
|
||||
double_click(&mut s, 150.0, 100.0);
|
||||
assert_eq!(layers(&s), vec![rect], "no stray vector drawn");
|
||||
assert_eq!(editing(&s), Some((rect, VTool::Move)), "its points are edited with the Move tool");
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert_eq!(s.tool.tool, ToolId::Move);
|
||||
// Off any layer the double-click draws nothing either.
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
double_click(&mut s, 400.0, 400.0);
|
||||
assert!(layers(&s).is_empty() && s.tool.pen.is_none() && editing(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_click_while_drawing_ends_the_path_there_and_edits_it() {
|
||||
let (mut s, id) = drawing();
|
||||
let undo = s.undo_depth().0;
|
||||
double_click(&mut s, 200.0, 200.0);
|
||||
assert_eq!(shape(&s, id), (3, 2), "the double-click's point once, no zero-length segment");
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert_eq!(editing(&s), Some((id, VTool::Move)));
|
||||
assert_eq!(s.undo_depth().0, undo + 1, "the path is one undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_while_drawing_ends_the_path_and_edits_it_with_the_move_tool() {
|
||||
let (mut s, id) = drawing();
|
||||
assert!(s.tool_key("Enter", false, false, false).unwrap());
|
||||
assert!(s.tool.pen.is_none());
|
||||
assert_eq!(editing(&s), Some((id, VTool::Move)));
|
||||
assert_eq!(shape(&s, id), (2, 1));
|
||||
// The next Enter leaves edit mode.
|
||||
assert!(s.tool_key("Enter", false, false, false).unwrap());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
assert_eq!(s.selection(), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_edit_mode_the_pen_goes_to_the_move_tool_on_a_double_click_or_enter() {
|
||||
// Escape keeps the pen in edit mode (#760); a double-click on the path then edits it rather
|
||||
// than adding a point where it landed.
|
||||
let (mut s, id) = drawing();
|
||||
s.escape().unwrap();
|
||||
assert_eq!(editing(&s), Some((id, VTool::Pen)));
|
||||
double_click(&mut s, 150.0, 100.0);
|
||||
assert_eq!(editing(&s), Some((id, VTool::Move)));
|
||||
assert_eq!(shape(&s, id), (2, 1), "no point split into the segment");
|
||||
// Off the path: no lone point left behind.
|
||||
let (mut s, id) = drawing();
|
||||
s.escape().unwrap();
|
||||
double_click(&mut s, 150.0, 300.0);
|
||||
assert_eq!((editing(&s), shape(&s, id)), (Some((id, VTool::Move)), (2, 1)));
|
||||
// Enter ends the pen's path in edit mode and takes the Move tool; the next one leaves.
|
||||
let (mut s, id) = drawing();
|
||||
s.escape().unwrap();
|
||||
click(&mut s, 150.0, 300.0);
|
||||
click(&mut s, 250.0, 300.0);
|
||||
assert!(s.tool_key("Enter", false, false, false).unwrap());
|
||||
assert_eq!((editing(&s), shape(&s, id)), (Some((id, VTool::Move)), (4, 2)));
|
||||
assert!(s.tool_key("Enter", false, false, false).unwrap());
|
||||
assert!(s.ds().unwrap().vector_edit.is_none());
|
||||
}
|
||||
@@ -29,6 +29,9 @@ pub struct Prefs {
|
||||
pub highlight_on_hover: bool,
|
||||
pub number_keys_opacity: bool,
|
||||
pub show_dimensions: bool,
|
||||
/// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the
|
||||
/// other way round). Trackpads are unchanged.
|
||||
pub scroll_wheel_zoom: bool,
|
||||
/// "system" by default; an explicit "light" or "dark" choice stays saved.
|
||||
pub theme: String,
|
||||
/// The widths of the main window's left and right panels (the interface keeps them within its
|
||||
@@ -122,6 +125,7 @@ impl Default for Prefs {
|
||||
highlight_on_hover: true,
|
||||
number_keys_opacity: true,
|
||||
show_dimensions: true,
|
||||
scroll_wheel_zoom: false,
|
||||
theme: "system".into(),
|
||||
panel_widths: (240.0, 240.0),
|
||||
pages_height: None,
|
||||
|
||||
@@ -186,12 +186,15 @@ impl Session {
|
||||
}
|
||||
|
||||
/// A live session's document you may only view (or that is on its way to a server) is
|
||||
/// read-only: refuse an edit (it is rolled back).
|
||||
/// read-only: refuse an edit (it is rolled back). With a comment link, one that changes only
|
||||
/// comment threads goes through.
|
||||
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
|
||||
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
|
||||
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
|
||||
match self.live_read_only(*uid) {
|
||||
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) => Err(EngineError::Disabled(id.to_string(), why.into())),
|
||||
Some(why) if !Arc::ptr_eq(&ds.doc, bdoc) && !self.live_comment_edit(*uid, &ds.doc) => {
|
||||
Err(EngineError::Disabled(id.to_string(), why.into()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,31 @@ pub fn current(s: &Session, uid: u64) -> Vec<Branch> {
|
||||
branches
|
||||
}
|
||||
|
||||
/// The tabs showing branches of the file in tab `uid`.
|
||||
pub fn tabs(s: &Session, uid: u64) -> impl Iterator<Item = &DocState> {
|
||||
s.docs.iter().filter(move |t| t.branch_of.as_ref().is_some_and(|(main, _)| *main == uid))
|
||||
}
|
||||
|
||||
/// A file whose changes would be lost, so it needs a recovery copy: in its document, its history
|
||||
/// or one of its branch tabs (a branch tab's edits are saved with its main file, so the tab has no
|
||||
/// copy of its own).
|
||||
pub fn needs_copy(s: &Session, d: &DocState) -> bool {
|
||||
d.branch_of.is_none() && (d.is_dirty() || tabs(s, d.uid).any(DocState::is_dirty))
|
||||
}
|
||||
|
||||
/// What a recovery copy of `d` holds: the document itself (not a version or live text preview
|
||||
/// shown in its place: a crash during a preview must bring the file back, not the version),
|
||||
/// its version history and its branches as they are now ([`current`]).
|
||||
pub fn recovery_copy(s: &Session, d: &DocState) -> (Arc<Document>, Vec<figcraft_format::Version>, Vec<Branch>) {
|
||||
(d.document_itself().clone(), d.versions.clone(), current(s, d.uid))
|
||||
}
|
||||
|
||||
/// The revisions a recovery copy of `d` is of: its own and its branch tabs' (a branch tab's edits
|
||||
/// change only its own revision). A new copy is due when they change.
|
||||
pub fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
|
||||
std::iter::once(d.revision).chain(tabs(s, d.uid).map(|t| t.revision)).collect()
|
||||
}
|
||||
|
||||
/// The tab a save writes: the main file's for a branch tab, else the active tab.
|
||||
pub fn file_tab(s: &Session) -> usize {
|
||||
s.ds().ok().and_then(|d| d.branch_of.as_ref()).and_then(|(m, _)| s.docs.iter().position(|d| d.uid == *m)).unwrap_or(s.active)
|
||||
|
||||
@@ -386,6 +386,24 @@ pub const NO_TEAM_LIBRARIES: &str = "team libraries on a server need the desktop
|
||||
|
||||
/// Why an edit of a server file you joined with a view link is refused.
|
||||
pub const VIEW_ONLY: &str = "View only: the link you joined with lets you see this file, not change it";
|
||||
/// Why an edit other than to comment threads of a server file you joined with a comment link is
|
||||
/// refused.
|
||||
pub const COMMENT_ONLY: &str = "Can comment: the link you joined with lets you see this file and comment on it, not change it";
|
||||
|
||||
/// Why a session document can't be edited with `access` (`None`: it can).
|
||||
pub(crate) fn limited_by(access: Access) -> Option<&'static str> {
|
||||
match access {
|
||||
Access::Edit => None,
|
||||
Access::View => Some(VIEW_ONLY),
|
||||
Access::Comment => Some(COMMENT_ONLY),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `tool` is one a file you can't edit now leaves you (`why`: what
|
||||
/// [`Session::live_read_only`] says): moving around and, with a comment link, commenting.
|
||||
pub fn tool_left(why: &str, tool: ToolId) -> bool {
|
||||
matches!(tool, ToolId::Move | ToolId::Hand) || (why == COMMENT_ONLY && tool == ToolId::Comment)
|
||||
}
|
||||
/// Why an edit of a file on its way to a server is refused.
|
||||
pub const PUBLISHING: &str = "The file is being published: you can edit it again once it is on the server";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::host::{clean_hosting, clean_shared};
|
||||
use super::types::{Event, PageInfo, Role, SessionId, Status};
|
||||
use super::{
|
||||
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
|
||||
ToolId, VIEW_ONLY, display_name, editing_hint, editing_of, sanitize,
|
||||
ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
@@ -410,8 +410,11 @@ impl Session {
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
// A view link: the editing tools are off.
|
||||
if client.access() == Access::View && self.tab() == Some(uid) && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
// A view or comment link: the editing tools are off.
|
||||
if let Some(why) = limited_by(client.access())
|
||||
&& self.tab() == Some(uid)
|
||||
&& !tool_left(why, self.tool.tool)
|
||||
{
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
let Some(live) = self.live.sessions.get_mut(&id) else { return };
|
||||
@@ -439,18 +442,25 @@ impl Session {
|
||||
self.message = Some(reason);
|
||||
}
|
||||
|
||||
/// Why document `uid` can't be edited now, if it can't: you may only view it in its session,
|
||||
/// or it is on its way to a server.
|
||||
/// Why document `uid` can't be edited now, if it can't: you may only view it (or view and
|
||||
/// comment on it: see [`live_comment_edit`](Self::live_comment_edit)) in its session, or it is
|
||||
/// on its way to a server.
|
||||
pub fn live_read_only(&self, uid: u64) -> Option<&'static str> {
|
||||
let live = self.live.doc(uid)?;
|
||||
match &live.client {
|
||||
Some(c) if c.access() == Access::View => Some(VIEW_ONLY),
|
||||
Some(_) => None,
|
||||
Some(c) => limited_by(c.access()),
|
||||
None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `doc` is session document `uid` with nothing but comment threads changed, and the
|
||||
/// link it was joined with lets you comment: such an edit goes through where others are refused.
|
||||
pub(crate) fn live_comment_edit(&self, uid: u64, doc: &Document) -> bool {
|
||||
let client = self.live.doc(uid).and_then(|l| l.client.as_ref());
|
||||
client.is_some_and(|c| c.access() == Access::Comment && c.changes_only_comments(doc))
|
||||
}
|
||||
|
||||
/// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)).
|
||||
pub fn active_read_only(&self) -> Option<&'static str> {
|
||||
self.tab().and_then(|uid| self.live_read_only(uid))
|
||||
|
||||
@@ -82,8 +82,17 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Why a local network share has no view-only code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code";
|
||||
/// Why a local network share has no view-only or comment code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
|
||||
|
||||
/// Why a share has no comment code.
|
||||
const NO_COMMENT_LINK: &str =
|
||||
"this share has no comment code (its server is older than comment links, or it was shared before them): stop sharing and share again";
|
||||
|
||||
/// The `access` parameter of `cmd`, if given.
|
||||
fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
|
||||
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
|
||||
}
|
||||
|
||||
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.share";
|
||||
@@ -92,10 +101,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some("local") => Via::Local,
|
||||
Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")),
|
||||
};
|
||||
let access = match string(p, "access") {
|
||||
None => None,
|
||||
Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?),
|
||||
};
|
||||
let access = access_param(CMD, p)?;
|
||||
// A share of this file is there already: only which code is shown changes, or (the file is
|
||||
// on the server, not shared at the moment) it gets new links and codes. Nothing is published
|
||||
// twice.
|
||||
@@ -106,7 +112,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
|
||||
));
|
||||
}
|
||||
if access == Some(Access::View) && shared == Via::Local {
|
||||
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
}
|
||||
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
@@ -114,6 +120,9 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
shared_now(s, Some(clean_shared(links)));
|
||||
}
|
||||
if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) {
|
||||
if sh.links.as_ref().is_some_and(|l| l.of(a).is_none()) {
|
||||
return Err(bad(CMD, NO_COMMENT_LINK));
|
||||
}
|
||||
sh.access = a;
|
||||
}
|
||||
return Ok(s.live_summary());
|
||||
@@ -121,7 +130,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let access = access.unwrap_or_default();
|
||||
let mut server = String::new();
|
||||
let id = match via {
|
||||
Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
||||
Via::Online => {
|
||||
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
||||
@@ -136,7 +145,7 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// what the link says: where the session is and its secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
|
||||
let code = code::Local::from_link(&i.link).map(|l| l.code());
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None }
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
|
||||
});
|
||||
live.share = Some(Share { via, server, access, links });
|
||||
}
|
||||
@@ -155,6 +164,7 @@ pub(crate) fn clean_shared(mut links: Shared) -> Shared {
|
||||
let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN);
|
||||
clean(&mut links.edit);
|
||||
links.view.as_mut().map(clean);
|
||||
links.comment.as_mut().map(clean);
|
||||
links
|
||||
}
|
||||
|
||||
@@ -217,11 +227,7 @@ pub(super) fn manage_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false),
|
||||
"links" => Admin::Links { file: file.clone() },
|
||||
"createLink" => {
|
||||
let access = match string(p, "access").as_deref().map(str::trim) {
|
||||
None | Some("view") => Access::View,
|
||||
Some("edit") => Access::Edit,
|
||||
Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")),
|
||||
};
|
||||
let access = access_param(CMD, p)?.unwrap_or(Access::View);
|
||||
Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() }
|
||||
}
|
||||
"revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
|
||||
use super::types::{Published, Role, SessionId, Share, Status, join_target};
|
||||
use super::types::{Published, Role, SessionId, Share, Shared, Status, join_target};
|
||||
use super::{
|
||||
Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
|
||||
};
|
||||
@@ -53,7 +53,7 @@ fn stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// their codes) away, which disconnects whoever joined with them, and the owner stays in the
|
||||
// file. Anything else (a session this app hosts, one it joined, a file not shared now) ends.
|
||||
let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref());
|
||||
let ids: Vec<String> = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let ids: Vec<String> = shared.into_iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
|
||||
let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) };
|
||||
share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?;
|
||||
shared_now(s, None);
|
||||
@@ -174,7 +174,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
@@ -216,7 +216,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.manageFile",
|
||||
"Manage published file",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}",
|
||||
"{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink | revoke | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename | remove (the file is deleted from the server and leaves your online files), access? (createLink: view (default) | comment | edit), label? (createLink), link? (revoke: a link's id), name? (rename)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, commentCode, editLink, viewLink, commentLink} | {done}",
|
||||
false,
|
||||
manage_file
|
||||
),
|
||||
@@ -230,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::host::hosting_json;
|
||||
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
|
||||
use super::{
|
||||
Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
editing_of, json,
|
||||
};
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone());
|
||||
if self.live_read_only(uid).is_some() {
|
||||
if self.live_read_only(uid).is_some() && !self.live_comment_edit(uid, &doc) {
|
||||
// Nothing goes out: what changed outside a command is put back.
|
||||
if let Some(s) = synced {
|
||||
self.apply_remote(uid, s);
|
||||
@@ -322,7 +322,8 @@ impl Session {
|
||||
"published": l.published,
|
||||
"code": l.share.as_ref().and_then(Share::code),
|
||||
"share": l.share.as_ref().map(|sh| {
|
||||
let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref()));
|
||||
let of = |a| sh.links.as_ref().and_then(|s| s.of(a));
|
||||
let (edit, view, comment) = (of(Access::Edit), of(Access::View), of(Access::Comment));
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"via": sh.via,
|
||||
@@ -330,8 +331,10 @@ impl Session {
|
||||
"shared": sh.links.is_some(),
|
||||
"editCode": code(edit),
|
||||
"viewCode": code(view),
|
||||
"commentCode": code(comment),
|
||||
"editLink": edit.map(|l| l.link.as_str()),
|
||||
"viewLink": view.map(|l| l.link.as_str()),
|
||||
"commentLink": comment.map(|l| l.link.as_str()),
|
||||
})
|
||||
}),
|
||||
"hosting": l.hosting.as_ref().map(hosting_json),
|
||||
|
||||
@@ -117,7 +117,8 @@ pub struct SessionInfo {
|
||||
pub address: String,
|
||||
/// Reachable from other machines (the host listens on a network address, not loopback).
|
||||
pub network: bool,
|
||||
/// What you may do: edit, or only view (a server's view link).
|
||||
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
|
||||
/// link).
|
||||
pub access: Access,
|
||||
}
|
||||
|
||||
@@ -164,12 +165,31 @@ pub struct SharedLink {
|
||||
pub code: Option<String>,
|
||||
}
|
||||
|
||||
/// What others join a shared file with: to edit it and, on a server, to only view it.
|
||||
/// What others join a shared file with: to edit it and, on a server, to only view it or to view
|
||||
/// and comment on it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Shared {
|
||||
pub edit: SharedLink,
|
||||
pub view: Option<SharedLink>,
|
||||
/// `None`: a server older than comment links, or a share made before them.
|
||||
pub comment: Option<SharedLink>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
/// The way in that gives `access`, if there is one.
|
||||
pub fn of(&self, access: Access) -> Option<&SharedLink> {
|
||||
match access {
|
||||
Access::Edit => Some(&self.edit),
|
||||
Access::View => self.view.as_ref(),
|
||||
Access::Comment => self.comment.as_ref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every way in (what stopping revokes).
|
||||
pub fn all(&self) -> impl Iterator<Item = &SharedLink> {
|
||||
std::iter::once(&self.edit).chain(&self.view).chain(&self.comment)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the
|
||||
@@ -190,8 +210,7 @@ pub struct Share {
|
||||
impl Share {
|
||||
/// The way in to show: the one for [`access`](Self::access).
|
||||
fn shown(&self) -> Option<&SharedLink> {
|
||||
let links = self.links.as_ref()?;
|
||||
if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) }
|
||||
self.links.as_ref()?.of(self.access)
|
||||
}
|
||||
|
||||
/// The code to show, grouped (`K7M-2QX-9TD`).
|
||||
|
||||
@@ -161,6 +161,7 @@ impl OnlineFile {
|
||||
let usable = |l: &SharedLink| link(&l.link) && l.id.len() <= 64 && !l.id.contains(char::is_control);
|
||||
self.shared = self.shared.take().filter(|s| usable(&s.edit)).map(|mut s| {
|
||||
s.view = s.view.take().filter(usable);
|
||||
s.comment = s.comment.take().filter(usable);
|
||||
clean_shared(s)
|
||||
});
|
||||
Some(self)
|
||||
@@ -384,9 +385,7 @@ impl Session {
|
||||
let Some(f) = self.prefs.online_files.get_mut(i) else { return };
|
||||
let before = (f.name.clone(), f.shared.is_some());
|
||||
// Links revoked from elsewhere no longer let anyone in.
|
||||
let live = |f: &OnlineFile, o: &OwnedFile| {
|
||||
f.shared.as_ref().is_some_and(|s| o.links.contains(&s.edit.id) && s.view.as_ref().is_none_or(|v| o.links.contains(&v.id)))
|
||||
};
|
||||
let live = |f: &OnlineFile, o: &OwnedFile| f.shared.as_ref().is_some_and(|s| s.all().all(|l| o.links.contains(&l.id)));
|
||||
let shared = match answer {
|
||||
Some(o) => {
|
||||
f.state = FileState::Online;
|
||||
@@ -429,6 +428,7 @@ impl Session {
|
||||
"shared": f.shared.is_some(),
|
||||
"code": code(f.shared.as_ref().map(|s| &s.edit)),
|
||||
"viewCode": code(f.shared.as_ref().and_then(|s| s.view.as_ref())),
|
||||
"commentCode": code(f.shared.as_ref().and_then(|s| s.comment.as_ref())),
|
||||
"state": f.state,
|
||||
"expires": f.expires,
|
||||
"session": session.map(|l| l.id),
|
||||
@@ -590,14 +590,16 @@ pub(crate) fn removed(s: &mut Session, server: &str, file: &str) {
|
||||
pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, on: bool) -> Result<Value> {
|
||||
let shared = s.online_index(server, file).and_then(|i| s.prefs.online_files.get(i)).and_then(|f| f.shared.clone());
|
||||
let result = |links: Option<&Shared>| {
|
||||
let (edit, view) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()));
|
||||
let (edit, view, comment) = (links.map(|l| &l.edit), links.and_then(|l| l.view.as_ref()), links.and_then(|l| l.comment.as_ref()));
|
||||
let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped);
|
||||
json!({
|
||||
"shared": links.is_some(),
|
||||
"code": code(edit),
|
||||
"viewCode": code(view),
|
||||
"commentCode": code(comment),
|
||||
"editLink": edit.map(|l| l.link.as_str()),
|
||||
"viewLink": view.map(|l| l.link.as_str()),
|
||||
"commentLink": comment.map(|l| l.link.as_str()),
|
||||
})
|
||||
};
|
||||
// As asked already: nothing to tell the server.
|
||||
@@ -607,7 +609,7 @@ pub(crate) fn set_shared(s: &mut Session, cmd: &str, server: &str, file: &str, o
|
||||
if !on && shared.is_none() {
|
||||
return Err(bad(cmd, "the links others got aren't recorded on this computer: revoke them by id (action: links, then revoke)"));
|
||||
}
|
||||
let revoke: Vec<String> = shared.iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect();
|
||||
let revoke: Vec<String> = shared.iter().flat_map(Shared::all).map(|l| l.id.clone()).collect();
|
||||
let req = NetRequest::Share { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), revoke, create: on };
|
||||
let links = crate::live::share_answer(s, req)?.map(clean_shared);
|
||||
if on && links.is_none() {
|
||||
@@ -622,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.files",
|
||||
"Your online files",
|
||||
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode (the join codes, while shared), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
|
||||
"{refresh? (true: ask each server once what became of its files; default false: no network)} → {files: [{server, host, file (its id), name, created, opened (seconds since 1970), shared (others get in with a code now), code, viewCode, commentCode (the join codes, while shared; commentCode null from a server older than comment links), state: unknown | online | gone (the server no longer has it: removed or expired), expires, session, document (the session and open document it is in now, or null), hasOwnerToken: true}], problems: [{server, host, state: offline | unsupported (a server older than this request), reason}], max}: the files you published or shared without a server key, most recently opened first, as recorded on this computer with the preferences. Owner tokens and your own links are never in the result",
|
||||
false,
|
||||
files
|
||||
),
|
||||
|
||||
@@ -89,3 +89,21 @@ fn batch_steps_refuse_unknown_keys_and_parameters() {
|
||||
err(&mut s, "command.batch", json!({"commands": [{"command": "select.all"}, {"command": "comment.list", "params": {"filterr": "open"}}]}));
|
||||
assert!(e.contains("comment.list") && e.contains("step 1: unknown parameter `filterr`"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_that_cant_run_without_parameters_say_so() {
|
||||
// A parameter without `?` (or one of alternatives) is needed; `?` ones and none aren't.
|
||||
// (`file.placeImage` too: its menu item asks for the file, a call has to name it.)
|
||||
for id in ["image.place", "file.placeImage", "comment.add", "comment.reply", "tool.set"] {
|
||||
assert!(crate::cmd::needs_params(id), "{id}");
|
||||
}
|
||||
for id in ["view.zoomIn", "edit.delete", "edit.undo", "no.such.command"] {
|
||||
assert!(!crate::cmd::needs_params(id), "{id}");
|
||||
}
|
||||
// What it says holds: such a command refuses no parameters.
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for id in ["image.place", "comment.add", "tool.set"] {
|
||||
assert!(s.execute(id, &json!({})).is_err(), "{id} ran without its parameters");
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,36 @@ fn variables_import_checks_what_it_reads() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_caps_the_file_it_reads() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-vars-io-cap-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut s = Session::new();
|
||||
theme(&mut s);
|
||||
let small = dir.join("theme.json").to_string_lossy().into_owned();
|
||||
ex(&mut s, "variable.export", json!({"path": small}));
|
||||
let mut t = Session::new();
|
||||
ex(&mut t, "variable.createCollection", json!({"name": "Other"}));
|
||||
// One byte over the limit (sparse, so nothing is written), a folder, and a device: refused,
|
||||
// nothing changes.
|
||||
let big = dir.join("big.json");
|
||||
std::fs::File::create(&big).unwrap().set_len(256 * 1024 * 1024 + 1).unwrap();
|
||||
let mut refused = vec![big.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()];
|
||||
if cfg!(unix) {
|
||||
refused.push("/dev/null".to_string());
|
||||
}
|
||||
for bad in &refused {
|
||||
let before = (format!("{:?}", t.docs), t.active);
|
||||
let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}");
|
||||
assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
|
||||
}
|
||||
// A file under the limit still imports.
|
||||
ex(&mut t, "variable.import", json!({"variables": small}));
|
||||
assert_eq!(t.doc().unwrap().variables.collections.len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A rectangle filled with a three-stop linear gradient, and colour variables `brand` and `ink`.
|
||||
fn gradient_rect(s: &mut Session) -> NodeId {
|
||||
let r = idv(&ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40})));
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
mod auto_layout_sizing_tests;
|
||||
#[cfg(test)]
|
||||
mod connector_label_tests;
|
||||
#[cfg(test)]
|
||||
mod deep_import_tests;
|
||||
#[cfg(test)]
|
||||
mod expansion_cost_tests;
|
||||
mod export;
|
||||
#[cfg(test)]
|
||||
mod export_tests;
|
||||
@@ -51,7 +55,7 @@ use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use figcraft_doc::{Document, FileKind};
|
||||
|
||||
pub use export::export;
|
||||
|
||||
@@ -81,6 +85,9 @@ pub(crate) fn layer_budget(packed: usize) -> usize {
|
||||
(packed / PACKED_BYTES_PER_LAYER).clamp(crate::rest::MAX_NODES, MAX_FIG_NODES)
|
||||
}
|
||||
|
||||
/// The canvas header of a FigJam whiteboard (a `.jam`); a `.fig` has `fig-kiwi`.
|
||||
pub const JAM_HEADER: &[u8] = b"fig-jam.";
|
||||
|
||||
/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas.
|
||||
pub fn is_fig(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"fig-") || (bytes.starts_with(b"PK") && zip_has_canvas(bytes))
|
||||
@@ -102,6 +109,8 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
|
||||
// inflate too).
|
||||
let packed = archive.canvas.len().min(bytes.len());
|
||||
// FigJam writes its whiteboards with this header: they open as boards.
|
||||
let board = archive.canvas.starts_with(JAM_HEADER);
|
||||
// Each stage's input is dropped as soon as the next one is built: in a large file every one
|
||||
// of them takes gigabytes, and holding them all at once runs out of memory.
|
||||
let (schema, message) = decode_canvas(&std::mem::take(&mut archive.canvas))?;
|
||||
@@ -115,7 +124,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
|
||||
let embedded = embedded_copies(&msg);
|
||||
drop(msg);
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
if board {
|
||||
doc.kind = FileKind::Board;
|
||||
}
|
||||
// Both stages stop at the same cap and say so in the same words: one note (#981).
|
||||
for w in more.into_iter().filter_map(map::adapt_warning) {
|
||||
let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b);
|
||||
|
||||
@@ -4,7 +4,12 @@ use super::*;
|
||||
|
||||
/// The synthetic FigJam schema adds generated layout and text alignment records.
|
||||
fn board_schema() -> Schema {
|
||||
Schema::parse(&SCHEMA.replace(
|
||||
board_schema_of(SCHEMA)
|
||||
}
|
||||
|
||||
/// [`board_schema`] over another base schema.
|
||||
fn board_schema_of(base: &str) -> Schema {
|
||||
Schema::parse(&base.replace(
|
||||
"\nmessage Blob ",
|
||||
", GeneratedData nodeGenerationData, GeneratedData derivedImmutableFrameData, string textAlignHorizontal, string textAlignVertical\nmessage GeneratedData NodeChange[] overrides, uint version\nmessage Blob ",
|
||||
))
|
||||
@@ -97,6 +102,7 @@ fn imported(layer: Kv<'static>) -> (Document, Vec<String>) {
|
||||
fn generated_stickies_and_shapes_keep_their_background_text_and_author() {
|
||||
for ty in ["STICKY", "SHAPE_WITH_TEXT"] {
|
||||
let (doc, warnings) = imported(board_node(ty));
|
||||
assert_eq!(doc.kind, figcraft_doc::FileKind::Design, "the same layers in a .fig stay a design file");
|
||||
let frame = named(&doc, "Board object");
|
||||
assert!(matches!(frame.kind, NodeKind::Frame));
|
||||
assert_eq!(frame.opacity, 0.5);
|
||||
@@ -417,3 +423,33 @@ fn outer_sticky_body_survives_alongside_the_generated_author() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A FigJam `.jam` file (#1342) is the same archive as a `.fig` with a `fig-jam.` header on its
|
||||
/// canvas (synthetic here, never a real file): it imports the same way, as a board, notes and
|
||||
/// shapes with text as their frames, a table as its stand-in, and a layer type the importer doesn't know is
|
||||
/// left out with a note instead of failing the file.
|
||||
#[test]
|
||||
fn a_jam_file_imports_like_a_fig() {
|
||||
let schema = board_schema_of(&SCHEMA.replace("CONNECTOR TABLE", "CONNECTOR TABLE SYNTHETIC_JAM_ONLY"));
|
||||
let layers = vec![
|
||||
node(0, None, "DOCUMENT", "Synthetic board", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
|
||||
board_node("STICKY"),
|
||||
node(3, Some((1, "\"")), "TABLE", "Synthetic table", vec![("size", xy(200.0, 100.0)), ("transform", at(400.0, 0.0))]),
|
||||
node(4, Some((1, "#")), "SYNTHETIC_JAM_ONLY", "Unknown object", vec![("size", xy(50.0, 50.0)), ("transform", at(0.0, 400.0))]),
|
||||
];
|
||||
let msg = o(vec![("nodeChanges", arr(layers)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(triangle_blob()))])]))]);
|
||||
let mut jam = canvas(&schema, &msg);
|
||||
jam.splice(..8, *b"fig-jam.");
|
||||
for bytes in [jam.clone(), zip_file(&jam)] {
|
||||
assert!(is_fig(&bytes), "read as the same archive");
|
||||
let (doc, warnings) = import(&bytes, "Synthetic board").unwrap();
|
||||
assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board");
|
||||
let note = named(&doc, "Board object");
|
||||
assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author");
|
||||
assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame));
|
||||
assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out");
|
||||
assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Files nested as deep as the importers go, on the browser's stack (#1101): a `.fig` file and a
|
||||
//! REST file 256 layers deep (and deeper, which the importers cut off) import on a 1 MB stack, the
|
||||
//! size wasm32 gets. The REST import's layer walk used about 4 KB of stack a level, a megabyte at
|
||||
//! 256; it now runs on a work list. The `.fig` mapping stays recursive at about 1.8 KB a level.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::map;
|
||||
|
||||
/// The stack wasm32 runs on.
|
||||
const WASM_STACK: usize = 1 << 20;
|
||||
|
||||
fn on_wasm_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
|
||||
std::thread::Builder::new().stack_size(WASM_STACK).spawn(f).unwrap().join().unwrap()
|
||||
}
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn node(id: u64, parent: Option<u64>, ty: &'static str) -> Kv<'static> {
|
||||
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(format!("L{id}")))];
|
||||
if let Some(parent) = parent {
|
||||
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str("a".into()))])));
|
||||
}
|
||||
o(fields)
|
||||
}
|
||||
|
||||
/// A `.fig` message: a page holding `depth` frames, each inside the last.
|
||||
fn deep_fig(depth: u64) -> Kv<'static> {
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT"), node(2, Some(1), "CANVAS")];
|
||||
nodes.extend((0..depth).map(|i| node(10 + i, Some(if i == 0 { 2 } else { 9 + i }), "FRAME")));
|
||||
o(vec![("nodeChanges", Kv::Array(nodes))])
|
||||
}
|
||||
|
||||
/// A REST file: a page holding `depth` frames, each inside the last.
|
||||
fn deep_rest(depth: usize) -> Value {
|
||||
let mut layer = json!({"id": format!("1:{depth}"), "type": "FRAME", "name": "Innermost"});
|
||||
for i in (0..depth - 1).rev() {
|
||||
layer = json!({"id": format!("1:{i}"), "type": "FRAME", "name": format!("L{i}"), "children": [layer]});
|
||||
}
|
||||
json!({"name": "Deep", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [layer]}]}})
|
||||
}
|
||||
|
||||
/// The `.fig` import of a file `depth` frames deep on the wasm stack: (layers, warnings).
|
||||
fn fig_import(depth: u64) -> (usize, Vec<String>) {
|
||||
on_wasm_stack(move || {
|
||||
let msg = deep_fig(depth);
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, "Deep", 2_000_000);
|
||||
let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, 2_000_000).unwrap();
|
||||
warnings.extend(more);
|
||||
(doc.node_count(), warnings)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
|
||||
let (layers, warnings) = fig_import(256);
|
||||
assert_eq!(layers, 257, "the page and every frame");
|
||||
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
|
||||
// Deeper than that, the rest is cut off with a note (still on the small stack).
|
||||
let (layers, warnings) = fig_import(400);
|
||||
assert!(layers < 400, "{layers}");
|
||||
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rest_file_as_deep_as_the_import_goes_fits_the_wasm_stack() {
|
||||
let (layers, warnings) = on_wasm_stack(|| {
|
||||
let v = deep_rest(256);
|
||||
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
(doc.node_count(), warnings)
|
||||
});
|
||||
assert_eq!(layers, 257, "the page and every frame");
|
||||
assert!(!warnings.iter().any(|w| w.contains("deeper")), "{warnings:?}");
|
||||
let (layers, warnings) = on_wasm_stack(|| {
|
||||
let v = deep_rest(400);
|
||||
let (doc, warnings) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
(doc.node_count(), warnings)
|
||||
});
|
||||
assert!(layers < 400, "{layers}");
|
||||
assert!(warnings.iter().any(|w| w.contains("nested deeper than 256 levels")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_work_list_keeps_the_files_order_and_fits_groups_to_their_children() {
|
||||
// Siblings in order, depth first, and a group's bounds from its children once they are in.
|
||||
let v = json!({"name": "Order", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "type": "CANVAS", "name": "Page", "children": [
|
||||
{"id": "1:1", "type": "GROUP", "name": "G", "absoluteBoundingBox": {"x": 0, "y": 0, "width": 1, "height": 1}, "children": [
|
||||
{"id": "1:2", "type": "RECTANGLE", "name": "A", "absoluteBoundingBox": {"x": 10, "y": 20, "width": 30, "height": 40}},
|
||||
{"id": "1:3", "type": "FRAME", "name": "B", "absoluteBoundingBox": {"x": 50, "y": 20, "width": 10, "height": 10}, "children": [
|
||||
{"id": "1:4", "type": "RECTANGLE", "name": "C", "absoluteBoundingBox": {"x": 52, "y": 22, "width": 2, "height": 2}}
|
||||
]}
|
||||
]},
|
||||
{"id": "1:5", "type": "RECTANGLE", "name": "D", "absoluteBoundingBox": {"x": 0, "y": 100, "width": 5, "height": 5}}
|
||||
]}]}});
|
||||
let (doc, _) = crate::rest::import(&v, &|_: &str| None).unwrap();
|
||||
let page = doc.first_page().unwrap();
|
||||
let name = |id| doc.node(id).unwrap().name.clone();
|
||||
let top: Vec<String> = doc.node(page).unwrap().children.iter().map(|c| name(*c)).collect();
|
||||
assert_eq!(top, ["G", "D"]);
|
||||
let g = doc.node(page).unwrap().children[0];
|
||||
let kids: Vec<String> = doc.node(g).unwrap().children.iter().map(|c| name(*c)).collect();
|
||||
assert_eq!(kids, ["A", "B"]);
|
||||
let b = doc.node(g).unwrap().children[1];
|
||||
assert_eq!(doc.node(b).unwrap().children.iter().map(|c| name(*c)).collect::<Vec<_>>(), ["C"]);
|
||||
let group = doc.node(g).unwrap();
|
||||
assert_eq!((group.width, group.height), (50.0, 40.0), "the group fits A and B");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Instance expansion stays near linear on crafted files (#1077): variable nodes under a main
|
||||
//! aren't walked again for every instance (they were never layers, so the layer cap never stopped
|
||||
//! them), and a nested instance takes only the outer instance's override rows addressed below it
|
||||
//! instead of copying all of them. Overrides still reach the layers they name.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::map;
|
||||
|
||||
type Fields = Vec<(&'static str, Kv<'static>)>;
|
||||
|
||||
fn o(fields: Fields) -> Kv<'static> {
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
fn g(id: u64) -> Kv<'static> {
|
||||
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
|
||||
}
|
||||
|
||||
fn node(id: u64, parent: Option<u64>, ty: &'static str, name: &str, more: Fields) -> Kv<'static> {
|
||||
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
|
||||
if let Some(parent) = parent {
|
||||
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))])));
|
||||
}
|
||||
fields.extend(more);
|
||||
o(fields)
|
||||
}
|
||||
|
||||
fn instance_of(main: u64, overrides: Vec<Kv<'static>>) -> Fields {
|
||||
vec![("symbolData", o(vec![("symbolID", g(main)), ("symbolOverrides", Kv::Array(overrides))]))]
|
||||
}
|
||||
|
||||
/// Map and import `nodes`; the document and how long it took.
|
||||
fn import(nodes: Vec<Kv<'static>>) -> (Document, Duration) {
|
||||
let msg = o(vec![("nodeChanges", Kv::Array(nodes))]);
|
||||
let start = Instant::now();
|
||||
let (json, _, networks) = map::to_rest(&msg, "Crafted", 2_000_000);
|
||||
let took = start.elapsed();
|
||||
let (doc, _) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, 2_000_000).unwrap();
|
||||
(doc, took)
|
||||
}
|
||||
|
||||
fn page_children(doc: &Document) -> Vec<NodeId> {
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.node(page).unwrap().children.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_nodes_under_a_main_are_not_walked_for_every_instance() {
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
|
||||
nodes.push(node(10, Some(2), "SYMBOL", "Main", vec![]));
|
||||
nodes.push(node(11, Some(10), "RECTANGLE", "R", vec![]));
|
||||
nodes.extend((0..5_000).map(|i| node(100_000 + i, Some(10), "VARIABLE", "V", vec![])));
|
||||
nodes.extend((0..1_000).map(|i| node(500_000 + i, Some(2), "INSTANCE", "I", instance_of(10, vec![]))));
|
||||
let (doc, took) = import(nodes);
|
||||
// Five million needless visits took seconds; now they are skipped.
|
||||
assert!(took < Duration::from_secs(5), "{took:?}");
|
||||
let kids = page_children(&doc);
|
||||
assert_eq!(kids.len(), 1_001, "the main and its instances");
|
||||
assert!(kids.iter().all(|k| doc.node(*k).unwrap().children.len() == 1), "each holds the rectangle and no variable layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_take_only_the_override_rows_below_them() {
|
||||
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
|
||||
nodes.push(node(10, Some(2), "SYMBOL", "Inner", vec![]));
|
||||
nodes.push(node(11, Some(10), "RECTANGLE", "R", vec![]));
|
||||
nodes.push(node(20, Some(2), "SYMBOL", "Outer", vec![]));
|
||||
nodes.extend((0..1_000).map(|i| node(30_000 + i, Some(20), "INSTANCE", "Nested", instance_of(10, vec![]))));
|
||||
// 20 000 rows spread over the nested instances (for layers they don't have), and one that
|
||||
// hides the rectangle of nested instance 7.
|
||||
let mut rows: Vec<Kv<'static>> = (0..20_000)
|
||||
.map(|i| o(vec![("guidPath", o(vec![("guids", Kv::Array(vec![g(30_000 + i % 1_000), g(900_000 + i)]))])), ("visible", Kv::Bool(false))]))
|
||||
.collect();
|
||||
rows.push(o(vec![("guidPath", o(vec![("guids", Kv::Array(vec![g(30_007), g(11)]))])), ("visible", Kv::Bool(false))]));
|
||||
nodes.push(node(40, Some(2), "INSTANCE", "Outer instance", instance_of(20, rows)));
|
||||
let (doc, took) = import(nodes);
|
||||
// Each nested instance copied all 20 000 rows: twenty million copies.
|
||||
assert!(took < Duration::from_secs(5), "{took:?}");
|
||||
let outer = *page_children(&doc).iter().find(|k| doc.node(**k).unwrap().name == "Outer instance").unwrap();
|
||||
let nested = doc.node(outer).unwrap().children.clone();
|
||||
assert_eq!(nested.len(), 1_000);
|
||||
let shown: Vec<bool> = nested.iter().map(|n| doc.node(doc.node(*n).unwrap().children[0]).unwrap().visible).collect();
|
||||
assert!(!shown[7], "the row for nested instance 7's rectangle still applies");
|
||||
assert_eq!(shown.iter().filter(|v| **v).count(), 999, "and only there");
|
||||
}
|
||||
@@ -286,8 +286,12 @@ impl<'a> Mapper<'a> {
|
||||
if !nodes.get(&id).is_some_and(|latest| std::ptr::eq(*latest, n)) {
|
||||
continue;
|
||||
}
|
||||
// Variables and their collections are read from the node list (`of_type`), never
|
||||
// as layers: out of the layer tree, an instance's expansion doesn't walk them again for
|
||||
// every instance without counting them (#1077).
|
||||
if let Some(p) = n.get("parentIndex")
|
||||
&& let Some(parent) = p.get("guid").and_then(guid)
|
||||
&& !matches!(n.get("type").and_then(Kv::str), Some("VARIABLE" | "VARIABLE_SET"))
|
||||
{
|
||||
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
|
||||
}
|
||||
|
||||
@@ -55,6 +55,28 @@ pub(super) struct OverrideScope {
|
||||
aliases: BTreeMap<String, Option<String>>,
|
||||
}
|
||||
|
||||
/// Values by key that nested instances add to their outer instances' (property values, slot
|
||||
/// contents): an outer instance's value wins. Each instance keeps only what it adds and shares the
|
||||
/// rest, instead of copying every outer value (#1077); lookups walk at most the nesting depth.
|
||||
pub(super) struct Chain<V> {
|
||||
own: BTreeMap<String, V>,
|
||||
outer: Option<Rc<Chain<V>>>,
|
||||
}
|
||||
|
||||
impl<V> Chain<V> {
|
||||
/// `outer`'s values, then `own`'s for keys `outer` lacks (shared as it is when `own` adds nothing).
|
||||
fn over(outer: Option<&Rc<Chain<V>>>, own: BTreeMap<String, V>) -> Rc<Self> {
|
||||
match outer {
|
||||
Some(o) if own.is_empty() => Rc::clone(o),
|
||||
_ => Rc::new(Self { own, outer: outer.cloned() }),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn get(&self, key: &str) -> Option<&V> {
|
||||
self.outer.as_ref().and_then(|o| o.get(key)).or_else(|| self.own.get(key))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an instance's expanded layers are: the overrides and derived layout of the outermost
|
||||
/// instance (by id path), the path of the instance being expanded, and the property values.
|
||||
#[derive(Clone)]
|
||||
@@ -62,9 +84,9 @@ pub(super) struct Inst<'a> {
|
||||
pub(super) root: String,
|
||||
pub(super) path: Vec<String>,
|
||||
pub(super) overrides: Rc<Overrides<'a>>,
|
||||
props: Rc<BTreeMap<String, &'a Kv<'a>>>,
|
||||
props: Rc<Chain<&'a Kv<'a>>>,
|
||||
/// Slot property GUID → private slot-content node GUID.
|
||||
pub(super) slot_contents: Rc<BTreeMap<String, String>>,
|
||||
pub(super) slot_contents: Rc<Chain<String>>,
|
||||
/// Layers imported from a slot's independent content subtree.
|
||||
pub(super) slot_content: bool,
|
||||
/// Mains being expanded (an instance inside its own main stops there).
|
||||
@@ -318,12 +340,14 @@ impl<'a> Mapper<'a> {
|
||||
let scope = self.override_scope(&sym);
|
||||
let mut overrides: Overrides<'a> = BTreeMap::new();
|
||||
if let Some(inherited) = outer {
|
||||
for (path, rows) in inherited.overrides.iter() {
|
||||
// Only rows addressed below this instance can apply inside it (every path looked up in
|
||||
// it starts with `own_path`); they sort together, so the others aren't even visited. A
|
||||
// nested instance used to copy all of its outer instance's rows (#1077).
|
||||
let below = inherited.overrides.range(own_path.clone()..).take_while(|(path, _)| path.starts_with(&own_path));
|
||||
for (path, rows) in below.filter(|(path, _)| path.len() > own_path.len()) {
|
||||
let mut path = path.clone();
|
||||
// Rows an outer instance wrote for this instance's layers may use their old keys.
|
||||
if path.starts_with(&own_path) && path.len() > own_path.len() {
|
||||
normalize_override_segment(&mut path, own_path.len(), &scope);
|
||||
}
|
||||
normalize_override_segment(&mut path, own_path.len(), &scope);
|
||||
overrides.entry(path).or_default().extend(rows.iter().cloned());
|
||||
}
|
||||
}
|
||||
@@ -373,8 +397,9 @@ impl<'a> Mapper<'a> {
|
||||
// last, then wire order.
|
||||
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
|
||||
}
|
||||
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
|
||||
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
|
||||
// This instance's own property values and slot contents; the outer instances' win.
|
||||
let mut all_props: BTreeMap<String, &'a Kv<'a>> = BTreeMap::new();
|
||||
let mut slot_contents: BTreeMap<String, String> = BTreeMap::new();
|
||||
for a in assignments {
|
||||
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
|
||||
all_props.entry(self.prop_root(&d)).or_insert(val);
|
||||
@@ -411,8 +436,8 @@ impl<'a> Mapper<'a> {
|
||||
root,
|
||||
path: own_path,
|
||||
overrides: Rc::new(overrides),
|
||||
props: Rc::new(all_props),
|
||||
slot_contents: Rc::new(slot_contents),
|
||||
props: Chain::over(outer.map(|i| &i.props), all_props),
|
||||
slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents),
|
||||
slot_content: false,
|
||||
symbols,
|
||||
},
|
||||
|
||||
+87
-13
@@ -64,6 +64,16 @@ const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
|
||||
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
/// Most images, and image bytes together, read from one file (members past it are left out).
|
||||
const IMAGE_BUDGET: ImageBudget = ImageBudget { images: 20_000, bytes: 4 * 1024 * 1024 * 1024 };
|
||||
/// Most bytes one open of a `.figcraft` file unpacks, every area together: its document, images,
|
||||
/// published library, version history and branches ([`Unpacked`], #1079). Each area keeps its own
|
||||
/// cap; their sum was about 10 GB. Read in that order, the document (a 900 000-layer one unpacks
|
||||
/// to 1.5 GB) and the images up to their own 4 GiB cap fit, so a file whose images pass that cap
|
||||
/// (#1313: an 84 MiB document with 7.3 GiB of images) keeps as many as before; the history and
|
||||
/// branches share what is left. 32-bit targets (wasm32) have 4 GB of address space in all.
|
||||
#[cfg(not(target_pointer_width = "32"))]
|
||||
const MAX_UNPACKED: u64 = 6 * 1024 * 1024 * 1024;
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
const MAX_UNPACKED: u64 = 2 * 1024 * 1024 * 1024;
|
||||
const BRANCH_INDEX: &str = "branches/index.json";
|
||||
/// Most branches a file keeps: making more is refused, extra entries in a file are skipped.
|
||||
pub const MAX_BRANCHES: usize = 100;
|
||||
@@ -664,6 +674,42 @@ pub(crate) fn read_member<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// What one file open may still unpack, every area together ([`MAX_UNPACKED`]).
|
||||
pub(crate) struct Unpacked {
|
||||
left: u64,
|
||||
total: u64,
|
||||
}
|
||||
|
||||
impl Unpacked {
|
||||
pub(crate) fn new(total: u64) -> Self {
|
||||
Self { left: total, total }
|
||||
}
|
||||
|
||||
/// Why a member is left out once the total is spent.
|
||||
fn passed(&self) -> String {
|
||||
match self.total / (1024 * 1024) {
|
||||
0 => format!("too large: the file's contents together pass {} bytes", self.total),
|
||||
mb => format!("too large: the file's contents together pass {mb} MB"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether member `name` (by its recorded size) needs more than is left.
|
||||
fn exceeded<R: Read + Seek>(&self, z: &mut zip::ZipArchive<R>, name: &str) -> bool {
|
||||
z.by_name(name).is_ok_and(|f| f.size() > self.left)
|
||||
}
|
||||
|
||||
/// [`read_member`] within `limit` (the area's own cap) and what is left of the total, which the
|
||||
/// member then uses up.
|
||||
pub(crate) fn read<R: Read + Seek>(&mut self, z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, FormatError> {
|
||||
if self.exceeded(z, name) && z.by_name(name).is_ok_and(|f| f.size() <= limit) {
|
||||
return Err(FormatError::Corrupt(format!("{name}: {}", self.passed())));
|
||||
}
|
||||
let data = read_member(z, name, limit.min(self.left))?;
|
||||
self.left = self.left.saturating_sub(data.len() as u64);
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest `document.json` read from an archive of `len` bytes in which it takes `packed`.
|
||||
pub(crate) fn document_limit(packed: u64, len: u64) -> u64 {
|
||||
packed.min(len).saturating_mul(DOCUMENT_RATIO).clamp(MAX_MEMBER, MAX_DOCUMENT)
|
||||
@@ -683,6 +729,11 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
||||
|
||||
/// [`load_bytes_with_versions`] reading at most `budget` of images.
|
||||
pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, FormatError> {
|
||||
load_limited(bytes, budget, MAX_UNPACKED)
|
||||
}
|
||||
|
||||
/// [`load_within`] unpacking at most `total` bytes from the archive, every area together.
|
||||
pub(crate) fn load_limited(bytes: &[u8], budget: ImageBudget, total: u64) -> Result<Loaded, FormatError> {
|
||||
let trimmed = bytes.iter().position(|b| !b.is_ascii_whitespace()).and_then(|i| bytes.get(i));
|
||||
if trimmed == Some(&b'{') {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
@@ -692,18 +743,19 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let packed = z.by_name("document.json").map(|f| f.compressed_size()).unwrap_or(0);
|
||||
let limit = document_limit(packed, bytes.len() as u64);
|
||||
let text = String::from_utf8(read_member(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let mut unpacked = Unpacked::new(total);
|
||||
let text = String::from_utf8(unpacked.read(&mut z, "document.json", limit)?).map_err(|e| FormatError::Corrupt(e.to_string()))?;
|
||||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||||
let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget);
|
||||
let ImagesRead { images, unread, past_budget } = read_images(&mut z, own_images.as_ref(), budget, &mut unpacked);
|
||||
report_lost_images(&mut issues, &unread, &past_budget, own_images.as_ref(), &images);
|
||||
let images_past_budget = past_budget.len();
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
match library::read(&mut z, &images, &mut unpacked) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
}
|
||||
let mut budget = MAX_VERSION_BYTES;
|
||||
let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget);
|
||||
let branches = read_branches(&mut z, &images, &mut issues);
|
||||
let versions = read_versions(&mut z, "versions", &images, &mut issues, &mut budget, &mut unpacked);
|
||||
let branches = read_branches(&mut z, &images, &mut issues, &mut unpacked);
|
||||
// Images only the version history or branches use stay with them.
|
||||
let branch_images =
|
||||
branches.iter().flat_map(|b| b.base.images.keys().chain(b.head.images.keys()).chain(b.versions.iter().flat_map(|v| v.images().keys())));
|
||||
@@ -720,7 +772,12 @@ pub(crate) fn load_within(bytes: &[u8], budget: ImageBudget) -> Result<Loaded, F
|
||||
/// The archive's `images/` members by hash, within `budget`, and the ones that couldn't be read
|
||||
/// with why. The document's own images (`own`) are read first, so a file past the budget loses
|
||||
/// the images only its history uses before its own.
|
||||
fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet<String>>, budget: ImageBudget) -> ImagesRead {
|
||||
fn read_images<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
own: Option<&BTreeSet<String>>,
|
||||
budget: ImageBudget,
|
||||
unpacked: &mut Unpacked,
|
||||
) -> ImagesRead {
|
||||
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect();
|
||||
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
|
||||
let mut images = BTreeMap::new();
|
||||
@@ -748,7 +805,14 @@ fn read_images<R: Read + Seek>(z: &mut zip::ZipArchive<R>, own: Option<&BTreeSet
|
||||
unread.push((hash, FormatError::Corrupt(format!("the file's images together pass {mb} MB"))));
|
||||
continue;
|
||||
}
|
||||
match read_member(z, &name, left.min(MAX_MEMBER)) {
|
||||
// Past the file's total, an image is left out as one past the images' own budget: it stays
|
||||
// in the file, which can't then be saved over without losing it (#1313).
|
||||
if unpacked.exceeded(z, &name) {
|
||||
past_budget.insert(hash.clone());
|
||||
unread.push((hash, FormatError::Corrupt(unpacked.passed())));
|
||||
continue;
|
||||
}
|
||||
match unpacked.read(z, &name, left.min(MAX_MEMBER)) {
|
||||
Ok(data) => {
|
||||
left = left.saturating_sub(data.len() as u64);
|
||||
images.insert(hash, image_data(data));
|
||||
@@ -812,12 +876,14 @@ fn read_versions<R: Read + Seek>(
|
||||
images: &BTreeMap<String, ImageData>,
|
||||
issues: &mut Vec<String>,
|
||||
budget: &mut u64,
|
||||
unpacked: &mut Unpacked,
|
||||
) -> Vec<Version> {
|
||||
let index_name = format!("{dir}/index.json");
|
||||
if z.index_for_name(&index_name).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let index = match read_member(z, &index_name, MAX_VERSION_INDEX)
|
||||
let index = match unpacked
|
||||
.read(z, &index_name, MAX_VERSION_INDEX)
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
|
||||
{
|
||||
Ok(v) => v,
|
||||
@@ -838,7 +904,7 @@ fn read_versions<R: Read + Seek>(
|
||||
issues.push("a version with an invalid id was skipped".into());
|
||||
continue;
|
||||
}
|
||||
let json = match read_member(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
|
||||
let json = match unpacked.read(z, &format!("{dir}/{id}.json"), (*budget).min(MAX_MEMBER)) {
|
||||
Ok(j) => j,
|
||||
Err(err) => {
|
||||
issues.push(format!("version `{id}` was skipped: {err}"));
|
||||
@@ -869,11 +935,17 @@ fn read_versions<R: Read + Seek>(
|
||||
|
||||
/// The branches of an archive (none when it has no index), parsed. Damaged entries are skipped and
|
||||
/// reported in `issues`; nothing here fails the load.
|
||||
fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<String, ImageData>, issues: &mut Vec<String>) -> Vec<Branch> {
|
||||
fn read_branches<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
images: &BTreeMap<String, ImageData>,
|
||||
issues: &mut Vec<String>,
|
||||
unpacked: &mut Unpacked,
|
||||
) -> Vec<Branch> {
|
||||
if z.index_for_name(BRANCH_INDEX).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let index = match read_member(z, BRANCH_INDEX, MAX_VERSION_INDEX)
|
||||
let index = match unpacked
|
||||
.read(z, BRANCH_INDEX, MAX_VERSION_INDEX)
|
||||
.and_then(|b| serde_json::from_slice::<Value>(&b).map_err(|e| FormatError::Corrupt(e.to_string())))
|
||||
{
|
||||
Ok(v) => v,
|
||||
@@ -896,7 +968,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
|
||||
continue;
|
||||
}
|
||||
let mut snapshot = |part: &str, list: &str| -> Result<Arc<Document>, FormatError> {
|
||||
let json = read_member(z, &format!("branches/{id}/{part}.json"), budget.min(MAX_MEMBER))?;
|
||||
let json = unpacked.read(z, &format!("branches/{id}/{part}.json"), budget.min(MAX_MEMBER))?;
|
||||
budget = budget.saturating_sub(json.len() as u64);
|
||||
let (mut doc, _) = from_json(std::str::from_utf8(&json).map_err(|e| FormatError::Corrupt(e.to_string()))?)?;
|
||||
let hashes = e.get(list).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str);
|
||||
@@ -916,7 +988,7 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
|
||||
time: e.get("time").and_then(Value::as_u64).unwrap_or(0),
|
||||
client: e.get("client").and_then(Value::as_u64).and_then(|c| u32::try_from(c).ok()).unwrap_or(0),
|
||||
state: e.get("state").and_then(|s| BranchState::deserialize(s).ok()).unwrap_or(BranchState::Active),
|
||||
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget),
|
||||
versions: read_versions(z, &format!("branches/{id}/versions"), images, issues, &mut budget, unpacked),
|
||||
base,
|
||||
head,
|
||||
});
|
||||
@@ -996,6 +1068,8 @@ mod staging_tests;
|
||||
#[cfg(test)]
|
||||
mod thumbnail_tests;
|
||||
#[cfg(test)]
|
||||
mod unpacked_total_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{ImageData, LibraryManifest, NodeKind, PublishedLibrary, used_images};
|
||||
|
||||
use crate::{FormatError, MAX_MEMBER, from_json, image_data, io_err, json_options, put, put_json, read_member};
|
||||
use crate::{FormatError, MAX_MEMBER, Unpacked, from_json, image_data, io_err, json_options, put, put_json};
|
||||
|
||||
const MANIFEST: &str = "library/manifest.json";
|
||||
const SNAPSHOT: &str = "library/document.json";
|
||||
@@ -52,22 +52,22 @@ pub(crate) fn write<W: Write + Seek>(z: &mut zip::ZipWriter<W>, lib: &PublishedL
|
||||
}
|
||||
|
||||
/// The published library in an archive (`None` when the file was never published). `own` are
|
||||
/// the images already read from `images/`; `budget` caps the bytes its snapshot and images unpack
|
||||
/// to, together (beyond the per-part caps).
|
||||
/// the images already read from `images/`; what it unpacks comes out of `unpacked` (beyond the
|
||||
/// per-part caps).
|
||||
pub(crate) fn read<R: Read + Seek>(
|
||||
z: &mut zip::ZipArchive<R>,
|
||||
own: &BTreeMap<String, ImageData>,
|
||||
budget: u64,
|
||||
unpacked: &mut Unpacked,
|
||||
) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
if z.index_for_name(MANIFEST).is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut manifest: LibraryManifest = serde_json::from_slice(&read_member(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
|
||||
let mut manifest: LibraryManifest = serde_json::from_slice(&unpacked.read(z, MANIFEST, MAX_MANIFEST)?).map_err(corrupt)?;
|
||||
if !is_valid_library_id(&manifest.id) {
|
||||
return Err(corrupt("the library has an invalid id"));
|
||||
}
|
||||
let text = String::from_utf8(read_member(z, SNAPSHOT, budget.min(MAX_MEMBER))?).map_err(corrupt)?;
|
||||
let mut budget = budget.saturating_sub(text.len() as u64).min(MAX_IMAGE_BYTES);
|
||||
let text = String::from_utf8(unpacked.read(z, SNAPSHOT, MAX_MEMBER)?).map_err(corrupt)?;
|
||||
let mut budget = MAX_IMAGE_BYTES;
|
||||
let (mut doc, _) = from_json(&text)?;
|
||||
for hash in used_images(&doc) {
|
||||
if !ImageData::is_valid_key(&hash) {
|
||||
@@ -79,7 +79,7 @@ pub(crate) fn read<R: Read + Seek>(
|
||||
}
|
||||
let name = [format!("{IMAGES}{hash}"), format!("images/{hash}")].into_iter().find(|n| z.index_for_name(n).is_some());
|
||||
// A missing or oversized image shows as a placeholder, like in any other file.
|
||||
let Some(data) = name.and_then(|n| read_member(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
|
||||
let Some(data) = name.and_then(|n| unpacked.read(z, &n, budget.min(MAX_MEMBER)).ok()) else { continue };
|
||||
budget = budget.saturating_sub(data.len() as u64);
|
||||
doc.images.insert(hash, image_data(data));
|
||||
}
|
||||
@@ -100,16 +100,15 @@ pub(crate) fn read<R: Read + Seek>(
|
||||
/// Read the published library of a `.figcraft` file (only its library entries are read): `None`
|
||||
/// when the file was never published, an error when it isn't a FigCraft archive or is damaged.
|
||||
pub fn read_library<R: Read + Seek>(r: R) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
|
||||
read(&mut z, &BTreeMap::new(), u64::MAX)
|
||||
read_library_within(r, u64::MAX)
|
||||
}
|
||||
|
||||
/// [`read_library`] unpacking at most `max_bytes` in all (snapshot and images: an archive that
|
||||
/// would unpack to more is refused, or loses the images past it), for libraries from people the
|
||||
/// reader doesn't trust (a server).
|
||||
/// [`read_library`] unpacking at most `max_bytes` in all (manifest, snapshot and images: an
|
||||
/// archive that would unpack to more is refused, or loses the images past it), for libraries from
|
||||
/// people the reader doesn't trust (a server).
|
||||
pub fn read_library_within<R: Read + Seek>(r: R, max_bytes: u64) -> Result<Option<PublishedLibrary>, FormatError> {
|
||||
let mut z = zip::ZipArchive::new(r).map_err(corrupt)?;
|
||||
read(&mut z, &BTreeMap::new(), max_bytes)
|
||||
read(&mut z, &BTreeMap::new(), &mut Unpacked::new(max_bytes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use super::*;
|
||||
use crate::{add_image, load_bytes, save_bytes};
|
||||
use crate::{add_image, load_bytes, read_member, save_bytes};
|
||||
use figcraft_doc::color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::{Document, LibraryAsset, NodeKind};
|
||||
|
||||
|
||||
@@ -397,6 +397,22 @@ impl Default for Space {
|
||||
}
|
||||
}
|
||||
|
||||
/// A layer [`Ctx::build`] has yet to import, with where it goes.
|
||||
struct Layer<'a> {
|
||||
j: &'a Value,
|
||||
parent: NodeId,
|
||||
space: Space,
|
||||
parent_mode: Option<LayoutMode>,
|
||||
depth: usize,
|
||||
root_abs: bool,
|
||||
}
|
||||
|
||||
/// [`Ctx::build`]'s work list: layers to import, and groups to fit once their children are in.
|
||||
enum Build<'a> {
|
||||
Layer(Layer<'a>),
|
||||
Fit(NodeId),
|
||||
}
|
||||
|
||||
pub(crate) struct Ctx<'a> {
|
||||
doc: Document,
|
||||
images: ImageResolver<'a>,
|
||||
@@ -514,8 +530,26 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// Import layer `j` (and its subtree) under `parent`. `root_abs`: place it by its absolute
|
||||
/// bounds (roots of a nodes response, whose parent isn't in the file).
|
||||
/// bounds (roots of a nodes response, whose parent isn't in the file). Depth first in the
|
||||
/// file's order, on a work list rather than the call stack (#1101): a level took about 4 KB of
|
||||
/// stack, so the deepest files filled the browser's 1 MB.
|
||||
fn build(&mut self, j: &'a Value, parent: NodeId, space: Space, parent_mode: Option<LayoutMode>, depth: usize, root_abs: bool) {
|
||||
let mut work = vec![Build::Layer(Layer { j, parent, space, parent_mode, depth, root_abs })];
|
||||
while let Some(step) = work.pop() {
|
||||
match step {
|
||||
Build::Layer(layer) => self.build_layer(layer, &mut work),
|
||||
// A group or boolean fits its children once all of them are in.
|
||||
Build::Fit(id) => {
|
||||
let _ = self.doc.fit_to_children(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One layer of [`Self::build`]: its node, then its children pushed on `work` (and, when its
|
||||
/// bounds follow them, the fit after them).
|
||||
fn build_layer(&mut self, layer: Layer<'a>, work: &mut Vec<Build<'a>>) {
|
||||
let Layer { j, parent, space, parent_mode, depth, root_abs } = layer;
|
||||
if depth > MAX_DEPTH {
|
||||
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
|
||||
return;
|
||||
@@ -570,12 +604,13 @@ impl<'a> Ctx<'a> {
|
||||
return;
|
||||
}
|
||||
let inner = Space { world, frame: if follows { space.frame } else { world } };
|
||||
for c in kids {
|
||||
self.build(c, id, inner, mode, depth + 1, false);
|
||||
}
|
||||
if follows {
|
||||
let _ = self.doc.fit_to_children(id);
|
||||
work.push(Build::Fit(id));
|
||||
}
|
||||
// Reversed, so the first child comes off the list first.
|
||||
work.extend(
|
||||
kids.iter().rev().map(|c| Build::Layer(Layer { j: c, parent: id, space: inner, parent_mode: mode, depth: depth + 1, root_abs: false })),
|
||||
);
|
||||
}
|
||||
|
||||
fn kind(&mut self, ty: &str, j: &'a Value) -> Option<NodeKind> {
|
||||
|
||||
@@ -82,7 +82,8 @@ impl FileKind {
|
||||
let ext = path.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"figcraft" => FileKind::FigCraft,
|
||||
"fig" => FileKind::Fig,
|
||||
// A FigJam `.jam` is the same archive.
|
||||
"fig" | "jam" => FileKind::Fig,
|
||||
"sketch" => FileKind::Sketch,
|
||||
"svg" => FileKind::Svg,
|
||||
"png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "tif" | "tiff" => FileKind::Image,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! One budget for everything a `.figcraft` file unpacks when it opens (#1079): the document,
|
||||
//! images, version history and branches share it, beyond each area's own cap, and what passes it
|
||||
//! is left out (or, for the document, refused) with a note that says so. Images left out this way
|
||||
//! stay in the file like those past the images' own budget (#1313).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::{IMAGE_BUDGET, MAX_UNPACKED, load_limited, to_json};
|
||||
|
||||
fn png(k: u8) -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 7, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
format!("{:x}", sha2::Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
fn zip_of(members: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
for (name, bytes) in members {
|
||||
z.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(bytes).unwrap();
|
||||
}
|
||||
z.finish().unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
/// A crafted file: a document, two images, and a version history with one snapshot, each well
|
||||
/// within its own cap. The member sizes, in that order.
|
||||
fn crafted() -> (Vec<u8>, [u64; 5]) {
|
||||
let doc = serde_json::to_vec(&to_json(&Document::new("Crafted")).unwrap()).unwrap();
|
||||
let (a, b) = (png(1), png(2));
|
||||
let index = br#"{"versions": [{"id": "v1", "name": "First"}]}"#.to_vec();
|
||||
let snapshot = doc.clone();
|
||||
let sizes = [doc.len(), a.len(), b.len(), index.len(), snapshot.len()].map(|n| n as u64);
|
||||
let members = vec![
|
||||
("document.json".to_string(), doc),
|
||||
(format!("images/{}", hash(&a)), a),
|
||||
(format!("images/{}", hash(&b)), b),
|
||||
("versions/index.json".to_string(), index),
|
||||
("versions/v1.json".to_string(), snapshot),
|
||||
];
|
||||
(zip_of(&members), sizes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everything_within_the_total_loads() {
|
||||
let (bytes, sizes) = crafted();
|
||||
let loaded = load_limited(&bytes, IMAGE_BUDGET, sizes.iter().sum()).unwrap();
|
||||
assert_eq!(loaded.doc.images.len(), 2);
|
||||
assert_eq!(loaded.versions.len(), 1);
|
||||
assert!(loaded.issues.is_empty(), "{:?}", loaded.issues);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_passes_the_total_is_left_out_with_a_note() {
|
||||
let (bytes, [doc, a, b, index, _]) = crafted();
|
||||
// Room for the document and one image: the other image stays in the file, left out.
|
||||
let loaded = load_limited(&bytes, IMAGE_BUDGET, doc + a.max(b)).unwrap();
|
||||
assert_eq!(loaded.doc.images.len(), 1);
|
||||
assert_eq!(loaded.images_past_budget, 1, "kept in the file, so saving over it is refused");
|
||||
assert!(loaded.issues.iter().any(|i| i.contains("the file's contents together pass")), "{:?}", loaded.issues);
|
||||
// Room for the document, the images and the index, not the snapshot: the version is skipped.
|
||||
let loaded = load_limited(&bytes, IMAGE_BUDGET, doc + a + b + index).unwrap();
|
||||
assert_eq!(loaded.doc.images.len(), 2);
|
||||
assert!(loaded.versions.is_empty());
|
||||
let note = loaded.issues.iter().find(|i| i.contains("version `v1` was skipped")).unwrap();
|
||||
assert!(note.contains("the file's contents together pass"), "{note}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_past_the_total_is_refused_with_the_reason() {
|
||||
let (bytes, [doc, ..]) = crafted();
|
||||
let Err(e) = load_limited(&bytes, IMAGE_BUDGET, doc - 1) else { panic!("loaded") };
|
||||
assert!(e.to_string().contains("document.json: too large: the file's contents together pass"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_total_leaves_room_for_the_largest_real_files() {
|
||||
// A 900 000-layer document unpacks to 1.5 GB; with images up to their own cap it fits, so a
|
||||
// file whose images pass that cap (#1313) loads as many as before.
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
const {
|
||||
assert!(MAX_UNPACKED >= 1_500_000_000 + IMAGE_BUDGET.bytes)
|
||||
};
|
||||
// Below what the per-area caps added up to (about 10 GB).
|
||||
const { assert!(MAX_UNPACKED < 10 * 1024 * 1024 * 1024) };
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Grid auto-placement on an occupancy bitmap (#1083) places every child exactly where the plain
|
||||
//! cell-by-cell search did, on randomised grids with anchored, half-anchored and spanning
|
||||
//! children, while no longer testing every cell of a span at every position.
|
||||
|
||||
use figcraft_doc::{AutoLayout, Document, NodeId, NodeKind};
|
||||
|
||||
use crate::{GridCell, MAX_GRID_CELLS, MAX_TRACKS, grid_place};
|
||||
|
||||
/// The cell-by-cell placement `grid_place` had before #1083, kept as the reference.
|
||||
fn reference(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> Vec<GridCell> {
|
||||
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
|
||||
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
|
||||
let mut occ: Vec<Vec<bool>> = Vec::new();
|
||||
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
|
||||
};
|
||||
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
while occ.len() < r + rs {
|
||||
occ.push(vec![false; cols]);
|
||||
}
|
||||
for row in occ.iter_mut().skip(r).take(rs) {
|
||||
for cell in row.iter_mut().skip(c).take(cs) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
let spans = |id: NodeId| {
|
||||
let cl = doc.node(id).unwrap().child_layout.clone();
|
||||
let cs = (cl.column_span() as usize).min(cols);
|
||||
let rs = (cl.row_span() as usize).min(max_rows);
|
||||
(cl.grid_column.map(|c| (c as usize).min(cols - cs)), cl.grid_row.map(|r| (r as usize).min(max_rows - rs)), cs, rs)
|
||||
};
|
||||
let mut placed: Vec<Option<GridCell>> = vec![None; kids.len()];
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if let (Some(c), Some(r), cs, rs) = spans(*id) {
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
}
|
||||
let mut cursor = (0usize, 0usize);
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if placed[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let (col, row, cs, rs) = spans(*id);
|
||||
let last_row = max_rows - rs;
|
||||
let (c, r) = match (col, row) {
|
||||
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
|
||||
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
|
||||
(None, None) => {
|
||||
let mut found = None;
|
||||
let (mut r, mut c) = cursor;
|
||||
while r <= last_row {
|
||||
if c + cs <= cols && free(&occ, c, r, cs, rs) {
|
||||
found = Some((c, r));
|
||||
break;
|
||||
}
|
||||
c += 1;
|
||||
if c + cs > cols {
|
||||
c = 0;
|
||||
r += 1;
|
||||
}
|
||||
}
|
||||
let (c, r) = found.unwrap_or((0, last_row));
|
||||
cursor = (r, c + cs);
|
||||
(c, r)
|
||||
}
|
||||
};
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
placed[i] = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
placed.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// A small deterministic generator (xorshift).
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0 % n.max(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// A grid of `cols` columns with `n` children, each anchored, half-anchored or free, with spans
|
||||
/// up to `span` (sometimes past the grid, which clamps them).
|
||||
fn grid(rng: &mut Rng, cols: u32, n: usize, span: u64) -> (Document, AutoLayout, Vec<NodeId>) {
|
||||
let mut d = Document::new("grid");
|
||||
let page = d.first_page().unwrap();
|
||||
let frame = d.create(page, NodeKind::Frame, "G", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let kids: Vec<NodeId> = (0..n)
|
||||
.map(|_| {
|
||||
let id = d.create(frame, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let anchor = rng.below(4);
|
||||
let (column, row) = (rng.below(u64::from(cols) + 3) as u32, rng.below(40) as u32);
|
||||
let cl = &mut d.get_mut(id).unwrap().child_layout;
|
||||
cl.grid_column = matches!(anchor, 1 | 3).then_some(column);
|
||||
cl.grid_row = matches!(anchor, 2 | 3).then_some(row);
|
||||
cl.grid_column_span = rng.below(span + 1) as u32;
|
||||
cl.grid_row_span = rng.below(span + 1) as u32;
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
let al = AutoLayout { grid_columns: cols, ..Default::default() };
|
||||
(d, al, kids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_match_the_cell_by_cell_search_on_random_grids() {
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
for case in 0..400 {
|
||||
let cols = [1, 2, 3, 7, 63, 64, 65, 130][case % 8];
|
||||
let (n, span) = (rng.below(60) as usize + 1, [1, 3, 9, 70][case % 4]);
|
||||
let (d, al, kids) = grid(&mut rng, cols, n, span);
|
||||
let (cells, _) = grid_place(&d, &al, &kids);
|
||||
assert_eq!(cells, reference(&d, &al, &kids), "case {case}: {cols} columns, {n} children, spans up to {span}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_grid_puts_the_rest_on_its_last_rows_as_before() {
|
||||
// 1000 columns leave 1000 rows; a child too tall for what is left goes to the last row.
|
||||
let mut rng = Rng(7);
|
||||
let (mut d, al, kids) = grid(&mut rng, 1000, 30, 1);
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
let cl = &mut d.get_mut(*id).unwrap().child_layout;
|
||||
(cl.grid_column, cl.grid_row, cl.grid_column_span, cl.grid_row_span) = (Some(0), None, 1000, if i % 2 == 0 { 400 } else { 1 });
|
||||
}
|
||||
let (cells, _) = grid_place(&d, &al, &kids);
|
||||
assert_eq!(cells, reference(&d, &al, &kids));
|
||||
assert!(cells.iter().any(|c| c.row == 600 && c.row_span == 400), "a tall child past the free rows sits at the last row it fits");
|
||||
}
|
||||
+21
-29
@@ -689,20 +689,7 @@ impl GridGeometry {
|
||||
pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<GridCell>, usize) {
|
||||
let cols = (al.grid_columns as usize).clamp(1, MAX_TRACKS);
|
||||
let max_rows = (MAX_GRID_CELLS / cols).clamp(1, MAX_TRACKS);
|
||||
let mut occ: Vec<Vec<bool>> = Vec::new();
|
||||
let free = |occ: &Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
(r..r + rs).all(|ri| occ.get(ri).is_none_or(|row| (c..c + cs).all(|ci| !row.get(ci).copied().unwrap_or(false))))
|
||||
};
|
||||
let mark = |occ: &mut Vec<Vec<bool>>, c: usize, r: usize, cs: usize, rs: usize| {
|
||||
while occ.len() < r + rs {
|
||||
occ.push(vec![false; cols]);
|
||||
}
|
||||
for row in occ.iter_mut().skip(r).take(rs) {
|
||||
for cell in row.iter_mut().skip(c).take(cs) {
|
||||
*cell = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut occ = occupancy::Occupancy::new(cols);
|
||||
let spans = |id: NodeId| {
|
||||
let cl = doc.node(id).map(|n| n.child_layout.clone()).unwrap_or_default();
|
||||
let cs = (cl.column_span() as usize).min(cols);
|
||||
@@ -713,7 +700,7 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
// 1. Fully anchored children.
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if let (Some(c), Some(r), cs, rs) = spans(*id) {
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
occ.mark(c, r, cs, rs);
|
||||
if let Some(slot) = placed.get_mut(i) {
|
||||
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
@@ -721,6 +708,10 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
}
|
||||
// 2. The rest, in order (a cursor keeps auto-placed children in reading order).
|
||||
let mut cursor = (0usize, 0usize); // (row, column)
|
||||
// Cells only ever get taken, so the first free row for a column span never moves up: each
|
||||
// (column, spans) looks on from where it last found one.
|
||||
let mut from_row: std::collections::HashMap<(usize, usize, usize), usize> = std::collections::HashMap::new();
|
||||
let mut scratch = Vec::new();
|
||||
for (i, id) in kids.iter().enumerate() {
|
||||
if placed.get(i).is_some_and(Option::is_some) {
|
||||
continue;
|
||||
@@ -728,28 +719,26 @@ pub fn grid_place(doc: &Document, al: &AutoLayout, kids: &[NodeId]) -> (Vec<Grid
|
||||
let (col, row, cs, rs) = spans(*id);
|
||||
let last_row = max_rows - rs;
|
||||
let (c, r) = match (col, row) {
|
||||
(Some(c), _) => (0..=last_row).find(|r| free(&occ, c, *r, cs, rs)).map(|r| (c, r)).unwrap_or((c, last_row)),
|
||||
(None, Some(r)) => (0..=cols - cs).find(|c| free(&occ, *c, r, cs, rs)).map(|c| (c, r)).unwrap_or((0, r)),
|
||||
(Some(c), _) => {
|
||||
let start = from_row.get(&(c, cs, rs)).copied().unwrap_or(0);
|
||||
let found = occ.first_row(c, cs, rs, start, last_row);
|
||||
from_row.insert((c, cs, rs), found.unwrap_or(last_row + 1));
|
||||
(c, found.unwrap_or(last_row))
|
||||
}
|
||||
(None, Some(r)) => (occ.first_column(r, rs, 0, cs, &mut scratch).unwrap_or(0), r),
|
||||
(None, None) => {
|
||||
let mut found = None;
|
||||
let (mut r, mut c) = cursor;
|
||||
while r <= last_row {
|
||||
if c + cs <= cols && free(&occ, c, r, cs, rs) {
|
||||
found = Some((c, r));
|
||||
break;
|
||||
}
|
||||
c += 1;
|
||||
if c + cs > cols {
|
||||
c = 0;
|
||||
r += 1;
|
||||
}
|
||||
let mut found = None;
|
||||
while r <= last_row && found.is_none() {
|
||||
found = occ.first_column(r, rs, c, cs, &mut scratch).map(|c| (c, r));
|
||||
(r, c) = (r + 1, 0);
|
||||
}
|
||||
let (c, r) = found.unwrap_or((0, last_row));
|
||||
cursor = (r, c + cs);
|
||||
(c, r)
|
||||
}
|
||||
};
|
||||
mark(&mut occ, c, r, cs, rs);
|
||||
occ.mark(c, r, cs, rs);
|
||||
if let Some(slot) = placed.get_mut(i) {
|
||||
*slot = Some(GridCell { id: *id, column: c, row: r, column_span: cs, row_span: rs });
|
||||
}
|
||||
@@ -1171,6 +1160,9 @@ mod content_align_tests;
|
||||
#[cfg(test)]
|
||||
mod depth_tests;
|
||||
#[cfg(test)]
|
||||
mod grid_place_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_tests;
|
||||
mod occupancy;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Which cells of a layout grid are taken, one bit per cell (#1083). Grid placement asks whether a
|
||||
//! span of cells is free a 64-bit word at a time and jumps past the cell that blocks it, instead of
|
||||
//! testing every cell of the span at every position.
|
||||
|
||||
/// The taken cells of a grid of `cols` columns, row by row.
|
||||
pub(crate) struct Occupancy {
|
||||
cols: usize,
|
||||
words: usize,
|
||||
bits: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The bits of columns `c..end` that fall in word `w`.
|
||||
fn mask(w: usize, c: usize, end: usize) -> u64 {
|
||||
let lo = c.max(w * 64) - w * 64;
|
||||
let hi = end.min((w + 1) * 64) - w * 64;
|
||||
match hi.saturating_sub(lo) {
|
||||
0 => 0,
|
||||
64 => u64::MAX,
|
||||
n => ((1u64 << n) - 1) << lo,
|
||||
}
|
||||
}
|
||||
|
||||
/// The last taken column of `c..c + cs` in `row`, if any.
|
||||
fn last_taken(row: &[u64], c: usize, cs: usize) -> Option<usize> {
|
||||
let end = c + cs;
|
||||
if cs == 0 {
|
||||
return None;
|
||||
}
|
||||
(c / 64..=(end - 1) / 64).rev().find_map(|w| {
|
||||
let v = row.get(w).copied().unwrap_or(0) & mask(w, c, end);
|
||||
(v != 0).then(|| w * 64 + 63 - v.leading_zeros() as usize)
|
||||
})
|
||||
}
|
||||
|
||||
impl Occupancy {
|
||||
/// An empty grid of `cols` columns. Rows are stored only once something takes a cell in them
|
||||
/// (or a row below them), so an empty or sparse grid allocates little.
|
||||
pub(crate) fn new(cols: usize) -> Self {
|
||||
Self { cols, words: cols.div_ceil(64), bits: Vec::new() }
|
||||
}
|
||||
|
||||
fn row(&self, r: usize) -> &[u64] {
|
||||
self.bits.get(r * self.words..(r + 1) * self.words).unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Take columns `c..c + cs` of rows `r..r + rs`.
|
||||
pub(crate) fn mark(&mut self, c: usize, r: usize, cs: usize, rs: usize) {
|
||||
let end = c + cs;
|
||||
if cs == 0 || rs == 0 {
|
||||
return;
|
||||
}
|
||||
let need = (r + rs) * self.words;
|
||||
if self.bits.len() < need {
|
||||
self.bits.resize(need, 0);
|
||||
}
|
||||
for ri in r..r + rs {
|
||||
for w in c / 64..=(end - 1) / 64 {
|
||||
if let Some(word) = self.bits.get_mut(ri * self.words + w) {
|
||||
*word |= mask(w, c, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first column from `from` where columns `c..c + cs` are free in rows `r..r + rs` and fit
|
||||
/// the grid (`scratch`: a buffer for the rows' union).
|
||||
pub(crate) fn first_column(&self, r: usize, rs: usize, from: usize, cs: usize, scratch: &mut Vec<u64>) -> Option<usize> {
|
||||
scratch.clear();
|
||||
scratch.resize(self.words, 0);
|
||||
for ri in r..r + rs {
|
||||
for (u, w) in scratch.iter_mut().zip(self.row(ri)) {
|
||||
*u |= w;
|
||||
}
|
||||
}
|
||||
let mut c = from;
|
||||
while c + cs <= self.cols {
|
||||
match last_taken(scratch, c, cs) {
|
||||
None => return Some(c),
|
||||
// No span starting at or before the taken column can include it free.
|
||||
Some(t) => c = t + 1,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The first row from `from` (at most `last`) where columns `c..c + cs` are free in `rs` rows
|
||||
/// in a row.
|
||||
pub(crate) fn first_row(&self, c: usize, cs: usize, rs: usize, from: usize, last: usize) -> Option<usize> {
|
||||
let mut run = 0;
|
||||
for r in from..last + rs {
|
||||
if last_taken(self.row(r), c, cs).is_some() {
|
||||
run = 0;
|
||||
continue;
|
||||
}
|
||||
run += 1;
|
||||
if run == rs {
|
||||
return Some(r + 1 - rs);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -164,9 +164,6 @@ impl Headless {
|
||||
"select.children" if self.single_text().is_some() => {
|
||||
self.execute("text.edit.begin", &json!({"id": self.single_text()}))?;
|
||||
}
|
||||
"select.children" if self.session.tool.pen.is_some() => {
|
||||
let _ = self.session.pen_end();
|
||||
}
|
||||
// Enter on shapes edits them as vectors (several at once).
|
||||
"select.children" if self.vector_targets().is_some() => {
|
||||
self.execute("vector.edit.begin", &json!({"ids": self.vector_targets()}))?;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Input caps for the sweep: a boolean, a simplification or an arrangement past them is refused
|
||||
//! with [`PathOpsError::TooComplex`] instead of running for unbounded time and memory (a crafted
|
||||
//! file can hold any number of segments, crossing each other as often as it likes).
|
||||
//!
|
||||
//! The caps sit far above real designs: the boolean groups in the example files sweep at most 55
|
||||
//! segments with 155 possible crossings per step and 75 segments over a whole fold, and their
|
||||
//! largest outline has about 4 000 segments. In a native release build the sweep takes 0.3 s for
|
||||
//! 200 000 segments and 1.8 s for 2 million crossings, and a union of 1 000 squares one by one
|
||||
//! (2 million segments swept) takes 1.8 s.
|
||||
|
||||
use figcraft_geom::kurbo::{BezPath, ParamCurveExtrema, Rect};
|
||||
|
||||
use crate::PathOpsError;
|
||||
|
||||
/// The most segments one sweep takes, over all its inputs.
|
||||
pub const MAX_SEGMENTS: usize = 250_000;
|
||||
|
||||
/// The most crossings one sweep may have to split at, counted as the pairs of segments whose
|
||||
/// bounds overlap (every crossing is one of them).
|
||||
pub const MAX_CROSSINGS: u64 = 2_000_000;
|
||||
|
||||
/// The most segments a fold of several operands sweeps over all its steps ([`FoldBudget`]): each
|
||||
/// step sweeps the result so far again.
|
||||
pub const MAX_FOLD_SEGMENTS: usize = 4_000_000;
|
||||
|
||||
/// How complex the sweep of some paths is ([`complexity`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Complexity {
|
||||
pub segments: usize,
|
||||
/// Pairs of segments whose bounds overlap (touching counts), counted up to [`MAX_CROSSINGS`] + 1.
|
||||
pub crossings: u64,
|
||||
}
|
||||
|
||||
/// The segments of `paths` and the pairs of them that may cross; `None` past [`MAX_SEGMENTS`]
|
||||
/// (counting stops there, so a huge input is refused at once).
|
||||
pub fn complexity<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Option<Complexity> {
|
||||
let mut boxes = Vec::new();
|
||||
for seg in paths.into_iter().flat_map(BezPath::segments) {
|
||||
if boxes.len() == MAX_SEGMENTS {
|
||||
return None;
|
||||
}
|
||||
boxes.push(seg.bounding_box());
|
||||
}
|
||||
Some(Complexity { segments: boxes.len(), crossings: overlapping_pairs(&boxes, MAX_CROSSINGS) })
|
||||
}
|
||||
|
||||
/// Refuse `paths` past [`MAX_SEGMENTS`] or [`MAX_CROSSINGS`].
|
||||
pub(crate) fn check<'a>(paths: impl IntoIterator<Item = &'a BezPath>) -> Result<(), PathOpsError> {
|
||||
match complexity(paths) {
|
||||
Some(c) if c.crossings <= MAX_CROSSINGS => Ok(()),
|
||||
_ => Err(PathOpsError::TooComplex),
|
||||
}
|
||||
}
|
||||
|
||||
/// What a fold of several operands has swept so far, against [`MAX_FOLD_SEGMENTS`].
|
||||
#[derive(Debug, Default)]
|
||||
pub struct FoldBudget(usize);
|
||||
|
||||
impl FoldBudget {
|
||||
/// Count the step combining `acc` with `p`: refused once the fold would sweep more than
|
||||
/// [`MAX_FOLD_SEGMENTS`].
|
||||
pub fn spend(&mut self, acc: &BezPath, p: &BezPath) -> Result<(), PathOpsError> {
|
||||
self.0 = self.0.saturating_add(acc.segments().count()).saturating_add(p.segments().count());
|
||||
if self.0 > MAX_FOLD_SEGMENTS { Err(PathOpsError::TooComplex) } else { Ok(()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How many pairs of `boxes` overlap (touching counts), counted up to `limit` + 1, in
|
||||
/// O(n log n): a sweep across keeps the boxes it is inside, and a box it enters overlaps those
|
||||
/// of them that neither end above it nor start below it.
|
||||
fn overlapping_pairs(boxes: &[Rect], limit: u64) -> u64 {
|
||||
// The y coordinates by rank, for counting the boxes the sweep is inside by their top and bottom.
|
||||
let mut ys: Vec<f64> = boxes.iter().flat_map(|b| [b.y0, b.y1]).collect();
|
||||
ys.sort_unstable_by(f64::total_cmp);
|
||||
ys.dedup();
|
||||
let below = |y: f64| ys.partition_point(|v| *v < y);
|
||||
let up_to = |y: f64| ys.partition_point(|v| *v <= y);
|
||||
// Entering at x0 before leaving at x1, so boxes that only touch across count.
|
||||
let mut events: Vec<(f64, bool, &Rect)> = boxes.iter().flat_map(|b| [(b.x0, false, b), (b.x1, true, b)]).collect();
|
||||
events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
let (mut tops, mut bottoms) = (Fenwick::new(ys.len()), Fenwick::new(ys.len()));
|
||||
let (mut inside, mut pairs) = (0u64, 0u64);
|
||||
for (_, leaving, b) in events {
|
||||
if leaving {
|
||||
tops.add(below(b.y0), -1);
|
||||
bottoms.add(below(b.y1), -1);
|
||||
inside = inside.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
let ending_above = bottoms.count_below(below(b.y0));
|
||||
let starting_below = inside.saturating_sub(tops.count_below(up_to(b.y1)));
|
||||
pairs += inside.saturating_sub(ending_above + starting_below);
|
||||
if pairs > limit {
|
||||
return pairs;
|
||||
}
|
||||
tops.add(below(b.y0), 1);
|
||||
bottoms.add(below(b.y1), 1);
|
||||
inside += 1;
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Counts by rank, with the count below a rank in O(log n) (a Fenwick tree).
|
||||
struct Fenwick(Vec<i64>);
|
||||
|
||||
impl Fenwick {
|
||||
fn new(ranks: usize) -> Self {
|
||||
Self(vec![0; ranks + 1])
|
||||
}
|
||||
|
||||
fn add(&mut self, rank: usize, by: i64) {
|
||||
let mut i = rank + 1;
|
||||
while let Some(c) = self.0.get_mut(i) {
|
||||
*c += by;
|
||||
i += i.isolate_lowest_one();
|
||||
}
|
||||
}
|
||||
|
||||
/// How many were added at ranks below `rank`.
|
||||
fn count_below(&self, rank: usize) -> u64 {
|
||||
let (mut i, mut n) = (rank, 0i64);
|
||||
while i > 0
|
||||
&& let Some(c) = self.0.get(i)
|
||||
{
|
||||
n += c;
|
||||
i -= i.isolate_lowest_one();
|
||||
}
|
||||
u64::try_from(n).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user