M9: branches: create, update from main, review and merge (#119)
Branches live inside the .figcraft file (base and head snapshots, own id space). Update from main and merge are three-way merges over the collaboration change sets; a conflict (same property changed on both sides, or delete vs edit) must be resolved branch-or-main before merging, which is one undo step on main. The review dialog reuses Dev Mode's compare view; branch tabs show a banner; File > Create branch; branch.* commands for CLI and MCP.
This commit is contained in:
parent
5b321a779c
commit
7f9f2a0873
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
|
|
@ -13,13 +13,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
|||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (203 / 203) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (204 / 204) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~79%** (core editor ~90%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
| Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
|
||||
| Remaining to beta | **~200–365 h** one agent | estimated |
|
||||
| Remaining to full parity | **~380–725 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
|
||||
| Remaining to full parity | **~378–722 h** one agent (~95–165 h wall-clock with 5 agents) | estimated |
|
||||
|
||||
### Readiness by audience
|
||||
|
||||
|
|
@ -166,6 +166,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
//! writes back what the user's step replaced where nobody else wrote since. Refused batches
|
||||
//! leave the history with a [`Notice`]. Offline, batches queue; on reconnect the server sends
|
||||
//! what the client missed (or a snapshot) and the queue goes out ([`Client::reconnect`]).
|
||||
//! - [`merge`] is the three-way merge behind branches: what a branch and its main file changed
|
||||
//! since the branch was made, their conflicts, and one merged into the other.
|
||||
//! - [`sim`] runs a server and several clients over in-memory links with random delays, dropped
|
||||
//! connections and an undo checker (tests).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
|
@ -25,6 +27,7 @@ mod diff;
|
|||
mod docprops;
|
||||
pub mod frac;
|
||||
mod history;
|
||||
pub mod merge;
|
||||
pub mod message;
|
||||
pub mod replica;
|
||||
pub mod server;
|
||||
|
|
@ -127,6 +130,8 @@ mod frac_tests;
|
|||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
#[cfg(test)]
|
||||
mod reconnect_tests;
|
||||
#[cfg(test)]
|
||||
mod server_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,301 @@
|
|||
//! Three-way merge, for branches of a file: what a branch and its main file each changed since
|
||||
//! the branch was made (two [`diff`]s from their common base), where those changes conflict, and
|
||||
//! one side's changes merged into the other side.
|
||||
//!
|
||||
//! A conflict is the same property (or document-level entry) changed on both sides to different
|
||||
//! values, or a layer deleted on one side while the other side edited it or something inside it
|
||||
//! (including adding layers to it or moving layers into or out of it). Nothing that conflicts is
|
||||
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
|
||||
//! changes to it are left out. Everything else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Prop};
|
||||
use crate::replica::{Mode, Positions, Replica, create_change};
|
||||
use crate::{Error, diff};
|
||||
|
||||
/// One side of a merge.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Side {
|
||||
Branch,
|
||||
Main,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
pub fn other(self) -> Side {
|
||||
match self {
|
||||
Side::Branch => Side::Main,
|
||||
Side::Main => Side::Branch,
|
||||
}
|
||||
}
|
||||
|
||||
/// `"branch"` or `"main"`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Side::Branch => "branch",
|
||||
Side::Main => "main",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Side> {
|
||||
match s {
|
||||
"branch" => Some(Side::Branch),
|
||||
"main" => Some(Side::Main),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a conflict is about.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum Key {
|
||||
/// A layer property. A layer's parent and position are one property (where it is).
|
||||
Node(NodeId, Prop),
|
||||
/// A layer deleted on one side and edited (it or what's inside it) on the other.
|
||||
Layer(NodeId),
|
||||
/// A document-level property.
|
||||
Doc(DocProp),
|
||||
/// A document-level entry (a style, variable, comment thread…).
|
||||
Entry(Entry),
|
||||
}
|
||||
|
||||
/// The serde name of `v` (`fills`, `styleOrder`…).
|
||||
fn camel<T: Serialize>(v: &T) -> String {
|
||||
match serde_json::to_value(v) {
|
||||
Ok(serde_json::Value::String(s)) => s,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `12/fills`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the keys resolutions use).
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Key::Node(id, prop) => write!(f, "{id}/{}", camel(prop)),
|
||||
Key::Layer(id) => write!(f, "{id}/layer"),
|
||||
Key::Doc(prop) => write!(f, "doc/{}", camel(prop)),
|
||||
Key::Entry(e) => match e {
|
||||
Entry::Style(k) => write!(f, "style/{k}"),
|
||||
Entry::Comment(k) => write!(f, "comment/{k}"),
|
||||
Entry::Collection(k) => write!(f, "collection/{k}"),
|
||||
Entry::Variable(k) => write!(f, "variable/{k}"),
|
||||
Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"),
|
||||
Entry::Image(k) => write!(f, "image/{k}"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Changes that conflict, and which side's to keep.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Conflict {
|
||||
pub key: Key,
|
||||
/// Layer conflicts: the side that deleted the layer.
|
||||
pub deleted_by: Option<Side>,
|
||||
/// The branch's changes to it (indexes into [`Plan::branch`]).
|
||||
pub branch: Vec<usize>,
|
||||
/// The main file's changes to it (indexes into [`Plan::main`]).
|
||||
pub main: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Conflict {
|
||||
/// `side`'s changes to it.
|
||||
pub fn changes(&self, side: Side) -> &[usize] {
|
||||
match side {
|
||||
Side::Branch => &self.branch,
|
||||
Side::Main => &self.main,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What each side changed since the base, and where they conflict.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Plan {
|
||||
base: Replica,
|
||||
/// The branch's changes since the base.
|
||||
pub branch: Vec<Change>,
|
||||
/// The main file's changes since the base.
|
||||
pub main: Vec<Change>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
branch_positions: Positions,
|
||||
main_positions: Positions,
|
||||
}
|
||||
|
||||
/// Document-level properties that belong to each copy, not to the work: the file name (a branch
|
||||
/// is named after itself) and the page it opens on.
|
||||
fn merges(c: &Change) -> bool {
|
||||
!matches!(c, Change::SetDoc { prop: DocProp::Name | DocProp::CurrentPage, .. })
|
||||
}
|
||||
|
||||
/// What a change writes, for finding the same write on both sides (creations: nothing; ids are
|
||||
/// never made twice, see [`Document::set_client_id`]).
|
||||
fn key_of(c: &Change) -> Option<Key> {
|
||||
Some(match c {
|
||||
Change::Set { id, prop: Prop::Position, .. } => Key::Node(*id, Prop::Parent),
|
||||
Change::Set { id, prop, .. } => Key::Node(*id, *prop),
|
||||
Change::Delete { id } => Key::Layer(*id),
|
||||
Change::SetDoc { prop, .. } => Key::Doc(*prop),
|
||||
Change::Put { entry, .. } | Change::Remove { entry } => Key::Entry(entry.clone()),
|
||||
Change::Create { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
let mut out: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
if let Some(k) = key_of(c) {
|
||||
out.entry(k).or_default().push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The changes (indexes) that edit what's in `gone`: write one of its layers, create a layer in it
|
||||
/// (or in a layer created in it) or move a layer into it. Changes come in [`diff`] order, so
|
||||
/// creations come parents first and before moves.
|
||||
fn touching(gone: &BTreeSet<NodeId>, changes: &[Change]) -> Vec<usize> {
|
||||
let mut doomed = gone.clone();
|
||||
let mut out = Vec::new();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
let hit = match c {
|
||||
Change::Set { id, prop: Prop::Parent, value } => {
|
||||
doomed.contains(id) || value.read::<Option<NodeId>>("parent").ok().flatten().is_some_and(|p| doomed.contains(&p))
|
||||
}
|
||||
Change::Set { id, .. } => doomed.contains(id),
|
||||
Change::Create { id, parent, .. } => {
|
||||
let hit = parent.is_some_and(|p| doomed.contains(&p));
|
||||
if hit {
|
||||
doomed.insert(*id);
|
||||
}
|
||||
hit
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if hit {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The changes on `branch` and on `main` since `base` (the main file when the branch was made or
|
||||
/// last updated), and their conflicts.
|
||||
pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan, Error> {
|
||||
let base_r = Replica::new(base.clone());
|
||||
let b = diff(&base_r, branch)?;
|
||||
let m = diff(&base_r, main)?;
|
||||
let bc: Vec<Change> = b.changes.into_iter().filter(merges).collect();
|
||||
let mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut conflicts = Vec::new();
|
||||
let km = by_key(&mc);
|
||||
for (k, bi) in by_key(&bc) {
|
||||
// Deletions conflict with edits (below); images are content-addressed, so never differ.
|
||||
if matches!(k, Key::Layer(_) | Key::Entry(Entry::Image(_))) {
|
||||
continue;
|
||||
}
|
||||
// shortcut: a layer inside an instance that follows its main conflicts along with the main's
|
||||
// layer (either side is fine: the engine re-derives it after merging); leave such layers
|
||||
// out once the plan can tell followed values from overrides.
|
||||
let Some(mi) = km.get(&k) else { continue };
|
||||
let same = bi.len() == mi.len() && bi.iter().zip(mi).all(|(x, y)| bc.get(*x) == mc.get(*y));
|
||||
if !same {
|
||||
conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() });
|
||||
}
|
||||
}
|
||||
for (side, changes, doc, other) in [(Side::Branch, &bc, branch, &mc), (Side::Main, &mc, main, &bc)] {
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
let Change::Delete { id } = c else { continue };
|
||||
// Layers moved out before the deletion survive it.
|
||||
let gone: BTreeSet<NodeId> = base.subtree(*id).into_iter().filter(|n| !doc.contains(*n)).collect();
|
||||
let edits = touching(&gone, other);
|
||||
if edits.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (branch, main) = if side == Side::Branch { (vec![i], edits) } else { (edits, vec![i]) };
|
||||
conflicts.push(Conflict { key: Key::Layer(*id), deleted_by: Some(side), branch, main });
|
||||
}
|
||||
}
|
||||
Ok(Plan {
|
||||
base: base_r,
|
||||
branch: bc,
|
||||
main: mc,
|
||||
conflicts,
|
||||
branch_positions: b.replica.positions().clone(),
|
||||
main_positions: m.replica.positions().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// The base, as a replica.
|
||||
pub fn base(&self) -> &Replica {
|
||||
&self.base
|
||||
}
|
||||
|
||||
/// `side`'s changes.
|
||||
pub fn changes(&self, side: Side) -> &[Change] {
|
||||
match side {
|
||||
Side::Branch => &self.branch,
|
||||
Side::Main => &self.main,
|
||||
}
|
||||
}
|
||||
|
||||
fn positions(&self, side: Side) -> &Positions {
|
||||
match side {
|
||||
Side::Branch => &self.branch_positions,
|
||||
Side::Main => &self.main_positions,
|
||||
}
|
||||
}
|
||||
|
||||
/// The conflicts `pick` has no side for.
|
||||
pub fn unresolved(&self, pick: &dyn Fn(&Key) -> Option<Side>) -> Vec<&Conflict> {
|
||||
self.conflicts.iter().filter(|c| pick(&c.key).is_none()).collect()
|
||||
}
|
||||
|
||||
/// Merge `from`'s changes into `into` (the other side's document, as the plan was made from),
|
||||
/// each conflict resolved by `pick`. Fails when a conflict has no side, or when the result is
|
||||
/// not a valid document. Returns the merged document and the layers it touched.
|
||||
pub fn merge(&self, into: &Document, from: Side, pick: &dyn Fn(&Key) -> Option<Side>) -> Result<(Document, BTreeSet<NodeId>), Error> {
|
||||
let unresolved: Vec<String> = self.unresolved(pick).iter().map(|c| c.key.to_string()).collect();
|
||||
if !unresolved.is_empty() {
|
||||
return Err(Error::Invalid(format!("unresolved conflicts: {}", unresolved.join(", "))));
|
||||
}
|
||||
let mut skip: BTreeSet<usize> = BTreeSet::new();
|
||||
let mut batch = Vec::new();
|
||||
for c in &self.conflicts {
|
||||
if pick(&c.key) != Some(from) {
|
||||
skip.extend(c.changes(from));
|
||||
} else if let (Key::Layer(id), Some(by)) = (&c.key, c.deleted_by)
|
||||
&& by != from
|
||||
{
|
||||
// `into` deleted the layer and `from`'s edits win: bring back what it deleted (as
|
||||
// it was in the base), then `from`'s edits apply to it.
|
||||
for n in self.base.document().subtree(*id) {
|
||||
if let Some(node) = self.base.document().node(n).filter(|_| !into.contains(n)) {
|
||||
batch.push(create_change(node, self.base.key(n))?);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// shortcut: images a side stopped using are kept (unused ones cost file size only); drop
|
||||
// them if files grow.
|
||||
let kept = |c: &Change| !matches!(c, Change::Remove { entry: Entry::Image(_) });
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c)).map(|(_, c)| c.clone()));
|
||||
let mut r = Replica::from_parts(into.clone(), self.positions(from.other()).clone());
|
||||
let applied = r.apply(&batch, Mode::Strict)?;
|
||||
Ok((r.document().clone(), applied.touched))
|
||||
}
|
||||
|
||||
/// `into` (the other side's document) with only `from`'s changes at `indexes` applied, those
|
||||
/// that apply (for showing what one side's changes to a conflict look like).
|
||||
pub fn with_changes(&self, into: &Document, from: Side, indexes: &[usize]) -> Document {
|
||||
let changes: Vec<Change> = indexes.iter().filter_map(|i| self.changes(from).get(*i).cloned()).collect();
|
||||
let mut r = Replica::from_parts(into.clone(), self.positions(from.other()).clone());
|
||||
let _ = r.apply(&changes, Mode::Lenient);
|
||||
r.document().clone()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
//! Three-way merges of documents edited directly (the engine's branch tests run real commands).
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, Style, StyleKind};
|
||||
|
||||
use crate::merge::{Key, Plan, Side, plan};
|
||||
|
||||
struct Files {
|
||||
base: Document,
|
||||
branch: Document,
|
||||
main: Document,
|
||||
frame: NodeId,
|
||||
kids: Vec<NodeId>,
|
||||
}
|
||||
|
||||
/// A frame with five rectangles; the branch allocates ids in its own space.
|
||||
fn files() -> Files {
|
||||
let mut base = Document::new("File");
|
||||
let page = base.first_page().unwrap();
|
||||
let frame = base.create(page, NodeKind::Frame, "Frame", 0.0, 0.0, 200.0, 200.0).unwrap();
|
||||
let kids = (0..5).map(|i| base.create(frame, NodeKind::Rectangle, &format!("R{i}"), 0.0, 0.0, 10.0, 10.0).unwrap()).collect();
|
||||
base.styles.push(style("S:1", Color::BLACK));
|
||||
let mut branch = base.clone();
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
let main = base.clone();
|
||||
Files { base, branch, main, frame, kids }
|
||||
}
|
||||
|
||||
fn style(id: &str, c: Color) -> Style {
|
||||
Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: vec![Paint::solid(c)] } }
|
||||
}
|
||||
|
||||
fn plan_of(f: &Files) -> Plan {
|
||||
plan(&f.base, &f.branch, &f.main).unwrap()
|
||||
}
|
||||
|
||||
fn all(side: Side) -> impl Fn(&Key) -> Option<Side> {
|
||||
move |_| Some(side)
|
||||
}
|
||||
|
||||
fn merged(f: &Files, p: &Plan, pick: Side) -> Document {
|
||||
let (doc, _) = p.merge(&f.main, Side::Branch, &all(pick)).unwrap();
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_edits_merge_both_ways() {
|
||||
let mut f = files();
|
||||
let (frame, k) = (f.frame, f.kids.clone());
|
||||
f.branch.get_mut(k[0]).unwrap().opacity = 0.5;
|
||||
let added = f.branch.create(frame, NodeKind::Rectangle, "New", 5.0, 5.0, 20.0, 20.0).unwrap();
|
||||
f.main.get_mut(k[1]).unwrap().fills = vec![Paint::solid(Color::WHITE)];
|
||||
f.main.remove(k[4]).unwrap();
|
||||
let main_new = f.main.create(frame, NodeKind::Rectangle, "Main new", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
assert_ne!(added, main_new, "the branch makes ids in its own space");
|
||||
let p = plan_of(&f);
|
||||
assert!(p.conflicts.is_empty(), "{:?}", p.conflicts);
|
||||
let none = |_: &Key| None;
|
||||
for (into, from) in [(&f.main, Side::Branch), (&f.branch, Side::Main)] {
|
||||
let (doc, _) = p.merge(into, from, &none).unwrap();
|
||||
assert_eq!(doc.node(k[0]).unwrap().opacity, 0.5);
|
||||
assert_eq!(doc.node(k[1]).unwrap().fills, vec![Paint::solid(Color::WHITE)]);
|
||||
assert!(doc.node(k[4]).is_none() && doc.node(added).is_some() && doc.node(main_new).is_some());
|
||||
assert!(doc.validate().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_property_changed_on_both_sides_is_a_conflict() {
|
||||
let mut f = files();
|
||||
let r = f.kids[0];
|
||||
f.branch.get_mut(r).unwrap().opacity = 0.25;
|
||||
f.main.get_mut(r).unwrap().opacity = 0.75;
|
||||
// The same change on both sides is no conflict.
|
||||
f.branch.get_mut(f.kids[1]).unwrap().visible = false;
|
||||
f.main.get_mut(f.kids[1]).unwrap().visible = false;
|
||||
let p = plan_of(&f);
|
||||
assert_eq!(p.conflicts.len(), 1);
|
||||
assert_eq!(p.conflicts[0].key.to_string(), format!("{r}/opacity"));
|
||||
let err = p.merge(&f.main, Side::Branch, &|_| None).unwrap_err();
|
||||
assert!(err.to_string().contains("unresolved"), "{err}");
|
||||
assert_eq!(merged(&f, &p, Side::Branch).node(r).unwrap().opacity, 0.25);
|
||||
assert_eq!(merged(&f, &p, Side::Main).node(r).unwrap().opacity, 0.75);
|
||||
assert!(!merged(&f, &p, Side::Main).node(f.kids[1]).unwrap().visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_what_the_other_side_edited_is_a_conflict() {
|
||||
// The branch deletes the frame; main edits a layer in it.
|
||||
let mut f = files();
|
||||
let (frame, r) = (f.frame, f.kids[0]);
|
||||
f.branch.remove(frame).unwrap();
|
||||
f.main.get_mut(r).unwrap().name = "Edited".into();
|
||||
let p = plan_of(&f);
|
||||
assert_eq!(p.conflicts.len(), 1);
|
||||
assert_eq!((p.conflicts[0].key.clone(), p.conflicts[0].deleted_by), (Key::Layer(frame), Some(Side::Branch)));
|
||||
assert_eq!(merged(&f, &p, Side::Main).node(r).unwrap().name, "Edited", "main's edit is kept");
|
||||
assert!(merged(&f, &p, Side::Branch).node(frame).is_none(), "the deletion wins");
|
||||
|
||||
// Main deletes the frame; the branch adds a layer to it and edits another.
|
||||
let mut f = files();
|
||||
let (frame, r) = (f.frame, f.kids[1]);
|
||||
f.main.remove(frame).unwrap();
|
||||
let added = f.branch.create(frame, NodeKind::Rectangle, "New", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
f.branch.get_mut(r).unwrap().opacity = 0.5;
|
||||
let p = plan_of(&f);
|
||||
assert_eq!(p.conflicts.len(), 1);
|
||||
assert_eq!(p.conflicts[0].deleted_by, Some(Side::Main));
|
||||
let kept = merged(&f, &p, Side::Branch);
|
||||
assert_eq!(kept.node(frame).unwrap().children.len(), 6, "the frame comes back with the branch's layer");
|
||||
assert!(kept.node(added).is_some());
|
||||
assert_eq!(kept.node(r).unwrap().opacity, 0.5);
|
||||
assert!(merged(&f, &p, Side::Main).node(frame).is_none());
|
||||
// Updating the branch from main the same way: main's deletion wins or the branch keeps it.
|
||||
let (doc, _) = p.merge(&f.branch, Side::Main, &all(Side::Main)).unwrap();
|
||||
assert!(doc.node(frame).is_none() && doc.node(added).is_none());
|
||||
let (doc, _) = p.merge(&f.branch, Side::Main, &all(Side::Branch)).unwrap();
|
||||
assert_eq!(doc, f.branch);
|
||||
|
||||
// Deleting a layer nobody else touched is no conflict.
|
||||
let mut f = files();
|
||||
f.branch.remove(f.kids[2]).unwrap();
|
||||
f.main.get_mut(f.kids[3]).unwrap().opacity = 0.1;
|
||||
let p = plan_of(&f);
|
||||
assert!(p.conflicts.is_empty());
|
||||
assert!(merged(&f, &p, Side::Main).node(f.kids[2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_moves_merge_and_conflict_per_layer() {
|
||||
let mut f = files();
|
||||
let (frame, k) = (f.frame, f.kids.clone());
|
||||
let page = f.base.first_page().unwrap();
|
||||
// The branch moves R0 into a new group; main moves R2 to the page.
|
||||
let g = f.branch.create(frame, NodeKind::Group, "G", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
f.branch.place(k[0], g, 0).unwrap();
|
||||
let at = f.main.node(page).unwrap().children.len();
|
||||
f.main.place(k[2], page, at).unwrap();
|
||||
let p = plan_of(&f);
|
||||
assert!(p.conflicts.is_empty(), "{:?}", p.conflicts);
|
||||
let doc = merged(&f, &p, Side::Branch);
|
||||
assert_eq!(doc.node(k[0]).unwrap().parent, Some(g));
|
||||
assert_eq!(doc.node(k[2]).unwrap().parent, Some(page));
|
||||
|
||||
// Both move R3, to different places: one conflict about where it is.
|
||||
let mut f = files();
|
||||
let k = f.kids.clone();
|
||||
let at = f.branch.node(page).unwrap().children.len();
|
||||
f.branch.place(k[3], page, at).unwrap();
|
||||
let g = f.main.create(f.frame, NodeKind::Group, "G", 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
f.main.place(k[3], g, 0).unwrap();
|
||||
let p = plan_of(&f);
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{}/parent", k[3])]);
|
||||
assert_eq!(merged(&f, &p, Side::Branch).node(k[3]).unwrap().parent, Some(page));
|
||||
assert_eq!(merged(&f, &p, Side::Main).node(k[3]).unwrap().parent, Some(g));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_merge_per_entry() {
|
||||
let mut f = files();
|
||||
f.branch.styles.push(style("S:b", Color::WHITE));
|
||||
f.main.styles.push(style("S:m", Color::WHITE));
|
||||
f.branch.styles[0] = style("S:1", Color::from_rgb8_const(255, 0, 0));
|
||||
f.main.styles[0] = style("S:1", Color::from_rgb8_const(0, 0, 255));
|
||||
let p = plan_of(&f);
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, ["style/S:1"]);
|
||||
let doc = merged(&f, &p, Side::Branch);
|
||||
let ids: Vec<&str> = doc.styles.iter().map(|s| s.id.as_str()).collect();
|
||||
assert_eq!(ids, ["S:1", "S:m", "S:b"], "both new styles are kept");
|
||||
assert_eq!(doc.styles[0], f.branch.styles[0]);
|
||||
assert_eq!(merged(&f, &p, Side::Main).styles[0], f.main.styles[0]);
|
||||
// A style removed on one side and edited on the other conflicts too.
|
||||
let mut f = files();
|
||||
f.branch.styles.clear();
|
||||
f.main.styles[0].name = "Renamed".into();
|
||||
assert_eq!(plan_of(&f).conflicts.len(), 1);
|
||||
}
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
//! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step,
|
||||
//! update from main, archive, saving with the file, and a fuzz test over random real commands.
|
||||
|
||||
use figcraft_collab::merge::{Side, plan};
|
||||
use figcraft_collab::sim::Rng;
|
||||
use figcraft_collab::{Change, DocProp, Mode, Replica, Target};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::collab_tests::{base, random_command};
|
||||
|
||||
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
/// The base file open, with a branch of it made and shown: (session, branch id, the rectangles).
|
||||
fn branched() -> (Session, String, Vec<NodeId>) {
|
||||
let mut s = Session::with_document(base());
|
||||
let rects: Vec<NodeId> = s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::Rectangle)).map(|n| n.id).collect();
|
||||
let b = ok(&mut s, "branch.create", json!({"name": "Explore"}));
|
||||
assert_eq!(b["index"], 1);
|
||||
assert_eq!(s.active, 1);
|
||||
(s, b["id"].as_str().unwrap().to_string(), rects)
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
serde_json::to_string(&s.doc().unwrap().node(id).unwrap().fills).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_on_both_sides_merge_as_one_undo_step() {
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#FF0000"}));
|
||||
let made = ok(&mut s, "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].clone();
|
||||
// Main is edited meanwhile; the branch edit made it unsaved.
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert!(s.ds().unwrap().is_dirty());
|
||||
ok(&mut s, "fill.set", json!({"ids": [r[1]], "color": "#00FF00"}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"], json!([]));
|
||||
assert!(review["branch"]["layers"].as_array().unwrap().iter().any(|l| l["id"] == json!(r[0].0)), "{review}");
|
||||
assert!(review["main"]["layers"].as_array().unwrap().iter().any(|l| l["id"] == json!(r[1].0)));
|
||||
let main_before = fill(&s, r[1]);
|
||||
let merged = ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
assert_eq!(merged["merged"], true);
|
||||
assert_eq!(s.docs.len(), 1, "the branch tab closes");
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(figcraft_doc::NodeId(made.as_u64().unwrap())).is_some());
|
||||
assert!(fill(&s, r[0]).contains("1.0") && fill(&s, r[1]) == main_before);
|
||||
assert_eq!(ok(&mut s, "branch.list", json!({}))[0]["state"], "merged");
|
||||
// One undo step takes the merge back.
|
||||
ok(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(figcraft_doc::NodeId(made.as_u64().unwrap())).is_none());
|
||||
assert_eq!(fill(&s, r[1]), main_before);
|
||||
assert!(s.execute("branch.merge", &json!({"id": id})).is_err(), "a merged branch can't merge again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_must_be_resolved_and_either_side_can_win() {
|
||||
for (pick, want) in [("branch", "#FF0000"), ("main", "#0000FF")] {
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#FF0000"}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#0000FF"}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let c = &review["conflicts"][0];
|
||||
assert_eq!(review["conflicts"].as_array().unwrap().len(), 1, "{review}");
|
||||
let key = c["key"].as_str().unwrap().to_string();
|
||||
assert_eq!(key, format!("{}/fills", r[0]));
|
||||
assert_eq!(c["property"], "fills");
|
||||
assert!(c["branch"].as_str().unwrap().contains("FF0000") && c["main"].as_str().unwrap().contains("0000FF"), "{c}");
|
||||
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
|
||||
assert!(err.contains(&key), "{err}");
|
||||
assert!(s.execute("branch.merge", &json!({"id": id, "resolutions": {&key: "both"}})).is_err());
|
||||
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
|
||||
let want = Session::with_document({
|
||||
let mut t = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut t, "fill.set", json!({"ids": [r[0]], "color": want}));
|
||||
t.doc().unwrap().clone()
|
||||
});
|
||||
assert_eq!(fill(&s, r[0]), fill(&want, r[0]), "{pick} wins");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_what_main_edited_is_an_explicit_conflict() {
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "edit.delete", json!({"ids": [r[2]]}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": r[2], "props": {"name": "Keep me"}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let c = &review["conflicts"][0];
|
||||
assert_eq!(
|
||||
(c["key"].as_str().unwrap(), &c["deletedBy"], &c["branch"], &c["main"]),
|
||||
(format!("{}/layer", r[2]).as_str(), &json!("branch"), &json!("deleted"), &json!("edited"))
|
||||
);
|
||||
assert!(s.execute("branch.merge", &json!({"id": id})).is_err(), "never dropped silently");
|
||||
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {c["key"].as_str().unwrap(): "main"}}));
|
||||
assert_eq!(s.doc().unwrap().node(r[2]).unwrap().name, "Keep me");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_from_main_then_merge() {
|
||||
let (mut s, _, r) = branched();
|
||||
ok(&mut s, "node.set", json!({"id": r[0], "props": {"opacity": 0.5}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": r[1], "props": {"opacity": 0.25}}));
|
||||
ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Main name"}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
ok(&mut s, "branch.updateFromMain", json!({}));
|
||||
let tab = s.doc().unwrap();
|
||||
assert_eq!((tab.node(r[0]).unwrap().opacity, tab.node(r[1]).unwrap().opacity), (0.5, 0.25));
|
||||
assert_eq!(tab.node(r[0]).unwrap().name, "Main name");
|
||||
assert!(s.ds().unwrap().history.undo.is_empty(), "undo can't step past the new base");
|
||||
// Main's changes are now in the branch's base: nothing on main to review any more.
|
||||
let review = ok(&mut s, "branch.review", json!({}));
|
||||
assert_eq!(review["main"]["layers"], json!([]));
|
||||
// A conflicting update needs a resolution too.
|
||||
ok(&mut s, "node.set", json!({"id": r[2], "props": {"opacity": 0.1}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": r[2], "props": {"opacity": 0.9}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
let key = format!("{}/opacity", r[2]);
|
||||
assert!(s.execute("branch.updateFromMain", &json!({})).is_err());
|
||||
ok(&mut s, "branch.updateFromMain", json!({"resolutions": {&key: "branch"}}));
|
||||
assert_eq!(s.doc().unwrap().node(r[2]).unwrap().opacity, 0.1);
|
||||
ok(&mut s, "branch.merge", json!({}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(s.active, 0);
|
||||
assert_eq!((doc.node(r[0]).unwrap().opacity, doc.node(r[2]).unwrap().opacity), (0.5, 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_variables_and_comments_merge() {
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "fill.set", json!({"ids": [r[0]], "color": "#123456"}));
|
||||
ok(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "from": r[0]}));
|
||||
let page = s.ds().unwrap().page;
|
||||
ok(&mut s, "comment.add", json!({"x": 1, "y": 2, "text": "On the branch", "page": page}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "variable.create", json!({"collection": "Tokens", "name": "accent", "type": "color", "value": "#FF00FF"}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let what: Vec<&str> = review["branch"]["document"].as_array().unwrap().iter().map(|d| d["what"].as_str().unwrap()).collect();
|
||||
assert!(what.contains(&"Style “Brand”") && what.contains(&"Comment “On the branch”"), "{what:?}");
|
||||
assert!(review["main"]["document"].as_array().unwrap().iter().any(|d| d["what"] == "Variable “accent”" && d["change"] == "added"));
|
||||
ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.styles.iter().any(|st| st.name == "Brand"));
|
||||
assert!(doc.comments.iter().any(|c| c.first.text == "On the branch"));
|
||||
assert!(doc.variables.variables.iter().any(|v| v.name == "accent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_tabs_save_with_the_main_file_and_come_back() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-branches-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("file.figcraft").to_string_lossy().to_string();
|
||||
let (mut s, id, r) = branched();
|
||||
ok(&mut s, "node.set", json!({"id": r[0], "props": {"name": "Branch edit"}}));
|
||||
// Versions belong to the main file.
|
||||
assert!(s.execute("file.saveVersion", &json!({})).is_err());
|
||||
// Saving on the branch tab saves the main file, branch included.
|
||||
ok(&mut s, "file.saveAs", json!({"path": path}));
|
||||
assert_eq!(s.docs[0].path.as_deref(), Some(path.as_str()));
|
||||
assert!(!s.docs[0].is_dirty() && !s.docs[1].is_dirty());
|
||||
// Closing the main file closes its branch tabs.
|
||||
ok(&mut s, "file.close", json!({"index": 0}));
|
||||
assert!(s.docs.is_empty());
|
||||
let mut t = Session::empty();
|
||||
ok(&mut t, "file.open", json!({"path": path}));
|
||||
let list = ok(&mut t, "branch.list", json!({}));
|
||||
assert_eq!((list[0]["id"].as_str().unwrap(), &list[0]["name"], &list[0]["state"]), (id.as_str(), &json!("Explore"), &json!("active")));
|
||||
ok(&mut t, "branch.open", json!({"id": id}));
|
||||
assert_eq!(t.doc().unwrap().node(r[0]).unwrap().name, "Branch edit");
|
||||
assert!(!t.docs[0].is_dirty(), "opening a branch is no edit");
|
||||
// New layers on the reopened branch are made in its own id space.
|
||||
let made = ok(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}))["id"].as_u64().unwrap();
|
||||
assert!(made >> 32 > 0, "{made}");
|
||||
// Archiving keeps it in the file and closes its tab.
|
||||
ok(&mut t, "branch.archive", json!({}));
|
||||
assert_eq!(t.docs.len(), 1);
|
||||
assert_eq!(ok(&mut t, "branch.list", json!({}))[0]["state"], "archived");
|
||||
assert!(t.execute("branch.merge", &json!({"id": id})).is_err());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_commands_refuse_what_makes_no_sense() {
|
||||
let (mut s, _, _) = branched();
|
||||
assert!(s.execute("branch.create", &json!({})).is_err(), "no branches of branches");
|
||||
assert!(s.execute("branch.open", &json!({"id": "nope"})).is_err());
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
assert!(s.execute("branch.review", &json!({})).is_err(), "main needs an id");
|
||||
assert!(s.execute("branch.merge", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Every change a side made (that the other side didn't overwrite) shows in `doc`.
|
||||
fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
|
||||
let r = Replica::new(doc.clone());
|
||||
for c in changes {
|
||||
let ok = match c {
|
||||
Change::Create { id, .. } => doc.contains(*id),
|
||||
Change::Delete { id } => !doc.contains(*id),
|
||||
Change::Set { prop: figcraft_collab::Prop::Position, .. } => true,
|
||||
Change::Set { id, prop, value } => r.value(&Target::Node(*id, *prop)).ok().flatten().as_ref() == Some(value),
|
||||
Change::SetDoc { prop: DocProp::StyleOrder | DocProp::CommentOrder | DocProp::CollectionOrder | DocProp::VariableOrder, .. } => true,
|
||||
Change::SetDoc { prop, value } => r.value(&Target::Doc(*prop)).ok().flatten().as_ref() == Some(value),
|
||||
Change::Put { entry, value } => r.value(&Target::Entry(entry.clone())).ok().flatten().as_ref() == Some(value),
|
||||
Change::Remove { entry: figcraft_collab::Entry::Image(_) } => true,
|
||||
Change::Remove { entry } => r.value(&Target::Entry(entry.clone())).ok().flatten().is_none(),
|
||||
};
|
||||
if !ok {
|
||||
return Err(format!("{c:?}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both
|
||||
/// ways) equals applying both change sets and shows every change of either side; with conflicts,
|
||||
/// resolving them all for either side gives a valid document.
|
||||
#[test]
|
||||
fn random_edits_on_both_sides_merge() {
|
||||
let (mut disjoint, mut conflicted) = (0, 0);
|
||||
for seed in 0..60 {
|
||||
let mut rng = Rng::new(seed);
|
||||
let start = base();
|
||||
let mut on_branch = start.clone();
|
||||
on_branch.set_client_id(start.highest_id_space() + 1);
|
||||
let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())];
|
||||
for s in &mut sides {
|
||||
for _ in 0..1 + rng.below(5) {
|
||||
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false);
|
||||
let _ = s.execute(cmd, ¶ms);
|
||||
}
|
||||
}
|
||||
let [b, m] = sides.map(|s| s.doc().unwrap().clone());
|
||||
let p = plan(&start, &b, &m).unwrap();
|
||||
if p.conflicts.is_empty() {
|
||||
disjoint += 1;
|
||||
let none = |_: &figcraft_collab::merge::Key| None;
|
||||
let (into_main, _) = p.merge(&m, Side::Branch, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
let (into_branch, _) = p.merge(&b, Side::Main, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
let mut both = Replica::new(start.clone());
|
||||
both.apply(&p.main, Mode::Strict).unwrap();
|
||||
both.apply(&p.branch, Mode::Strict).unwrap();
|
||||
let mut want = both.document().clone();
|
||||
(want.name, want.current_page) = (m.name.clone(), m.current_page);
|
||||
assert_eq!(into_main, want, "seed {seed}");
|
||||
for doc in [&into_main, &into_branch] {
|
||||
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
|
||||
for side in [&p.branch, &p.main] {
|
||||
shows(side, doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
conflicted += 1;
|
||||
for side in [Side::Branch, Side::Main] {
|
||||
let (doc, _) = p.merge(&m, Side::Branch, &|_| Some(side)).unwrap_or_else(|e| panic!("seed {seed} ({side:?} wins): {e}"));
|
||||
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts");
|
||||
}
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
//! Branches of a file: a copy of the document to work on separately (in its own tab), reviewed
|
||||
//! against the main file and merged back. A branch remembers the main document as it was when
|
||||
//! the branch was made or last updated from main (its base); [`figcraft_collab::merge`] compares
|
||||
//! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a
|
||||
//! branch tab's edits go into its branch ([`sync`]) and are saved with the main file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use figcraft_collab::merge::Side;
|
||||
use figcraft_collab::merge::{self, Conflict, Key, Plan};
|
||||
use figcraft_collab::{Change, Entry, Prop, Target};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_format::Branch;
|
||||
pub use figcraft_format::BranchState;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::devcompare::{self, ChangeKind, LayerChange, PropChange};
|
||||
use crate::params::{get, string};
|
||||
use crate::{DocState, EngineError, Result, Session, bad, normalize, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"branch.create",
|
||||
"Create branch",
|
||||
"{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}",
|
||||
false,
|
||||
create
|
||||
),
|
||||
spec!(
|
||||
"branch.list",
|
||||
"List branches",
|
||||
"{} the branches of the file shown (or of the file a branch tab belongs to), oldest first → [{id, name, time (Unix seconds), state: active|merged|archived, index? (its open tab)}]",
|
||||
false,
|
||||
|s, _| list(s)
|
||||
),
|
||||
spec!("branch.open", "Open branch", "{id} open the branch in a tab (or switch to its tab) → {index}", false, |s, p| {
|
||||
let (mi, bi) = locate(s, "branch.open", string(p, "id").as_deref())?;
|
||||
let (uid, id) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))).ok_or(EngineError::NoDocument)?;
|
||||
Ok(json!({"index": open_tab(s, uid, &id)?}))
|
||||
}),
|
||||
spec!(
|
||||
"branch.review",
|
||||
"Review branch",
|
||||
"{id? (default: the branch tab shown)} what changed on the branch and on the main file since the branch was made or last updated from main, and where they conflict (the same property changed on both sides, or a layer deleted on one side and edited on the other) → {id, name, state, branch: {layers, document, truncated}, main: {…}, conflicts: [{key, id?, name, property, branch, main, deletedBy?}]}",
|
||||
false,
|
||||
|s, p| serde_json::to_value(review(s, string(p, "id").as_deref())?).map_err(|e| EngineError::Other(e.to_string()))
|
||||
),
|
||||
spec!(
|
||||
"branch.updateFromMain",
|
||||
"Update from main",
|
||||
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}",
|
||||
false,
|
||||
|s, p| combine(s, p, Side::Main)
|
||||
),
|
||||
spec!(
|
||||
"branch.merge",
|
||||
"Merge branch",
|
||||
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}",
|
||||
false,
|
||||
|s, p| combine(s, p, Side::Branch)
|
||||
),
|
||||
spec!(
|
||||
"branch.archive",
|
||||
"Archive branch",
|
||||
"{id?} set the branch aside without merging (it stays in the file and can still be opened) and close its tab",
|
||||
false,
|
||||
archive
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Everything shown for reviewing a branch.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Review {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: BranchState,
|
||||
/// What changed on the branch since its base.
|
||||
pub branch: SideChanges,
|
||||
/// What changed on the main file since the branch's base.
|
||||
pub main: SideChanges,
|
||||
pub conflicts: Vec<ConflictRow>,
|
||||
}
|
||||
|
||||
/// One side's changes since the base.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SideChanges {
|
||||
/// Layers added, removed or changed (property changes formatted like Inspect).
|
||||
pub layers: Vec<LayerChange>,
|
||||
/// Styles, variables, comments and file settings.
|
||||
pub document: Vec<DocChange>,
|
||||
/// More than [`devcompare::MAX_CHANGES`] layers changed: the list stops there.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// A document-level change: a style, variable or comment added, changed or removed, or a file
|
||||
/// setting changed.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocChange {
|
||||
pub what: String,
|
||||
pub change: ChangeKind,
|
||||
}
|
||||
|
||||
/// A conflict, described for choosing a side.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConflictRow {
|
||||
/// What resolutions name it by.
|
||||
pub key: String,
|
||||
/// The layer it is about.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<NodeId>,
|
||||
/// The layer, style, variable… it is about.
|
||||
pub name: String,
|
||||
/// What conflicts: a layer property ("opacity"), "layer" (deleted on one side and edited on
|
||||
/// the other), a file setting, or what the entry is ("style").
|
||||
pub property: String,
|
||||
/// What the branch has.
|
||||
pub branch: String,
|
||||
/// What the main file has.
|
||||
pub main: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deleted_by: Option<Side>,
|
||||
}
|
||||
|
||||
/// Put each branch tab's document into its branch, so saving the main file (and its unsaved
|
||||
/// state) covers the branch. Runs before every command.
|
||||
pub(crate) fn sync(s: &mut Session) {
|
||||
if !s.docs.iter().any(|d| d.branch_of.is_some()) {
|
||||
return;
|
||||
}
|
||||
let tabs: Vec<(u64, String, Arc<Document>)> =
|
||||
s.docs.iter().filter_map(|d| d.branch_of.clone().map(|(m, id)| (m, id, d.document_itself().clone()))).collect();
|
||||
for (main, id, doc) in tabs {
|
||||
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == main) else { continue };
|
||||
let Some(i) = ds.branches.iter().position(|b| b.id == id && !Arc::ptr_eq(&b.head, &doc)) else { continue };
|
||||
if let Some(b) = ds.branches_mut().get_mut(i) {
|
||||
b.head = doc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// The tab showing branch `id` of the file in the tab with uid `main`.
|
||||
pub fn tab_of(s: &Session, main: u64, id: &str) -> Option<usize> {
|
||||
s.docs.iter().position(|d| d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && b == id))
|
||||
}
|
||||
|
||||
/// Close the tabs showing branches of the file in the tab with uid `main` (`id`: only that
|
||||
/// branch's). Their edits are already in the branches ([`sync`]).
|
||||
pub(crate) fn close_tabs(s: &mut Session, main: u64, id: Option<&str>) {
|
||||
let active = s.docs.get(s.active).map(|d| d.uid);
|
||||
s.docs.retain(|d| !d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b)));
|
||||
s.active = active.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(s.active).min(s.docs.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
/// The main file's tab: the active tab, or the file a branch tab belongs to.
|
||||
fn main_index(s: &Session, cmd: &str) -> Result<usize> {
|
||||
let ds = s.ds()?;
|
||||
let main = ds.branch_of.as_ref().map_or(ds.uid, |(m, _)| *m);
|
||||
s.docs.iter().position(|d| d.uid == main).ok_or_else(|| EngineError::Disabled(cmd.into(), "the branch's main file isn't open".into()))
|
||||
}
|
||||
|
||||
/// The main file's tab and branch `id` in it (default: the branch the active tab shows).
|
||||
pub fn locate(s: &Session, cmd: &str, id: Option<&str>) -> Result<(usize, usize)> {
|
||||
let mi = main_index(s, cmd)?;
|
||||
let id = id.map(str::to_string).or_else(|| s.ds().ok().and_then(|d| d.branch_of.as_ref()).map(|(_, b)| b.clone()));
|
||||
let id = id.ok_or_else(|| bad(cmd, "pass the branch `id`"))?;
|
||||
let bi = s.docs.get(mi).and_then(|m| m.branches.iter().position(|b| b.id == id)).ok_or_else(|| bad(cmd, format!("no branch `{id}`")))?;
|
||||
Ok((mi, bi))
|
||||
}
|
||||
|
||||
/// Branch `bi` of the file in tab `mi`: its base, its document (from its tab when open) and the
|
||||
/// main document.
|
||||
pub fn documents(s: &Session, mi: usize, bi: usize) -> Option<(Arc<Document>, Arc<Document>, Arc<Document>)> {
|
||||
let m = s.docs.get(mi)?;
|
||||
let b = m.branches.get(bi)?;
|
||||
let head = tab_of(s, m.uid, &b.id).and_then(|t| s.docs.get(t)).map_or_else(|| b.head.clone(), |t| t.document_itself().clone());
|
||||
Some((b.base.clone(), head, m.document_itself().clone()))
|
||||
}
|
||||
|
||||
fn disabled(cmd: &str, why: impl Into<String>) -> EngineError {
|
||||
EngineError::Disabled(cmd.into(), why.into())
|
||||
}
|
||||
|
||||
/// shortcut: branches of a file in a live session are refused (its participants' client ids
|
||||
/// aren't known here, so a branch's id space could clash); allow them once the server hands out
|
||||
/// branch id spaces.
|
||||
fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> {
|
||||
if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "branch.create";
|
||||
let ds = s.ds()?;
|
||||
if ds.branch_of.is_some() {
|
||||
return Err(disabled(CMD, "this tab is a branch: make branches from the main file"));
|
||||
}
|
||||
refuse_live(s, CMD, ds.uid)?;
|
||||
if ds.branches.len() >= figcraft_format::MAX_BRANCHES {
|
||||
return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES)));
|
||||
}
|
||||
let name =
|
||||
string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1));
|
||||
let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME);
|
||||
let base = ds.document_itself().clone();
|
||||
// Its own id space, above every space the file, its live sessions and its branches used.
|
||||
let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0);
|
||||
let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?;
|
||||
let mut head = (*base).clone();
|
||||
head.name = name.clone();
|
||||
head.set_client_id(client);
|
||||
let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id));
|
||||
let uid = ds.uid;
|
||||
let time = crate::cmd::comment::now_secs();
|
||||
s.ds_mut()?.branches_mut().push(Branch {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
time,
|
||||
client,
|
||||
state: BranchState::Active,
|
||||
base,
|
||||
head: Arc::new(head),
|
||||
});
|
||||
let index = open_tab(s, uid, &id)?;
|
||||
Ok(json!({"id": id, "name": name, "index": index}))
|
||||
}
|
||||
|
||||
/// Open branch `id` of the file in the tab with uid `main` in a tab, or switch to its tab.
|
||||
fn open_tab(s: &mut Session, main: u64, id: &str) -> Result<usize> {
|
||||
if let Some(i) = tab_of(s, main, id) {
|
||||
s.active = i;
|
||||
return Ok(i);
|
||||
}
|
||||
let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?;
|
||||
let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?;
|
||||
let mut doc = (*b.head).clone();
|
||||
// A branch read from a file has no client id yet.
|
||||
doc.set_client_id(b.client);
|
||||
let mut tab = DocState::new(doc, None);
|
||||
tab.branch_of = Some((main, id.to_string()));
|
||||
// The same content: opening isn't an edit of the main file.
|
||||
b.head = tab.doc.clone();
|
||||
s.docs.push(tab);
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(s.active)
|
||||
}
|
||||
|
||||
fn list(s: &Session) -> Result<Value> {
|
||||
let m = s.docs.get(main_index(s, "branch.list")?).ok_or(EngineError::NoDocument)?;
|
||||
Ok(Value::Array(
|
||||
m.branches
|
||||
.iter()
|
||||
.map(|b| {
|
||||
let mut j = json!({"id": b.id, "name": b.name, "time": b.time, "state": b.state});
|
||||
if let Some(i) = tab_of(s, m.uid, &b.id) {
|
||||
j["index"] = json!(i);
|
||||
}
|
||||
j
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn archive(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "branch.archive";
|
||||
let (mi, bi) = locate(s, CMD, string(p, "id").as_deref())?;
|
||||
let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?;
|
||||
if m.branches.get(bi).is_none_or(|b| b.state != BranchState::Active) {
|
||||
return Err(disabled(CMD, "the branch was already merged or archived"));
|
||||
}
|
||||
let (uid, id) = (m.uid, m.branches.get(bi).map(|b| b.id.clone()).unwrap_or_default());
|
||||
if let Some(b) = m.branches_mut().get_mut(bi) {
|
||||
b.state = BranchState::Archived;
|
||||
}
|
||||
close_tabs(s, uid, Some(&id));
|
||||
Ok(json!({"archived": true}))
|
||||
}
|
||||
|
||||
/// The side for each conflict key in `resolutions`.
|
||||
fn resolutions(cmd: &str, p: &Value) -> Result<BTreeMap<String, Side>> {
|
||||
let Some(v) = get(p, "resolutions") else { return Ok(BTreeMap::new()) };
|
||||
let obj = v.as_object().ok_or_else(|| bad(cmd, "`resolutions` is an object: {<conflict key>: \"branch\" | \"main\"}"))?;
|
||||
obj.iter()
|
||||
.map(|(k, v)| {
|
||||
let side = v.as_str().and_then(Side::parse).ok_or_else(|| bad(cmd, format!("the resolution of `{k}` is \"branch\" or \"main\"")))?;
|
||||
Ok((k.clone(), side))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `branch.merge` (`from` the branch into main) and `branch.updateFromMain` (`from` main into
|
||||
/// the branch).
|
||||
fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
|
||||
let cmd = if from == Side::Branch { "branch.merge" } else { "branch.updateFromMain" };
|
||||
let (mi, bi) = locate(s, cmd, string(p, "id").as_deref())?;
|
||||
let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?;
|
||||
let main_uid = m.uid;
|
||||
let (id, state) = m.branches.get(bi).map(|b| (b.id.clone(), b.state)).ok_or(EngineError::NoDocument)?;
|
||||
if state != BranchState::Active {
|
||||
return Err(disabled(cmd, "the branch was merged or archived"));
|
||||
}
|
||||
// The main file itself, not a version shown in its place.
|
||||
m.end_preview();
|
||||
refuse_live(s, cmd, main_uid)?;
|
||||
let picks = resolutions(cmd, p)?;
|
||||
let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?;
|
||||
let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let pick = |k: &Key| picks.get(&k.to_string()).copied();
|
||||
let open: Vec<String> = plan.unresolved(&pick).iter().map(|c| c.key.to_string()).collect();
|
||||
if !open.is_empty() {
|
||||
return Err(disabled(cmd, format!("resolve every conflict first (\"branch\" or \"main\" in `resolutions`): {}", open.join(", "))));
|
||||
}
|
||||
let into = if from == Side::Branch { &main } else { &head };
|
||||
let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?;
|
||||
doc.mark_dirty(touched);
|
||||
normalize(&mut doc, into);
|
||||
let doc = Arc::new(doc);
|
||||
let mut out = json!({"changes": plan.changes(from).len(), "conflicts": plan.conflicts.len()});
|
||||
if from == Side::Branch {
|
||||
let m = s.docs.get_mut(mi).ok_or(EngineError::NoDocument)?;
|
||||
let (selection, page) = (m.selection.clone(), m.page);
|
||||
show(m, doc);
|
||||
if let Some(b) = m.branches_mut().get_mut(bi) {
|
||||
b.state = BranchState::Merged;
|
||||
}
|
||||
s.record(main_uid, Some("Merge branch"), main, selection, page);
|
||||
close_tabs(s, main_uid, Some(&id));
|
||||
s.active = s.docs.iter().position(|d| d.uid == main_uid).unwrap_or(s.active);
|
||||
out["merged"] = json!(true);
|
||||
out["index"] = json!(s.active);
|
||||
} else {
|
||||
if let Some(t) = tab_of(s, main_uid, &id).and_then(|t| s.docs.get_mut(t)) {
|
||||
show(t, doc.clone());
|
||||
// Undo can't step back past the new base: that would undo main's changes on the branch.
|
||||
t.history.undo.clear();
|
||||
t.history.redo.clear();
|
||||
}
|
||||
if let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) {
|
||||
b.base = main;
|
||||
b.head = doc;
|
||||
}
|
||||
out["updated"] = json!(true);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Show merged `doc` in tab `ds`.
|
||||
fn show(ds: &mut DocState, doc: Arc<Document>) {
|
||||
ds.doc = doc;
|
||||
ds.text_edit = None;
|
||||
ds.vector_edit = None;
|
||||
ds.clean_selection();
|
||||
if !ds.doc.pages.contains(&ds.page)
|
||||
&& let Some(p) = ds.doc.first_page()
|
||||
{
|
||||
ds.page = p;
|
||||
}
|
||||
ds.revision += 1;
|
||||
}
|
||||
|
||||
/// What changed on branch `id` (default: the branch tab shown) and on its main file, and their
|
||||
/// conflicts.
|
||||
pub fn review(s: &Session, id: Option<&str>) -> Result<Review> {
|
||||
let (mi, bi) = locate(s, "branch.review", id)?;
|
||||
review_at(s, mi, bi)
|
||||
}
|
||||
|
||||
/// [`review`] of branch `bi` of the file in tab `mi`.
|
||||
pub fn review_at(s: &Session, mi: usize, bi: usize) -> Result<Review> {
|
||||
let b = s.docs.get(mi).and_then(|m| m.branches.get(bi)).ok_or(EngineError::NoDocument)?;
|
||||
let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?;
|
||||
let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
Ok(Review {
|
||||
id: b.id.clone(),
|
||||
name: b.name.clone(),
|
||||
state: b.state,
|
||||
branch: side_changes(&plan, Side::Branch, &base, &head),
|
||||
main: side_changes(&plan, Side::Main, &base, &main),
|
||||
conflicts: plan.conflicts.iter().map(|c| conflict_row(&plan, c, &base, &head, &main)).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn side_changes(plan: &Plan, side: Side, base: &Document, doc: &Document) -> SideChanges {
|
||||
let cmp = devcompare::compare_docs(base, doc);
|
||||
let document = plan.changes(side).iter().filter_map(|c| doc_change(plan, c)).collect();
|
||||
SideChanges { layers: cmp.changes, document, truncated: cmp.truncated }
|
||||
}
|
||||
|
||||
/// `prop`'s name in words: `childLayout` → "child layout".
|
||||
fn words<T: Serialize>(prop: &T) -> String {
|
||||
let name = serde_json::to_value(prop).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
let mut out = String::new();
|
||||
for ch in name.chars() {
|
||||
if ch.is_uppercase() {
|
||||
out.push(' ');
|
||||
out.extend(ch.to_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn capitalized(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// "Style “Primary”", "Variable “gap”"… (`None` for entries not worth listing: images, ready-for-dev
|
||||
/// snapshots).
|
||||
fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'static str, String)> {
|
||||
let kind = match entry {
|
||||
Entry::Style(_) => "style",
|
||||
Entry::Variable(_) => "variable",
|
||||
Entry::Collection(_) => "variable collection",
|
||||
Entry::Comment(_) => "comment",
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) => return None,
|
||||
};
|
||||
let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten();
|
||||
let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str);
|
||||
Some((kind, figcraft_format::clip(named.unwrap_or_default(), 60)))
|
||||
}
|
||||
|
||||
/// What `c` did to its entry: added, changed or removed.
|
||||
fn entry_change(plan: &Plan, c: &Change) -> ChangeKind {
|
||||
match c {
|
||||
Change::Put { entry, .. } if plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() => ChangeKind::Added,
|
||||
Change::Remove { .. } => ChangeKind::Removed,
|
||||
_ => ChangeKind::Changed,
|
||||
}
|
||||
}
|
||||
|
||||
fn doc_change(plan: &Plan, c: &Change) -> Option<DocChange> {
|
||||
let what = match c {
|
||||
Change::SetDoc { prop, .. } => capitalized(&words(prop)),
|
||||
Change::Put { entry, value } => entry_label(plan, entry, Some(&value.0)).map(|(k, n)| format!("{} “{n}”", capitalized(k)))?,
|
||||
Change::Remove { entry } => entry_label(plan, entry, None).map(|(k, n)| format!("{} “{n}”", capitalized(k)))?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(DocChange { what, change: entry_change(plan, c) })
|
||||
}
|
||||
|
||||
fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, main: &Document) -> ConflictRow {
|
||||
let layer = |id: NodeId| main.node(id).or(head.node(id)).or(base.node(id)).map_or_else(|| id.to_string(), |n| n.name.clone());
|
||||
let text = |side: Side| match c.changes(side).first().and_then(|i| plan.changes(side).get(*i)) {
|
||||
Some(ch) => entry_change(plan, ch).label().to_lowercase(),
|
||||
None => "unchanged".to_string(),
|
||||
};
|
||||
let (id, name, property, branch, main_text) = match &c.key {
|
||||
Key::Node(id, prop) => {
|
||||
// Main's layer, and main's layer with only the branch's change to this property.
|
||||
let with = plan.with_changes(main, Side::Branch, &c.branch);
|
||||
let changes = devcompare::layer_changes(main, &with, *id);
|
||||
let join = |f: fn(&PropChange) -> &str| changes.iter().map(|pc| format!("{} {}", pc.property, f(pc))).collect::<Vec<_>>().join(", ");
|
||||
let (b, m) = if changes.is_empty() { ("changed".into(), "changed".into()) } else { (join(|pc| &pc.after), join(|pc| &pc.before)) };
|
||||
let property = if *prop == Prop::Parent { "place in layers".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), property, b, m)
|
||||
}
|
||||
Key::Layer(id) => {
|
||||
let (b, m) = if c.deleted_by == Some(Side::Branch) { ("deleted", "edited") } else { ("edited", "deleted") };
|
||||
(Some(*id), layer(*id), "layer".to_string(), b.to_string(), m.to_string())
|
||||
}
|
||||
Key::Doc(prop) => (None, "File".to_string(), words(prop), "changed".to_string(), "changed".to_string()),
|
||||
Key::Entry(entry) => {
|
||||
let changes = c.branch.iter().filter_map(|i| plan.branch.get(*i)).chain(c.main.iter().filter_map(|i| plan.main.get(*i)));
|
||||
let value = changes.into_iter().find_map(|ch| match ch {
|
||||
Change::Put { value, .. } => Some(value.0.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let (kind, name) = entry_label(plan, entry, value.as_ref()).unwrap_or(("entry", String::new()));
|
||||
(None, name, kind.to_string(), text(Side::Branch), text(Side::Main))
|
||||
}
|
||||
};
|
||||
ConflictRow { key: c.key.to_string(), id, name, property, branch, main: main_text, deleted_by: c.deleted_by }
|
||||
}
|
||||
|
|
@ -29,6 +29,8 @@ pub static MENU: &[MenuItem] = &[
|
|||
m!("File", "Save as…", "file.saveAs"),
|
||||
m!("File", "Save to version history…", "file.saveVersion"),
|
||||
m!("File", "Show version history", "file.versionHistory"),
|
||||
// FigCraft's own: branches of the file.
|
||||
m!("File", "Create branch…", "branch.create"),
|
||||
m!("File", SEP, ""),
|
||||
// FigCraft's own (live sessions): not in the reference menu, so not counted by `parity`.
|
||||
m!("File", "Join live session…", "collab.join"),
|
||||
|
|
|
|||
|
|
@ -96,9 +96,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
|
||||
let (loaded, _) = load_path(&path, None)?;
|
||||
let i = s.active;
|
||||
let uid = s.ds()?.uid;
|
||||
if let Some(slot) = s.docs.get_mut(i) {
|
||||
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions);
|
||||
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches);
|
||||
}
|
||||
// Edits on its branch tabs are reverted too.
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
|
|
@ -233,7 +236,8 @@ pub const MAX_AUTOSAVES: usize = 20;
|
|||
/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version.
|
||||
fn autosave(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.preview.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
// A branch's versions would have nowhere to be saved.
|
||||
if ds.preview.is_some() || ds.branch_of.is_some() || !ds.is_dirty() || !ds.changed_since_checkpoint() {
|
||||
return Ok(json!({"saved": false}));
|
||||
}
|
||||
// Make room: the oldest autosave goes first; user versions are never dropped.
|
||||
|
|
@ -260,7 +264,7 @@ fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version {
|
|||
if snap.current_page != Some(ds.page) {
|
||||
Arc::make_mut(&mut snap).current_page = Some(ds.page);
|
||||
}
|
||||
Version::new(new_version_id(&ds.versions), name, description, super::comment::now_secs(), snap)
|
||||
Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, super::comment::now_secs(), snap)
|
||||
}
|
||||
|
||||
fn version_list(ds: &DocState) -> Value {
|
||||
|
|
@ -296,12 +300,12 @@ fn read_version(v: &Version) -> Result<Arc<Document>> {
|
|||
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
|
||||
}
|
||||
|
||||
/// A new version id: the time in milliseconds (hex), unique in the history.
|
||||
fn new_version_id(versions: &[Version]) -> String {
|
||||
/// A new version or branch id: the time in milliseconds (hex), not `taken`.
|
||||
pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String {
|
||||
let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
|
||||
loop {
|
||||
let id = format!("{n:x}");
|
||||
if !versions.iter().any(|v| v.id == id) {
|
||||
if !taken(&id) {
|
||||
return id;
|
||||
}
|
||||
n = n.wrapping_add(1);
|
||||
|
|
@ -310,6 +314,9 @@ fn new_version_id(versions: &[Version]) -> String {
|
|||
|
||||
fn save_version(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
if ds.branch_of.is_some() {
|
||||
return Err(EngineError::Disabled("file.saveVersion".into(), "versions are saved on the main file, not on a branch".into()));
|
||||
}
|
||||
if ds.versions.len() >= figcraft_format::MAX_VERSIONS {
|
||||
return Err(EngineError::Disabled(
|
||||
"file.saveVersion".into(),
|
||||
|
|
@ -577,7 +584,7 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions));
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
|
||||
|
|
@ -604,7 +611,7 @@ pub fn file_bytes(ds: &DocState) -> Result<Vec<u8>> {
|
|||
let mut doc = (*ds.doc).clone();
|
||||
doc.current_page = Some(ds.page);
|
||||
let thumb = thumbnail(&doc);
|
||||
figcraft_format::save_bytes_with_versions(&doc, thumb.as_deref(), &ds.versions).map_err(|e| EngineError::Other(e.to_string()))
|
||||
figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &ds.versions, &ds.branches).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
|
@ -619,13 +626,19 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({"profile": profile.id(), "changed": changed}))
|
||||
}
|
||||
|
||||
/// Save the file of the active tab (the main file for a branch tab, with its branches).
|
||||
fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
let ds = s.ds_mut()?;
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
write(&path, &bytes)?;
|
||||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
let uid = ds.uid;
|
||||
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
|
||||
tab.mark_saved();
|
||||
}
|
||||
remember(s, &path);
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
|
|
@ -634,7 +647,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
if let Some(path) = string(p, "path") {
|
||||
return save_to(s, path);
|
||||
}
|
||||
match s.ds()?.path.clone() {
|
||||
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
|
||||
Some(path) => save_to(s, path),
|
||||
None => {
|
||||
s.request_ui("dialog.saveAs", json!({}));
|
||||
|
|
@ -1016,14 +1029,17 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
|||
fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active);
|
||||
let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) };
|
||||
if ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
|
||||
// A branch tab's edits are in its main file already (which then has unsaved changes).
|
||||
if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) {
|
||||
s.request_ui("dialog.unsaved", json!({"index": i}));
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
let uid = ds.uid;
|
||||
s.docs.remove(i);
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ fn registry() -> &'static Registry {
|
|||
list.extend(crate::annotations::specs());
|
||||
list.extend(crate::distance::specs());
|
||||
list.extend(crate::collab::specs());
|
||||
list.extend(crate::branches::specs());
|
||||
list.extend(crate::plugins::specs());
|
||||
list.extend(crate::live::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
|
|
|
|||
|
|
@ -124,6 +124,35 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
|
|||
doc.with_dev_snapshot(id)
|
||||
}
|
||||
|
||||
/// Every layer that changed between two versions of a whole document, page by page (pages added
|
||||
/// or removed as a whole), for reviewing a branch.
|
||||
pub fn compare_docs(before: &Document, now: &Document) -> Comparison {
|
||||
let mut out = Comparison {
|
||||
id: NodeId(0),
|
||||
name: now.name.clone(),
|
||||
changed: false,
|
||||
snapshot: true,
|
||||
changes: Vec::new(),
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
truncated: false,
|
||||
};
|
||||
let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out };
|
||||
for p in &now.pages {
|
||||
if before.contains(*p) {
|
||||
w.layer(*p, 0);
|
||||
} else {
|
||||
w.whole(now, *p, 0, ChangeKind::Added);
|
||||
}
|
||||
}
|
||||
for p in before.pages.iter().filter(|p| !now.contains(**p)) {
|
||||
w.whole(before, *p, 0, ChangeKind::Removed);
|
||||
}
|
||||
out.changed = !out.changes.is_empty();
|
||||
out
|
||||
}
|
||||
|
||||
fn bounded_set(doc: &Document, id: NodeId) -> BTreeSet<NodeId> {
|
||||
doc.subtree(id).into_iter().take(MAX_SNAPSHOT_NODES * 2).collect()
|
||||
}
|
||||
|
|
@ -163,7 +192,9 @@ impl Walk<'_> {
|
|||
return;
|
||||
}
|
||||
let Some(now) = self.now.node(id) else { return };
|
||||
let props = layer_changes(self.before, self.now, id);
|
||||
// A layer shared by both versions under a shared parent didn't change (nor its place).
|
||||
let shared = |n: NodeId| self.before.node_arc(n).zip(self.now.node_arc(n)).is_some_and(|(x, y)| std::sync::Arc::ptr_eq(x, y));
|
||||
let props = if shared(id) && now.parent.is_none_or(shared) { Vec::new() } else { layer_changes(self.before, self.now, id) };
|
||||
if !props.is_empty() {
|
||||
self.push(LayerChange {
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ pub mod al_keys;
|
|||
pub mod annotations;
|
||||
pub mod arc_handles;
|
||||
pub mod booleans;
|
||||
pub mod branches;
|
||||
pub mod catalog;
|
||||
pub mod cmd;
|
||||
pub mod codegen;
|
||||
|
|
@ -253,7 +254,12 @@ pub struct DocState {
|
|||
pub uid: u64,
|
||||
/// The file's version history, oldest first (saved in the `.figcraft` file; not undoable).
|
||||
pub versions: Vec<figcraft_format::Version>,
|
||||
/// The version history changed since the last save.
|
||||
/// The file's branches (saved in the `.figcraft` file; not undoable).
|
||||
pub branches: Vec<figcraft_format::Branch>,
|
||||
/// This tab shows a branch: the `uid` of its main file's tab and the branch id. Its edits go
|
||||
/// into the main file's branch (and are saved with the main file).
|
||||
pub branch_of: Option<(u64, String)>,
|
||||
/// The version history or the branches changed since the last save.
|
||||
versions_changed: bool,
|
||||
/// A version shown in place of the document (read-only).
|
||||
pub preview: Option<VersionPreview>,
|
||||
|
|
@ -311,6 +317,8 @@ impl DocState {
|
|||
vector_edit: None,
|
||||
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
|
||||
versions: Vec::new(),
|
||||
branches: Vec::new(),
|
||||
branch_of: None,
|
||||
versions_changed: false,
|
||||
preview: None,
|
||||
text_preview: None,
|
||||
|
|
@ -326,6 +334,23 @@ impl DocState {
|
|||
self
|
||||
}
|
||||
|
||||
/// With the branches read from its file.
|
||||
pub fn with_branches(mut self, branches: Vec<figcraft_format::Branch>) -> Self {
|
||||
self.branches = branches;
|
||||
self
|
||||
}
|
||||
|
||||
/// The branches, for editing (the file then has unsaved changes).
|
||||
pub fn branches_mut(&mut self) -> &mut Vec<figcraft_format::Branch> {
|
||||
self.versions_changed = true;
|
||||
&mut self.branches
|
||||
}
|
||||
|
||||
/// The document itself, not a version or live text preview shown in its place.
|
||||
pub fn document_itself(&self) -> &Arc<Document> {
|
||||
self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc)
|
||||
}
|
||||
|
||||
/// The version history, for editing (the file then has unsaved changes).
|
||||
pub fn versions_mut(&mut self) -> &mut Vec<figcraft_format::Version> {
|
||||
self.versions_changed = true;
|
||||
|
|
@ -352,8 +377,7 @@ impl DocState {
|
|||
}
|
||||
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
let doc = self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc);
|
||||
!Arc::ptr_eq(doc, &self.saved_doc) || self.versions_changed
|
||||
!Arc::ptr_eq(self.document_itself(), &self.saved_doc) || self.versions_changed
|
||||
}
|
||||
|
||||
/// Show `version` in place of the document (or switch to it from another preview).
|
||||
|
|
@ -613,6 +637,7 @@ impl Session {
|
|||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
branches::sync(self);
|
||||
if textpreview::COMMANDS.contains(&id) {
|
||||
// A live preview is no edit: no undo step, normalisation or rollback here.
|
||||
let r = guard::run(id, || (spec.run)(self, params));
|
||||
|
|
@ -1159,6 +1184,8 @@ mod binding_lists_tests;
|
|||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod collab_live_tests;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
//! Branches in `.figcraft` files: round trips, files from before branches, damaged entries.
|
||||
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([9, 0, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn branch(base: &Document) -> Branch {
|
||||
let mut head = base.clone();
|
||||
let p = head.first_page().unwrap();
|
||||
head.create(p, NodeKind::Rectangle, "On the branch", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
add_image(&mut head, png()).unwrap();
|
||||
Branch {
|
||||
id: "b1".into(),
|
||||
name: "Explore".into(),
|
||||
time: 7,
|
||||
client: 3,
|
||||
state: BranchState::Merged,
|
||||
base: Arc::new(base.clone()),
|
||||
head: Arc::new(head),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_round_trip_with_their_images() {
|
||||
let doc = Document::new("File");
|
||||
let b = branch(&doc);
|
||||
let bytes = save_bytes_with_history(&doc, None, &[], std::slice::from_ref(&b)).unwrap();
|
||||
let l = load_bytes_with_versions(&bytes).unwrap();
|
||||
assert!(l.issues.is_empty(), "{:?}", l.issues);
|
||||
let got = l.branches.first().unwrap();
|
||||
assert_eq!((got.id.as_str(), got.name.as_str(), got.time, got.client, got.state), ("b1", "Explore", 7, 3, BranchState::Merged));
|
||||
assert_eq!(*got.base, *b.base);
|
||||
assert_eq!(*got.head, *b.head);
|
||||
// The image only the branch uses stays out of the document.
|
||||
assert!(l.doc.images.is_empty() && got.head.images.len() == 1);
|
||||
// Duplicate ids are refused when saving.
|
||||
assert!(save_bytes_with_history(&doc, None, &[], &[b.clone(), b]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_without_branches_and_damaged_branches_load() {
|
||||
let doc = Document::new("Old");
|
||||
// A file from before branches (and one saved without any) has none.
|
||||
let l = load_bytes_with_versions(&save_bytes(&doc, None).unwrap()).unwrap();
|
||||
assert!(l.branches.is_empty() && l.issues.is_empty());
|
||||
// A branch whose snapshot is missing is skipped and reported; the file still opens.
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let opts = zip::write::SimpleFileOptions::default();
|
||||
z.start_file("document.json", opts).unwrap();
|
||||
z.write_all(&serde_json::to_vec(&to_json(&doc).unwrap()).unwrap()).unwrap();
|
||||
z.start_file(BRANCH_INDEX, opts).unwrap();
|
||||
z.write_all(br#"{"branches": [{"id": "gone", "name": "x"}, {"id": "../bad"}]}"#).unwrap();
|
||||
z.finish().unwrap();
|
||||
}
|
||||
let l = load_bytes_with_versions(&buf.into_inner()).unwrap();
|
||||
assert!(l.branches.is_empty());
|
||||
assert_eq!(l.issues.len(), 2, "{:?}", l.issues);
|
||||
assert_eq!(l.doc.name, "Old");
|
||||
}
|
||||
|
|
@ -5,8 +5,11 @@
|
|||
//! `images/<sha256>` for every image the document (or a saved version) uses, an optional
|
||||
//! `thumbnail.png` and an optional version history: `versions/index.json` (`{"versions": [{id,
|
||||
//! name, description, time, images}]}`, oldest first) plus one `versions/<id>.json` snapshot per
|
||||
//! version (a `document.json` of its own). A published file also holds its library (see
|
||||
//! [`library`]). Readers that don't know about versions or libraries ignore those entries. Plain
|
||||
//! version (a `document.json` of its own). Branches are kept the same way: `branches/index.json`
|
||||
//! (`{"branches": [{id, name, time, client, state, baseImages, headImages}]}`) plus
|
||||
//! `branches/<id>/base.json` and `branches/<id>/head.json`. A published file also holds its
|
||||
//! library (see [`library`]). Readers that don't know about versions, branches or libraries
|
||||
//! ignore those entries. Plain
|
||||
//! JSON (the `document.json` alone) also opens. Reading is lenient: structural problems are
|
||||
//! repaired (see [`Document::repair`]) and reported, damaged versions and libraries are skipped.
|
||||
//!
|
||||
|
|
@ -42,6 +45,9 @@ pub const MAX_VERSION_DESCRIPTION: usize = 2000;
|
|||
const MAX_VERSION_INDEX: u64 = 16 * 1024 * 1024;
|
||||
const MAX_VERSION_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const VERSION_INDEX: &str = "versions/index.json";
|
||||
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;
|
||||
/// Deflate level of JSON members (see [`json_options`]).
|
||||
const JSON_LEVEL: i64 = 3;
|
||||
|
||||
|
|
@ -123,6 +129,34 @@ impl Version {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a branch is in its life.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum BranchState {
|
||||
Active,
|
||||
/// Merged into the main file (and archived).
|
||||
Merged,
|
||||
/// Archived without merging.
|
||||
Archived,
|
||||
}
|
||||
|
||||
/// A branch of the file: a copy of the document to work on separately, and the main document as
|
||||
/// it was when the branch was made or last updated from it (its base).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Branch {
|
||||
/// Unique among the file's branches; a valid version id ([`is_valid_version_id`]).
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// When it was made (Unix seconds).
|
||||
pub time: u64,
|
||||
/// The client id space the branch makes ids in ([`Document::set_client_id`]), so they never
|
||||
/// clash with ids made on the main file or on other branches.
|
||||
pub client: u32,
|
||||
pub state: BranchState,
|
||||
pub base: Arc<Document>,
|
||||
pub head: Arc<Document>,
|
||||
}
|
||||
|
||||
/// Whether `id` can name a version (and its archive entry).
|
||||
pub fn is_valid_version_id(id: &str) -> bool {
|
||||
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
|
|
@ -140,11 +174,12 @@ pub struct Loaded {
|
|||
pub doc: Document,
|
||||
pub issues: Vec<String>,
|
||||
pub versions: Vec<Version>,
|
||||
pub branches: Vec<Branch>,
|
||||
}
|
||||
|
||||
impl From<Document> for Loaded {
|
||||
fn from(doc: Document) -> Self {
|
||||
Self { doc, issues: Vec::new(), versions: Vec::new() }
|
||||
Self { doc, issues: Vec::new(), versions: Vec::new(), branches: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -310,14 +345,25 @@ pub fn save_bytes(doc: &Document, thumbnail_png: Option<&[u8]>) -> Result<Vec<u8
|
|||
|
||||
/// Write a `.figcraft` archive with a version history (oldest first) to bytes.
|
||||
pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, versions: &[Version]) -> Result<Vec<u8>, FormatError> {
|
||||
save_bytes_with_history(doc, thumbnail_png, versions, &[])
|
||||
}
|
||||
|
||||
/// Write a `.figcraft` archive with a version history (oldest first) and branches to bytes.
|
||||
pub fn save_bytes_with_history(
|
||||
doc: &Document,
|
||||
thumbnail_png: Option<&[u8]>,
|
||||
versions: &[Version],
|
||||
branches: &[Branch],
|
||||
) -> Result<Vec<u8>, FormatError> {
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
put_json(&mut z, "document.json", doc)?;
|
||||
// Every image once, whether the document or a version uses it.
|
||||
// Every image once, whether the document, a version or a branch uses it.
|
||||
let mut written = BTreeSet::new();
|
||||
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).flatten() {
|
||||
let branch_images = branches.iter().flat_map(|b| [&b.base.images, &b.head.images]);
|
||||
for (hash, img) in std::iter::once(&doc.images).chain(versions.iter().map(Version::images)).chain(branch_images).flatten() {
|
||||
if written.insert(hash) {
|
||||
put(&mut z, &format!("images/{hash}"), stored, &img.bytes)?;
|
||||
}
|
||||
|
|
@ -346,6 +392,21 @@ pub fn save_bytes_with_versions(doc: &Document, thumbnail_png: Option<&[u8]>, ve
|
|||
}
|
||||
put(&mut z, VERSION_INDEX, json_options(), &serde_json::to_vec(&json!({"versions": index})).map_err(io_err)?)?;
|
||||
}
|
||||
if !branches.is_empty() {
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut index = Vec::with_capacity(branches.len());
|
||||
for b in branches {
|
||||
if !is_valid_version_id(&b.id) || !ids.insert(b.id.as_str()) {
|
||||
return Err(FormatError::Io(format!("invalid or duplicate branch id `{}`", b.id)));
|
||||
}
|
||||
put_json(&mut z, &format!("branches/{}/base.json", b.id), &b.base)?;
|
||||
put_json(&mut z, &format!("branches/{}/head.json", b.id), &b.head)?;
|
||||
let images = |d: &Document| d.images.keys().cloned().collect::<Vec<String>>();
|
||||
index.push(json!({"id": b.id, "name": b.name, "time": b.time, "client": b.client, "state": b.state,
|
||||
"baseImages": images(&b.base), "headImages": images(&b.head)}));
|
||||
}
|
||||
put(&mut z, BRANCH_INDEX, json_options(), &serde_json::to_vec(&json!({"branches": index})).map_err(io_err)?)?;
|
||||
}
|
||||
if let Some(lib) = &doc.published {
|
||||
library::write(&mut z, lib, &written)?;
|
||||
}
|
||||
|
|
@ -397,11 +458,17 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
|||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
}
|
||||
let versions = read_versions(&mut z, &images, &mut issues);
|
||||
// Images only the version history uses stay with the versions.
|
||||
let version_only: BTreeSet<&String> =
|
||||
versions.iter().flat_map(|v| v.images().keys()).filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h))).collect();
|
||||
doc.images = images.into_iter().filter(|(h, _)| !version_only.contains(h)).collect();
|
||||
Ok(Loaded { doc, issues, versions })
|
||||
let branches = read_branches(&mut z, &images, &mut issues);
|
||||
// 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()));
|
||||
let history_only: BTreeSet<&String> = versions
|
||||
.iter()
|
||||
.flat_map(|v| v.images().keys())
|
||||
.chain(branch_images)
|
||||
.filter(|h| own_images.as_ref().is_some_and(|o| !o.contains(*h)))
|
||||
.collect();
|
||||
doc.images = images.into_iter().filter(|(h, _)| !history_only.contains(h)).collect();
|
||||
Ok(Loaded { doc, issues, versions, branches })
|
||||
}
|
||||
|
||||
/// The version history of an archive (none when it has no index). Damaged entries are skipped and
|
||||
|
|
@ -461,6 +528,62 @@ fn read_versions<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
|
|||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if z.index_for_name(BRANCH_INDEX).is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let index = match read_member(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,
|
||||
Err(e) => {
|
||||
issues.push(format!("the branches were skipped: {e}"));
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
let entries = index.get("branches").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
let mut out: Vec<Branch> = Vec::new();
|
||||
let mut budget = MAX_VERSION_BYTES;
|
||||
for e in entries {
|
||||
if out.len() >= MAX_BRANCHES {
|
||||
issues.push(format!("only the first {MAX_BRANCHES} branches were kept"));
|
||||
break;
|
||||
}
|
||||
let id = e.get("id").and_then(Value::as_str).unwrap_or_default();
|
||||
if !is_valid_version_id(id) || out.iter().any(|b| b.id == id) {
|
||||
issues.push("a branch with an invalid id was skipped".into());
|
||||
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))?;
|
||||
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);
|
||||
doc.images = hashes.filter_map(|h| images.get(h).map(|i| (h.to_string(), i.clone()))).collect();
|
||||
Ok(Arc::new(doc))
|
||||
};
|
||||
let (base, head) = match (snapshot("base", "baseImages"), snapshot("head", "headImages")) {
|
||||
(Ok(b), Ok(h)) => (b, h),
|
||||
(Err(err), _) | (_, Err(err)) => {
|
||||
issues.push(format!("branch `{id}` was skipped: {err}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
out.push(Branch {
|
||||
id: id.to_string(),
|
||||
name: e.get("name").and_then(Value::as_str).map(|s| clip(s, MAX_VERSION_NAME)).unwrap_or_default(),
|
||||
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),
|
||||
base,
|
||||
head,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Save atomically: write a temp file next to `path`, then rename over it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> Result<(), FormatError> {
|
||||
|
|
@ -477,6 +600,8 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
|||
load_bytes(&bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
//! Branches in the UI: the banner on a branch tab ("Branch of <file>", Review and merge) and the
|
||||
//! review dialog: the main file and the branch side by side (or one over the other) with
|
||||
//! Dev Mode's compare pieces, the conflicts with a branch / main choice each, what changed on each
|
||||
//! side, and Update from main / Merge. The Branches list is in the version history panel.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Ui, pos2, vec2};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::branches::{self, BranchState, Review, Side, SideChanges};
|
||||
use figcraft_engine::devcompare::ChangeKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::dev_compare::{self, BAR_H, LIST_W, Mode, Shot};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
||||
/// The open review dialog.
|
||||
pub struct ReviewView {
|
||||
/// The main file's tab (its uid) and the branch.
|
||||
pub main: u64,
|
||||
pub id: String,
|
||||
pub mode: Mode,
|
||||
/// Where the overlay slider is (0 = all branch, 1 = all main).
|
||||
pub split: f32,
|
||||
/// The side chosen per conflict key.
|
||||
pub picks: BTreeMap<String, Side>,
|
||||
/// The layer whose frame the images show.
|
||||
selected: Option<NodeId>,
|
||||
hovered: Option<NodeId>,
|
||||
cache: Option<Cached>,
|
||||
}
|
||||
|
||||
impl ReviewView {
|
||||
pub fn new(main: u64, id: String) -> Self {
|
||||
Self { main, id, mode: Mode::default(), split: 0.5, picks: BTreeMap::new(), selected: None, hovered: None, cache: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// What the dialog shows, built for one version of the main file and the branch.
|
||||
struct Cached {
|
||||
docs: [Arc<Document>; 3],
|
||||
review: Result<Review, String>,
|
||||
/// The frame the shots show, and the main file's and the branch's renderings of it.
|
||||
shots: Option<(NodeId, Option<Shot>, Option<Shot>)>,
|
||||
}
|
||||
|
||||
/// Open the review of branch `id` of the file shown (or of the file a branch tab belongs to).
|
||||
pub fn open(app: &mut FigApp, id: Option<&str>) {
|
||||
let Ok((mi, bi)) = branches::locate(&app.session, "branch.review", id) else { return };
|
||||
let found = app.session.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone())));
|
||||
app.ui.branch_review = found.map(|(main, id)| ReviewView::new(main, id));
|
||||
}
|
||||
|
||||
/// Escape closes the dialog (before the canvas's shortcuts see it).
|
||||
pub fn keys(app: &mut FigApp, ctx: &egui::Context) {
|
||||
if app.ui.branch_review.is_some() && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
app.ui.branch_review = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// On a branch tab: a banner over the canvas naming the branch and its file, with Review and
|
||||
/// merge.
|
||||
pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let Some((main, id)) = ds.branch_of.clone() else { return };
|
||||
if ds.focus.is_some() || app.ui.branch_review.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(m) = app.session.docs.iter().find(|d| d.uid == main) else { return };
|
||||
let Some(b) = m.branches.iter().find(|b| b.id == id) else { return };
|
||||
let (file, name, active) = (m.doc.name.clone(), b.name.clone(), b.state == BranchState::Active);
|
||||
let t = Tokens::get(ctx);
|
||||
let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| {
|
||||
ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text));
|
||||
let state = if active { format!("Branch of {file}") } else { format!("Branch of {file} · merged or archived") };
|
||||
ui.label(egui::RichText::new(state).font(regular(11.0)).color(t.text_secondary));
|
||||
ui.add_space(4.0);
|
||||
widgets::button(ui, "Review and merge", true, active).clicked()
|
||||
});
|
||||
if review {
|
||||
app.ui.branch_review = Some(ReviewView::new(main, id));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the footer or a row asked for.
|
||||
enum Action {
|
||||
Show(NodeId),
|
||||
Pick(String, Side),
|
||||
Update,
|
||||
Merge,
|
||||
Close,
|
||||
}
|
||||
|
||||
/// The top-level frame (a page's child) `id` is in, or `id` itself.
|
||||
fn frame_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let mut cur = id;
|
||||
for _ in 0..10_000 {
|
||||
let parent = doc.node(cur)?.parent?;
|
||||
if doc.node(parent)?.is_page() {
|
||||
return Some(cur);
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `doc`'s rendering of frame `frame`, the layers of `changes` in it outlined.
|
||||
fn shot(ctx: &egui::Context, doc: &Document, frame: NodeId, changes: &SideChanges, conflicts: &[NodeId]) -> Option<Shot> {
|
||||
doc.node(frame)?;
|
||||
let marks = changes.layers.iter().map(|c| (c.id, c.change)).chain(conflicts.iter().map(|id| (*id, ChangeKind::Changed)));
|
||||
Shot::new(ctx, doc, frame, marks.filter(|(id, _)| *id != frame && frame_of(doc, *id) == Some(frame)))
|
||||
}
|
||||
|
||||
/// The review dialog, while open.
|
||||
pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let Some(mut view) = app.ui.branch_review.take() else { return };
|
||||
let Some(mi) = app.session.docs.iter().position(|d| d.uid == view.main) else { return };
|
||||
let Some(bi) = app.session.docs.get(mi).and_then(|m| m.branches.iter().position(|b| b.id == view.id)) else { return };
|
||||
let Some((base, head, main)) = branches::documents(&app.session, mi, bi) else { return };
|
||||
let fresh = |c: &Cached| [&base, &head, &main].iter().zip(&c.docs).all(|(a, b)| Arc::ptr_eq(a, b));
|
||||
if !view.cache.as_ref().is_some_and(fresh) {
|
||||
let review = branches::review_at(&app.session, mi, bi).map_err(|e| e.to_string());
|
||||
view.picks.retain(|k, _| review.as_ref().is_ok_and(|r| r.conflicts.iter().any(|c| c.key == *k)));
|
||||
view.cache = Some(Cached { docs: [base, head.clone(), main.clone()], review, shots: None });
|
||||
}
|
||||
let Some(cache) = view.cache.as_mut() else { return };
|
||||
// The frame to show: the chosen layer's, else the first conflict's or change's.
|
||||
if let Ok(r) = &cache.review {
|
||||
let first = r.conflicts.iter().filter_map(|c| c.id).chain(r.branch.layers.iter().chain(&r.main.layers).map(|c| c.id)).next();
|
||||
let target = view.selected.or(first);
|
||||
let frame = target.and_then(|id| frame_of(&main, id).or_else(|| frame_of(&head, id)));
|
||||
if cache.shots.as_ref().map(|s| s.0) != frame {
|
||||
let conflicts: Vec<NodeId> = r.conflicts.iter().filter_map(|c| c.id).collect();
|
||||
cache.shots = frame.map(|f| (f, shot(ctx, &main, f, &r.main, &conflicts), shot(ctx, &head, f, &r.branch, &conflicts)));
|
||||
}
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let size = vec2(1120.0_f32.min(screen.width() - 64.0).max(560.0), 700.0_f32.min(screen.height() - 96.0).max(320.0));
|
||||
let mut open = true;
|
||||
let mut action = None;
|
||||
egui::Window::new("Review and merge")
|
||||
.id(Id::new("branch_review_window"))
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.fixed_size(size)
|
||||
.pivot(Align2::CENTER_CENTER)
|
||||
.default_pos(screen.center())
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(0)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_size(size);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let Some(cache) = view.cache.as_ref() else { return };
|
||||
let name = cache.review.as_ref().map(|r| r.name.clone()).unwrap_or_default();
|
||||
dev_compare::header(ui, &t, "Review and merge", &name, &mut view.mode, &mut open);
|
||||
let body_h = size.y - BAR_H * 2.0 - 2.0;
|
||||
ui.horizontal_top(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(size.x - LIST_W - 1.0, body_h), Sense::hover());
|
||||
match cache.shots.as_ref() {
|
||||
Some((_, Some(m), Some(b))) => {
|
||||
dev_compare::images(ui, &t, r, [(m, "Main"), (b, "Branch")], view.mode, &mut view.split, view.hovered)
|
||||
}
|
||||
Some((_, None, Some(_))) => dev_compare::message(ui, &t, r, "This frame is new on the branch."),
|
||||
Some((_, Some(_), None)) => dev_compare::message(ui, &t, r, "This frame isn't on the branch."),
|
||||
_ => dev_compare::message(ui, &t, r, "No layer changes to show."),
|
||||
}
|
||||
let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover());
|
||||
ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
|
||||
ui.allocate_ui_with_layout(vec2(LIST_W, body_h), egui::Layout::top_down(egui::Align::Min), |ui| match &cache.review {
|
||||
Ok(r) => {
|
||||
if let Some(a) = list(ui, &t, r, &view.picks, view.selected, &mut view.hovered) {
|
||||
action = Some(a);
|
||||
}
|
||||
}
|
||||
Err(e) => widgets::note(ui, &format!("This branch can't be reviewed: {e}"), t.danger),
|
||||
});
|
||||
});
|
||||
if let Ok(r) = &cache.review
|
||||
&& let Some(a) = footer(ui, &t, r, &view.picks)
|
||||
{
|
||||
action = Some(a);
|
||||
}
|
||||
});
|
||||
let resolutions = || json!(view.picks.iter().map(|(k, s)| (k.clone(), json!(s.name()))).collect::<serde_json::Map<_, _>>());
|
||||
match action {
|
||||
Some(Action::Show(id)) => view.selected = Some(id),
|
||||
Some(Action::Pick(key, side)) => {
|
||||
view.picks.insert(key, side);
|
||||
}
|
||||
Some(Action::Update) => {
|
||||
if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() {
|
||||
app.toast("Branch updated from main");
|
||||
view.picks.clear();
|
||||
}
|
||||
}
|
||||
Some(Action::Merge) => {
|
||||
if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() {
|
||||
app.toast("Branch merged");
|
||||
open = false;
|
||||
}
|
||||
}
|
||||
Some(Action::Close) => open = false,
|
||||
None => {}
|
||||
}
|
||||
if open {
|
||||
app.ui.branch_review = Some(view);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section title with a count.
|
||||
fn title(ui: &mut Ui, t: &Tokens, text: &str, n: usize) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, format!("{text} · {n}"), semibold(11.0), t.text);
|
||||
}
|
||||
|
||||
/// Conflicts with their choice, then each side's changes; a click on a layer shows its frame.
|
||||
fn list(
|
||||
ui: &mut Ui,
|
||||
t: &Tokens,
|
||||
r: &Review,
|
||||
picks: &BTreeMap<String, Side>,
|
||||
selected: Option<NodeId>,
|
||||
hovered: &mut Option<NodeId>,
|
||||
) -> Option<Action> {
|
||||
let mut out = None;
|
||||
let mut now_hovered = None;
|
||||
egui::ScrollArea::vertical().id_salt("branch_review_list").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
if !r.conflicts.is_empty() {
|
||||
title(ui, t, "Conflicts", r.conflicts.len());
|
||||
for c in &r.conflicts {
|
||||
let label = match c.deleted_by {
|
||||
Some(Side::Branch) => format!("{} · deleted on the branch", c.name),
|
||||
Some(Side::Main) => format!("{} · deleted on main", c.name),
|
||||
None => format!("{} · {}", c.name, c.property),
|
||||
};
|
||||
let resp = dev_compare::change_row(
|
||||
ui,
|
||||
t,
|
||||
ChangeKind::Changed,
|
||||
&label,
|
||||
0,
|
||||
1,
|
||||
c.id.is_some() && c.id == selected,
|
||||
"Changed on both sides: keep one",
|
||||
);
|
||||
if resp.hovered() {
|
||||
now_hovered = c.id;
|
||||
}
|
||||
if let Some(id) = c.id.filter(|_| resp.clicked()) {
|
||||
out = Some(Action::Show(id));
|
||||
}
|
||||
let pick = picks.get(&c.key).copied();
|
||||
for (side, side_name, value) in [(Side::Branch, "Branch", &c.branch), (Side::Main, "Main", &c.main)] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(28.0);
|
||||
let text = widgets::elide(ui, &format!("{side_name}: {value}"), 200.0);
|
||||
if widgets::radio(ui, pick == Some(side), &text) {
|
||||
out = Some(Action::Pick(c.key.clone(), side));
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
widgets::separator(ui);
|
||||
}
|
||||
for (label, side) in [("Changes on the branch", &r.branch), ("Changes on main", &r.main)] {
|
||||
title(ui, t, label, side.layers.len() + side.document.len());
|
||||
if side.layers.is_empty() && side.document.is_empty() {
|
||||
widgets::note(ui, "None.", t.text_secondary);
|
||||
}
|
||||
for c in &side.layers {
|
||||
let resp =
|
||||
dev_compare::change_row(ui, t, c.change, &c.name, c.depth.saturating_sub(1), c.layers, selected == Some(c.id), "Show this layer");
|
||||
if resp.hovered() {
|
||||
now_hovered = Some(c.id);
|
||||
}
|
||||
if resp.clicked() {
|
||||
out = Some(Action::Show(c.id));
|
||||
}
|
||||
dev_compare::property_lines(ui, t, c.depth.saturating_sub(1), c.properties.iter().map(|pc| pc.text.as_str()));
|
||||
}
|
||||
for d in &side.document {
|
||||
dev_compare::change_row(ui, t, d.change, &d.what, 0, 1, false, &d.what);
|
||||
}
|
||||
if side.truncated {
|
||||
widgets::note(ui, "Only the first changed layers are listed.", t.text_secondary);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
*hovered = now_hovered;
|
||||
out
|
||||
}
|
||||
|
||||
fn footer(ui: &mut Ui, t: &Tokens, r: &Review, picks: &BTreeMap<String, Side>) -> Option<Action> {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover());
|
||||
ui.painter().rect_filled(Rect::from_min_size(rect.min, vec2(rect.width(), 1.0)), CornerRadius::ZERO, t.border);
|
||||
let left = r.conflicts.iter().filter(|c| !picks.contains_key(&c.key)).count();
|
||||
let active = r.state == BranchState::Active;
|
||||
let status = match (active, left) {
|
||||
(false, _) => "This branch was merged or archived.".to_string(),
|
||||
(true, 0) if r.conflicts.is_empty() => "No conflicts.".to_string(),
|
||||
(true, 0) => "Every conflict has a choice.".to_string(),
|
||||
(true, n) => format!("{n} conflict{} to resolve", if n == 1 { "" } else { "s" }),
|
||||
};
|
||||
ui.painter().text(
|
||||
pos2(rect.min.x + 16.0, rect.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
status,
|
||||
regular(11.0),
|
||||
if left > 0 { t.danger } else { t.text_secondary },
|
||||
);
|
||||
let mut out = None;
|
||||
let inner = Rect::from_min_max(pos2(rect.min.x + 16.0, rect.min.y + 1.0), pos2(rect.max.x - 16.0, rect.max.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let ready = active && left == 0;
|
||||
if widgets::button(ui, "Merge into main", true, ready)
|
||||
.on_hover_text("Merge the branch into the main file (one undo step) and archive it")
|
||||
.clicked()
|
||||
{
|
||||
out = Some(Action::Merge);
|
||||
}
|
||||
let main_changed = !(r.main.layers.is_empty() && r.main.document.is_empty());
|
||||
if widgets::button(ui, "Update from main", false, ready && main_changed)
|
||||
.on_hover_text("Bring the main file's changes into the branch")
|
||||
.clicked()
|
||||
{
|
||||
out = Some(Action::Update);
|
||||
}
|
||||
if widgets::button(ui, "Close", false, true).clicked() {
|
||||
out = Some(Action::Close);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
//! The branch review dialog, driven headless (the harness is `present_tests`'): it opens on a
|
||||
//! branch with a conflict, keeps chosen sides while they still name a conflict, and closes with
|
||||
//! Escape.
|
||||
|
||||
use egui::{Key, pos2};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::branches::Side;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::present_tests::{Ui, id};
|
||||
|
||||
#[test]
|
||||
fn review_dialog_opens_keeps_choices_and_closes() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
let r = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 50, "parent": f})).unwrap());
|
||||
let b = s.execute("branch.create", &json!({"name": "Try"})).unwrap()["id"].as_str().unwrap().to_string();
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#FF0000"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("fill.set", &json!({"ids": [r], "color": "#0000FF"})).unwrap();
|
||||
s.execute("doc.switch", &json!({"index": 1})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
crate::branch_ui::open(&mut u.app, None);
|
||||
let key = format!("{r}/fills");
|
||||
if let Some(v) = u.app.ui.branch_review.as_mut() {
|
||||
v.picks.insert(key.clone(), Side::Branch);
|
||||
v.picks.insert("stale/key".into(), Side::Main);
|
||||
}
|
||||
u.frames(2, Vec::new);
|
||||
let state = &u.app.inspect()["branchReview"];
|
||||
assert_eq!(state["id"], json!(b));
|
||||
assert_eq!(state["picks"], json!({key: "branch"}), "choices for conflicts that no longer exist are dropped");
|
||||
u.key(pos2(700.0, 450.0), Key::Escape);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.branch_review.is_none());
|
||||
}
|
||||
|
|
@ -197,6 +197,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
|||
crate::live::draw(app, &painter, &ctx);
|
||||
crate::comments::popovers(app, &ctx);
|
||||
crate::versions::preview_banner(app, &ctx, rect);
|
||||
crate::branch_ui::banner(app, &ctx, rect);
|
||||
crate::dev_mode::focus_bar(app, &ctx, rect);
|
||||
// Cursor.
|
||||
if resp.hovered() {
|
||||
|
|
|
|||
|
|
@ -478,6 +478,20 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
|||
c.split = x.clamp(0.0, 1.0) as f32;
|
||||
}
|
||||
}
|
||||
// The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
|
||||
// chooses sides ({<conflict key>: "branch" | "main"}).
|
||||
if let Some(v) = p.get("branchReview") {
|
||||
match v.as_str() {
|
||||
Some(id) => crate::branch_ui::open(app, Some(id)),
|
||||
None => app.ui.branch_review = None,
|
||||
}
|
||||
}
|
||||
if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
|
||||
for (k, v) in picks {
|
||||
let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
|
||||
r.picks.insert(k.clone(), side);
|
||||
}
|
||||
}
|
||||
// Which annotations the canvas shows: all, none (uncategorised) or a category.
|
||||
if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
|
||||
let doc = app.session.doc().map_err(|e| e.to_string())?;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Dev Mode's compare view: a ready-for-dev frame or section as it was when it was marked next to
|
||||
//! how it is now (side by side, or one over the other with a slider), the changed layers outlined,
|
||||
//! and the list of what changed (`dev.compare`). Rendered once per document version.
|
||||
//! and the list of what changed (`dev.compare`). Rendered once per document version. The branch
|
||||
//! review ([`crate::branch_ui`]) shows two versions with the same pieces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -15,9 +16,9 @@ use crate::theme::{ACCENT, DEV_CHANGED, Tokens, regular, semibold};
|
|||
use crate::widgets;
|
||||
|
||||
/// Width of the list of changes.
|
||||
const LIST_W: f32 = 330.0;
|
||||
pub(crate) const LIST_W: f32 = 330.0;
|
||||
/// Height of the header and footer rows.
|
||||
const BAR_H: f32 = 48.0;
|
||||
pub(crate) const BAR_H: f32 = 48.0;
|
||||
/// Most texture pixels on an image's longer side.
|
||||
const TEXTURE_SIDE: f32 = 1400.0;
|
||||
|
||||
|
|
@ -56,25 +57,26 @@ struct Cached {
|
|||
}
|
||||
|
||||
/// One version rendered: its texture, the page area it shows and where each listed layer is.
|
||||
struct Shot {
|
||||
pub(crate) struct Shot {
|
||||
tex: egui::TextureHandle,
|
||||
area: figcraft_geom::Rect,
|
||||
boxes: Vec<(NodeId, ChangeKind, figcraft_geom::Rect)>,
|
||||
}
|
||||
|
||||
impl Shot {
|
||||
fn new(ctx: &egui::Context, doc: &Document, id: NodeId, changes: &[LayerChange], old: bool) -> Option<Self> {
|
||||
/// Layer `id` of `doc` rendered, the layers `marks` outlined.
|
||||
pub(crate) fn new(ctx: &egui::Context, doc: &Document, id: NodeId, marks: impl Iterator<Item = (NodeId, ChangeKind)>) -> Option<Self> {
|
||||
let area = figcraft_render::export_bounds(doc, id)?;
|
||||
let tex = crate::assets_panel::thumbnail(ctx, doc, id, TEXTURE_SIDE)?;
|
||||
// The old version outlines what was removed, the new one what was added or changed.
|
||||
let boxes = changes
|
||||
.iter()
|
||||
.filter(|c| c.depth > 0 && (c.change == ChangeKind::Removed) == old)
|
||||
.filter_map(|c| doc.world_bounds(c.id).map(|b| (c.id, c.change, b)))
|
||||
.collect();
|
||||
let boxes = marks.filter_map(|(c, kind)| doc.world_bounds(c).map(|b| (c, kind, b))).collect();
|
||||
Some(Self { tex, area, boxes })
|
||||
}
|
||||
|
||||
/// The old version outlines what was removed, the new one what was added or changed.
|
||||
fn of_changes(ctx: &egui::Context, doc: &Document, id: NodeId, changes: &[LayerChange], old: bool) -> Option<Self> {
|
||||
Self::new(ctx, doc, id, changes.iter().filter(|c| c.depth > 0 && (c.change == ChangeKind::Removed) == old).map(|c| (c.id, c.change)))
|
||||
}
|
||||
|
||||
/// Its size in page units.
|
||||
fn size(&self) -> egui::Vec2 {
|
||||
vec2(self.area.width().max(1e-6) as f32, self.area.height().max(1e-6) as f32)
|
||||
|
|
@ -106,7 +108,7 @@ impl Place {
|
|||
}
|
||||
|
||||
/// The colour a kind of change is marked with.
|
||||
fn kind_color(t: &Tokens, k: ChangeKind) -> Color32 {
|
||||
pub(crate) fn kind_color(t: &Tokens, k: ChangeKind) -> Color32 {
|
||||
match k {
|
||||
ChangeKind::Added => t.success,
|
||||
ChangeKind::Removed => t.danger,
|
||||
|
|
@ -131,8 +133,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
}
|
||||
if view.cache.as_ref().is_none_or(|c| !Arc::ptr_eq(&c.doc, &doc)) {
|
||||
let cmp = devcompare::compare(&doc, view.id);
|
||||
let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::new(ctx, &b, view.id, &cmp.changes, true));
|
||||
let after = Shot::new(ctx, &doc, view.id, &cmp.changes, false);
|
||||
let before = devcompare::before_doc(&doc, view.id).and_then(|b| Shot::of_changes(ctx, &b, view.id, &cmp.changes, true));
|
||||
let after = Shot::of_changes(ctx, &doc, view.id, &cmp.changes, false);
|
||||
view.cache = Some(Cached { doc: doc.clone(), cmp, before, after });
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
|
|
@ -153,13 +155,22 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
ui.set_min_size(size);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let Some(cache) = view.cache.as_ref() else { return };
|
||||
header(ui, &t, &cache.cmp, &mut view.mode, &mut open);
|
||||
header(ui, &t, "Compare changes", &cache.cmp.name, &mut view.mode, &mut open);
|
||||
let body_h = size.y - BAR_H * 2.0 - 2.0;
|
||||
let top_down = egui::Layout::top_down(egui::Align::Min);
|
||||
ui.horizontal_top(|ui| {
|
||||
let images_w = size.x - LIST_W - 1.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover());
|
||||
images(ui, &t, r, cache, view.mode, &mut view.split, view.hovered);
|
||||
if !cache.cmp.snapshot {
|
||||
let msg = if cache.cmp.changed {
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on."
|
||||
} else {
|
||||
"Nothing changed since this was marked ready for dev."
|
||||
};
|
||||
message(ui, &t, r, msg);
|
||||
} else if let (Some(before), Some(after)) = (&cache.before, &cache.after) {
|
||||
images(ui, &t, r, [(before, "Before"), (after, "Now")], view.mode, &mut view.split, view.hovered);
|
||||
}
|
||||
let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover());
|
||||
ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
|
||||
ui.allocate_ui_with_layout(vec2(LIST_W, body_h), top_down, |ui| {
|
||||
|
|
@ -196,13 +207,14 @@ enum Action {
|
|||
Close,
|
||||
}
|
||||
|
||||
fn header(ui: &mut Ui, t: &Tokens, cmp: &Comparison, mode: &mut Mode, open: &mut bool) {
|
||||
/// The title row: `title` and `name`, the side by side / overlay switch and the close button.
|
||||
pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mut Mode, open: &mut bool) {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H), Sense::hover());
|
||||
let p = ui.painter();
|
||||
let title = p.layout_no_wrap("Compare changes".into(), semibold(13.0), t.text);
|
||||
let title = p.layout_no_wrap(title.into(), semibold(13.0), t.text);
|
||||
let tw = title.size().x;
|
||||
p.galley(pos2(r.min.x + 16.0, r.center().y - title.size().y / 2.0), title, t.text);
|
||||
let name = widgets::elide(ui, &cmp.name, 320.0);
|
||||
let name = widgets::elide(ui, name, 320.0);
|
||||
ui.painter().text(pos2(r.min.x + 28.0 + tw, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text_secondary);
|
||||
// Side by side / overlay, as a two-way switch left of the close button.
|
||||
let modes = [(Mode::SideBySide, "Side by side"), (Mode::Overlay, "Overlay")];
|
||||
|
|
@ -238,21 +250,19 @@ fn header(ui: &mut Ui, t: &Tokens, cmp: &Comparison, mode: &mut Mode, open: &mut
|
|||
ui.painter().rect_filled(line, CornerRadius::ZERO, t.border);
|
||||
}
|
||||
|
||||
/// The two versions in `r`.
|
||||
fn images(ui: &mut Ui, t: &Tokens, r: Rect, cache: &Cached, mode: Mode, split: &mut f32, hovered: Option<NodeId>) {
|
||||
/// `msg` in the middle of the images' area `r`.
|
||||
pub(crate) fn message(ui: &Ui, t: &Tokens, r: Rect, msg: &str) {
|
||||
let p = ui.painter_at(r);
|
||||
p.rect_filled(r, CornerRadius::ZERO, t.canvas);
|
||||
if !cache.cmp.snapshot {
|
||||
let msg = if cache.cmp.changed {
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on."
|
||||
} else {
|
||||
"Nothing changed since this was marked ready for dev."
|
||||
};
|
||||
let g = p.layout(msg.into(), regular(12.0), t.text_secondary, (r.width() - 120.0).max(120.0));
|
||||
p.galley(r.center() - g.size() / 2.0, g, t.text_secondary);
|
||||
return;
|
||||
}
|
||||
let (Some(before), Some(after)) = (&cache.before, &cache.after) else { return };
|
||||
let g = p.layout(msg.into(), regular(12.0), t.text_secondary, (r.width() - 120.0).max(120.0));
|
||||
p.galley(r.center() - g.size() / 2.0, g, t.text_secondary);
|
||||
}
|
||||
|
||||
/// The two versions (`[(old, its label), (new, its label)]`) in `r`.
|
||||
pub(crate) fn images(ui: &mut Ui, t: &Tokens, r: Rect, shots: [(&Shot, &str); 2], mode: Mode, split: &mut f32, hovered: Option<NodeId>) {
|
||||
let p = ui.painter_at(r);
|
||||
p.rect_filled(r, CornerRadius::ZERO, t.canvas);
|
||||
let [(before, before_label), (after, after_label)] = shots;
|
||||
// Room for the labels above the images.
|
||||
let inner = r.shrink2(vec2(24.0, 20.0));
|
||||
let label_y = inner.min.y + 8.0;
|
||||
|
|
@ -262,7 +272,7 @@ fn images(ui: &mut Ui, t: &Tokens, r: Rect, cache: &Cached, mode: Mode, split: &
|
|||
let w = (area.width() - 24.0) / 2.0;
|
||||
let left = Rect::from_min_size(area.min, vec2(w, area.height()));
|
||||
let right = Rect::from_min_size(pos2(left.max.x + 24.0, area.min.y), vec2(w, area.height()));
|
||||
for (shot, half, label) in [(before, left, "Before"), (after, right, "Now")] {
|
||||
for (shot, half, label) in [(before, left, before_label), (after, right, after_label)] {
|
||||
let place = Place::fit(half, shot.size());
|
||||
p.text(pos2(place.min.x, label_y), Align2::LEFT_CENTER, label, semibold(11.0), t.text_secondary);
|
||||
pane(&p, t, place, shot, hovered);
|
||||
|
|
@ -276,8 +286,8 @@ fn images(ui: &mut Ui, t: &Tokens, r: Rect, cache: &Cached, mode: Mode, split: &
|
|||
let split_x = img.min.x + img.width() * *split;
|
||||
pane(&p, t, place, after, hovered);
|
||||
pane(&p.with_clip_rect(Rect::from_min_max(r.min, pos2(split_x, r.max.y))), t, place, before, hovered);
|
||||
p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, "Before", semibold(11.0), t.text_secondary);
|
||||
p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, "Now", semibold(11.0), t.text_secondary);
|
||||
p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, before_label, semibold(11.0), t.text_secondary);
|
||||
p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, after_label, semibold(11.0), t.text_secondary);
|
||||
// The slider: a line with a knob, dragged (or clicked) anywhere over the images.
|
||||
let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag());
|
||||
if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) {
|
||||
|
|
@ -332,46 +342,15 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option<NodeId>)
|
|||
egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
for c in &cmp.changes {
|
||||
let indent = 16.0 + (c.depth.min(6) as f32) * 10.0;
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
|
||||
let resp =
|
||||
if c.change == ChangeKind::Removed { resp.on_hover_text("Removed since it was marked") } else { resp.on_hover_text("Select layer") };
|
||||
crate::a11y::list_item(&resp, &format!("{} {}", c.change.label(), c.name), false);
|
||||
let tip = if c.change == ChangeKind::Removed { "Removed since it was marked" } else { "Select layer" };
|
||||
let resp = change_row(ui, t, c.change, &c.name, c.depth, c.layers, false, tip);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.hover);
|
||||
now_hovered = Some(c.id);
|
||||
}
|
||||
let col = kind_color(t, c.change);
|
||||
let tag = ui.painter().layout_no_wrap(c.change.label().into(), semibold(9.0), col);
|
||||
let tag_r = Rect::from_min_size(pos2(row.max.x - 16.0 - tag.size().x - 10.0, row.center().y - 8.0), vec2(tag.size().x + 10.0, 16.0));
|
||||
ui.painter().rect_filled(tag_r, CornerRadius::same(4), col.gamma_multiply(0.15));
|
||||
ui.painter().galley(tag_r.center() - tag.size() / 2.0, tag, col);
|
||||
ui.painter().circle_filled(pos2(row.min.x + indent + 3.0, row.center().y), 3.0, col);
|
||||
let mut name = c.name.clone();
|
||||
if c.layers > 1 {
|
||||
name = format!("{name} ({} layers)", c.layers);
|
||||
}
|
||||
let name = widgets::elide(ui, &name, (tag_r.min.x - row.min.x - indent - 20.0).max(20.0));
|
||||
let strike = c.change == ChangeKind::Removed;
|
||||
let galley = ui.painter().layout_no_wrap(name, regular(11.0), if strike { t.text_secondary } else { t.text });
|
||||
let at = pos2(row.min.x + indent + 12.0, row.center().y - galley.size().y / 2.0);
|
||||
if strike {
|
||||
let y = at.y + galley.size().y / 2.0;
|
||||
ui.painter().line_segment([pos2(at.x, y), pos2(at.x + galley.size().x, y)], Stroke::new(1.0, t.text_secondary));
|
||||
}
|
||||
ui.painter().galley(at, galley, t.text);
|
||||
if resp.clicked() && c.change != ChangeKind::Removed {
|
||||
out = Some(Action::Select(c.id));
|
||||
}
|
||||
for pc in &c.properties {
|
||||
let width = ui.available_width() - indent - 28.0;
|
||||
let g = ui.painter().layout(pc.text.clone(), regular(10.0), t.text_secondary, width.max(40.0));
|
||||
let (pr, _) = ui.allocate_exact_size(vec2(ui.available_width(), g.size().y + 4.0), Sense::hover());
|
||||
ui.painter().galley(pos2(pr.min.x + indent + 12.0, pr.min.y + 2.0), g, t.text_secondary);
|
||||
}
|
||||
if !c.properties.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
property_lines(ui, t, c.depth, c.properties.iter().map(|pc| pc.text.as_str()));
|
||||
}
|
||||
if cmp.truncated {
|
||||
widgets::note(ui, &format!("Only the first {} changed layers are listed.", devcompare::MAX_CHANGES), t.text_secondary);
|
||||
|
|
@ -381,6 +360,62 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option<NodeId>)
|
|||
out
|
||||
}
|
||||
|
||||
/// One changed layer (or other object) in a list of changes: its name `depth` deep (`layers`: how
|
||||
/// many layers it is), tagged with the kind of change, `selected` when it is the one shown.
|
||||
pub(crate) fn change_row(
|
||||
ui: &mut Ui,
|
||||
t: &Tokens,
|
||||
kind: ChangeKind,
|
||||
name: &str,
|
||||
depth: usize,
|
||||
layers: usize,
|
||||
selected: bool,
|
||||
tip: &str,
|
||||
) -> egui::Response {
|
||||
let indent = 16.0 + (depth.min(6) as f32) * 10.0;
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
|
||||
let resp = resp.on_hover_text(tip);
|
||||
crate::a11y::list_item(&resp, &format!("{} {name}", kind.label()), selected);
|
||||
if selected {
|
||||
ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.selected_row);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.hover);
|
||||
}
|
||||
let col = kind_color(t, kind);
|
||||
let tag = ui.painter().layout_no_wrap(kind.label().into(), semibold(9.0), col);
|
||||
let tag_r = Rect::from_min_size(pos2(row.max.x - 16.0 - tag.size().x - 10.0, row.center().y - 8.0), vec2(tag.size().x + 10.0, 16.0));
|
||||
ui.painter().rect_filled(tag_r, CornerRadius::same(4), col.gamma_multiply(0.15));
|
||||
ui.painter().galley(tag_r.center() - tag.size() / 2.0, tag, col);
|
||||
ui.painter().circle_filled(pos2(row.min.x + indent + 3.0, row.center().y), 3.0, col);
|
||||
let name = if layers > 1 { format!("{name} ({layers} layers)") } else { name.to_string() };
|
||||
let name = widgets::elide(ui, &name, (tag_r.min.x - row.min.x - indent - 20.0).max(20.0));
|
||||
let strike = kind == ChangeKind::Removed;
|
||||
let galley = ui.painter().layout_no_wrap(name, regular(11.0), if strike { t.text_secondary } else { t.text });
|
||||
let at = pos2(row.min.x + indent + 12.0, row.center().y - galley.size().y / 2.0);
|
||||
if strike {
|
||||
let y = at.y + galley.size().y / 2.0;
|
||||
ui.painter().line_segment([pos2(at.x, y), pos2(at.x + galley.size().x, y)], Stroke::new(1.0, t.text_secondary));
|
||||
}
|
||||
ui.painter().galley(at, galley, t.text);
|
||||
resp
|
||||
}
|
||||
|
||||
/// The property changes under a [`change_row`] (`width 120 → 136`).
|
||||
pub(crate) fn property_lines<'a>(ui: &mut Ui, t: &Tokens, depth: usize, lines: impl Iterator<Item = &'a str>) {
|
||||
let indent = 16.0 + (depth.min(6) as f32) * 10.0;
|
||||
let mut any = false;
|
||||
for text in lines {
|
||||
any = true;
|
||||
let width = ui.available_width() - indent - 28.0;
|
||||
let g = ui.painter().layout(text.to_string(), regular(10.0), t.text_secondary, width.max(40.0));
|
||||
let (pr, _) = ui.allocate_exact_size(vec2(ui.available_width(), g.size().y + 4.0), Sense::hover());
|
||||
ui.painter().galley(pos2(pr.min.x + indent + 12.0, pr.min.y + 2.0), g, t.text_secondary);
|
||||
}
|
||||
if any {
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option<Action> {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), BAR_H + 1.0), Sense::hover());
|
||||
ui.painter().rect_filled(Rect::from_min_size(r.min, vec2(r.width(), 1.0)), CornerRadius::ZERO, t.border);
|
||||
|
|
|
|||
|
|
@ -203,6 +203,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
}
|
||||
crate::variables_panel::show(app, ctx);
|
||||
crate::dev_compare::show(app, ctx);
|
||||
crate::branch_ui::show(app, ctx);
|
||||
if app.ui.export_preview {
|
||||
export_dialog(app, ctx);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
pub mod a11y;
|
||||
pub mod assets_panel;
|
||||
pub mod bidi;
|
||||
pub mod branch_ui;
|
||||
pub mod canvas;
|
||||
pub mod colorpicker;
|
||||
pub mod comments;
|
||||
|
|
@ -58,6 +59,8 @@ pub mod widgets;
|
|||
#[cfg(test)]
|
||||
mod a11y_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_drag_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_tests;
|
||||
|
|
@ -266,6 +269,8 @@ pub struct UiState {
|
|||
pub left_collapsed: bool,
|
||||
pub comments: comments::CommentUi,
|
||||
pub versions: versions::VersionUi,
|
||||
/// The branch review dialog, while open.
|
||||
pub branch_review: Option<branch_ui::ReviewView>,
|
||||
/// Dev Mode: annotations on the canvas and the ready-for-dev list.
|
||||
pub dev: dev_mode::DevUi,
|
||||
|
||||
|
|
@ -343,6 +348,7 @@ impl Default for UiState {
|
|||
left_collapsed: false,
|
||||
comments: comments::CommentUi::default(),
|
||||
versions: versions::VersionUi::default(),
|
||||
branch_review: None,
|
||||
dev: dev_mode::DevUi::default(),
|
||||
|
||||
last_copy: None,
|
||||
|
|
@ -558,7 +564,9 @@ impl FigApp {
|
|||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
if self.session.ds().map(|d| d.path.is_some()).unwrap_or(false) {
|
||||
// A branch tab saves its main file.
|
||||
let tab = figcraft_engine::branches::file_tab(&self.session);
|
||||
if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) {
|
||||
if self.run("file.save", json!({})).is_some() {
|
||||
self.toast("Saved");
|
||||
}
|
||||
|
|
@ -745,6 +753,7 @@ impl FigApp {
|
|||
present::show_pane(self, &ctx);
|
||||
comments::keys(self, &ctx);
|
||||
dev_compare::keys(self, &ctx);
|
||||
branch_ui::keys(self, &ctx);
|
||||
live::dialog_keys(self, &ctx);
|
||||
shortcuts::handle(self, &ctx, typing);
|
||||
menus::popups(self, &ctx);
|
||||
|
|
@ -815,6 +824,7 @@ impl FigApp {
|
|||
"palette": self.ui.palette.is_some(),
|
||||
"variables": self.ui.variables.is_some(),
|
||||
"versionHistory": self.ui.versions.open,
|
||||
"branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})),
|
||||
"devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})),
|
||||
"devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused),
|
||||
"annotationFilter": self.ui.dev.categories.id(),
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
|||
"file.save" => app.save(),
|
||||
"file.saveAs" => app.save_as_dialog(),
|
||||
"file.saveVersion" => crate::versions::start_save(app),
|
||||
"branch.create" => crate::versions::start_branch(app),
|
||||
"file.export" | "file.exportSvg" => app.ui.export_preview = true,
|
||||
"edit.copyAsCss" => {
|
||||
let ids = app.session.selection();
|
||||
|
|
@ -142,7 +143,7 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
|||
app.ui.last_copy = Some(text);
|
||||
}
|
||||
"edit.paste" => paste(app, None),
|
||||
"file.close" if app.session.ds().map(|d| d.is_dirty()).unwrap_or(false) => {
|
||||
"file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => {
|
||||
let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or(""));
|
||||
app.toast(format!("Unsaved changes: save first ({save}), or close the tab with {}-click to discard", pretty_shortcut("Alt")));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
app.ui.home = false;
|
||||
}
|
||||
if let Some(i) = close {
|
||||
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty());
|
||||
// A branch tab's edits live on in its main file: closing it loses nothing.
|
||||
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
|
||||
if dirty {
|
||||
app.ui.find = None;
|
||||
app.toast(format!("Unsaved changes: save first, or close again with {} to discard", crate::toolbar::pretty_shortcut("Alt")));
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
//! The Version history panel, shown in the left sidebar in place of Pages and Layers: save a
|
||||
//! version (title and description), the history newest first under the current version, and per
|
||||
//! version Restore, Rename, Duplicate and Delete. Clicking a version shows it on the canvas
|
||||
//! (read-only, with a banner to restore it or go back); clicking Current version goes back.
|
||||
//! (read-only, with a banner to restore it or go back); clicking Current version goes back. Above
|
||||
//! the history, the file's branches: create one, open one, review and merge, archive.
|
||||
|
||||
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use figcraft_engine::branches::BranchState;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
|
|
@ -25,16 +27,22 @@ pub struct VersionUi {
|
|||
}
|
||||
|
||||
/// Title and description being written; `id` is the version being edited (`None` = a new one).
|
||||
/// A new branch's form has a name only.
|
||||
pub struct Form {
|
||||
pub id: Option<String>,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub branch: bool,
|
||||
focus: bool,
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn new(id: Option<String>, name: &str, description: &str) -> Self {
|
||||
Self { id, name: name.to_string(), description: description.to_string(), focus: true }
|
||||
Self { id, name: name.to_string(), description: description.to_string(), branch: false, focus: true }
|
||||
}
|
||||
|
||||
fn branch() -> Self {
|
||||
Self { branch: true, ..Self::new(None, "", "") }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -52,6 +60,21 @@ pub fn start_save(app: &mut FigApp) {
|
|||
app.ui.versions.form = Some(Form::new(None, "", ""));
|
||||
}
|
||||
|
||||
/// Open the panel with the new branch form (File ▸ Create branch…).
|
||||
pub fn start_branch(app: &mut FigApp) {
|
||||
open(app);
|
||||
app.ui.versions.confirm_delete = None;
|
||||
app.ui.versions.form = Some(Form::branch());
|
||||
}
|
||||
|
||||
/// One branch as listed.
|
||||
struct BranchRow {
|
||||
id: String,
|
||||
name: String,
|
||||
time: u64,
|
||||
state: BranchState,
|
||||
}
|
||||
|
||||
/// One version as listed.
|
||||
struct Row {
|
||||
id: String,
|
||||
|
|
@ -75,6 +98,10 @@ enum Action {
|
|||
AskDelete(String),
|
||||
Delete(String),
|
||||
CancelDelete,
|
||||
NewBranch,
|
||||
OpenBranch(String),
|
||||
ReviewBranch(String),
|
||||
ArchiveBranch(String),
|
||||
}
|
||||
|
||||
const DOT_X: f32 = 20.0;
|
||||
|
|
@ -93,14 +120,14 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
if icons::button(&mut bar, Icon::Close, 24.0, false, "Close version history").clicked() {
|
||||
action = Some(Action::Close);
|
||||
}
|
||||
let new_open = app.ui.versions.form.as_ref().is_some_and(|f| f.id.is_none());
|
||||
let new_open = app.ui.versions.form.as_ref().is_some_and(|f| f.id.is_none() && !f.branch);
|
||||
let tip = format!("Save version {}", crate::toolbar::pretty_shortcut("Alt+Cmd+S"));
|
||||
if icons::button(&mut bar, Icon::Plus, 24.0, new_open, &tip).clicked() {
|
||||
action = Some(Action::New);
|
||||
}
|
||||
ui.painter().line_segment([head.left_bottom(), head.right_bottom()], Stroke::new(1.0, t.border));
|
||||
|
||||
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.is_none()) {
|
||||
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.is_none() && !f.branch) {
|
||||
ui.add_space(12.0);
|
||||
if let Some(a) = form(ui, f, "Save", 16.0) {
|
||||
action = Some(a);
|
||||
|
|
@ -121,9 +148,39 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
})
|
||||
.unwrap_or_default();
|
||||
let previewing = app.session.ds().ok().and_then(|ds| ds.preview.as_ref().map(|p| p.id.clone()));
|
||||
// The branches of the file shown, or of the file a branch tab belongs to.
|
||||
let branches: Vec<BranchRow> = app
|
||||
.session
|
||||
.docs
|
||||
.get(figcraft_engine::branches::file_tab(&app.session))
|
||||
.map(|m| m.branches.iter().map(|b| BranchRow { id: b.id.clone(), name: b.name.clone(), time: b.time, state: b.state }).collect())
|
||||
.unwrap_or_default();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
// Rows touch so the timeline is continuous.
|
||||
if widgets::section_header(ui, "Branches", &[(Icon::Plus, "Create branch")], false).is_some() {
|
||||
action = Some(Action::NewBranch);
|
||||
}
|
||||
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.branch) {
|
||||
if let Some(a) = form(ui, f, "Create", 16.0) {
|
||||
action = Some(a);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for b in &branches {
|
||||
if let Some(a) = branch_row(ui, b) {
|
||||
action = Some(a);
|
||||
}
|
||||
}
|
||||
if branches.is_empty() {
|
||||
widgets::note(
|
||||
ui,
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge.",
|
||||
Tokens::get(ui.ctx()).text_secondary,
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
widgets::separator(ui);
|
||||
// Rows touch so the timeline is continuous.
|
||||
ui.add_space(4.0);
|
||||
if current_row(ui, !rows.is_empty(), previewing.is_none()) {
|
||||
action = Some(Action::ExitPreview);
|
||||
|
|
@ -202,7 +259,20 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
|
|||
}
|
||||
Action::New => {
|
||||
st.confirm_delete = None;
|
||||
st.form = if st.form.as_ref().is_some_and(|f| f.id.is_none()) { None } else { Some(Form::new(None, "", "")) };
|
||||
st.form = if st.form.as_ref().is_some_and(|f| f.id.is_none() && !f.branch) { None } else { Some(Form::new(None, "", "")) };
|
||||
}
|
||||
Action::NewBranch => {
|
||||
st.confirm_delete = None;
|
||||
st.form = if st.form.as_ref().is_some_and(|f| f.branch) { None } else { Some(Form::branch()) };
|
||||
}
|
||||
Action::OpenBranch(id) => {
|
||||
app.run("branch.open", json!({"id": id}));
|
||||
}
|
||||
Action::ReviewBranch(id) => crate::branch_ui::open(app, Some(&id)),
|
||||
Action::ArchiveBranch(id) => {
|
||||
if app.run("branch.archive", json!({"id": id})).is_some() {
|
||||
app.toast("Branch archived");
|
||||
}
|
||||
}
|
||||
Action::CancelForm => st.form = None,
|
||||
Action::CancelDelete => st.confirm_delete = None,
|
||||
|
|
@ -217,6 +287,7 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
|
|||
Action::Submit => {
|
||||
let Some(f) = st.form.take() else { return };
|
||||
let (cmd, params) = match &f.id {
|
||||
_ if f.branch => ("branch.create", json!({"name": f.name.trim()})),
|
||||
Some(id) => ("version.rename", json!({"id": id, "name": f.name.trim(), "description": f.description.trim()})),
|
||||
None => ("file.saveVersion", json!({"name": f.name.trim(), "description": f.description.trim()})),
|
||||
};
|
||||
|
|
@ -253,23 +324,29 @@ fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option<Action>
|
|||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width() - 16.0);
|
||||
let w = ui.available_width() - 16.0;
|
||||
let hint = if f.branch { "Branch name" } else { "Title" };
|
||||
let title = field(ui, &mut |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text("Title").font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w))
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(hint).font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w))
|
||||
});
|
||||
crate::a11y::text(&title, hint, &f.name);
|
||||
if std::mem::take(&mut f.focus) {
|
||||
title.request_focus();
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let desc = field(ui, &mut |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut f.description)
|
||||
.hint_text("Describe what changed")
|
||||
.font(regular(11.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_rows(3)
|
||||
.desired_width(w),
|
||||
)
|
||||
});
|
||||
let desc = if f.branch {
|
||||
title.clone()
|
||||
} else {
|
||||
field(ui, &mut |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut f.description)
|
||||
.hint_text("Describe what changed")
|
||||
.font(regular(11.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_rows(3)
|
||||
.desired_width(w),
|
||||
)
|
||||
})
|
||||
};
|
||||
let (enter, cmd_enter, esc) = ui.input(|i| {
|
||||
(i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Enter) && i.modifiers.command, i.key_pressed(egui::Key::Escape))
|
||||
});
|
||||
|
|
@ -386,6 +463,58 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
|
|||
out
|
||||
}
|
||||
|
||||
/// A branch: its name and state; a click opens it, its More menu reviews or archives it.
|
||||
fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
|
||||
let resp = resp.tip("Open branch");
|
||||
crate::a11y::list_item(&resp, &format!("Branch {}", b.name), false);
|
||||
let hovered = ui.rect_contains_pointer(r);
|
||||
if hovered {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
|
||||
}
|
||||
let active = b.state == BranchState::Active;
|
||||
let state = match b.state {
|
||||
BranchState::Active => "Active",
|
||||
BranchState::Merged => "Merged",
|
||||
BranchState::Archived => "Archived",
|
||||
};
|
||||
let clip = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y), pos2(r.max.x - 40.0, r.max.y));
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
p.text(pos2(clip.min.x, r.min.y + 15.0), Align2::LEFT_CENTER, &b.name, semibold(11.0), if active { t.text } else { t.text_secondary });
|
||||
let sub = format!("{state} · {}", crate::comments::ago_long(b.time));
|
||||
p.text(pos2(clip.min.x, r.min.y + 31.0), Align2::LEFT_CENTER, sub, regular(11.0), t.text_secondary);
|
||||
let mut out = None;
|
||||
let br = Rect::from_center_size(pos2(r.max.x - 22.0, r.min.y + 15.0), vec2(24.0, 24.0));
|
||||
let more = ui.interact(br, Id::new(("branch_more", &b.id)), Sense::click());
|
||||
let menu_open = egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(&more));
|
||||
if hovered || menu_open {
|
||||
if more.hovered() || menu_open {
|
||||
ui.painter().rect_filled(br, CornerRadius::same(5), t.pressed);
|
||||
}
|
||||
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
|
||||
}
|
||||
let more = more.tip("More");
|
||||
let mut menu = |ui: &mut Ui| {
|
||||
let mut items = vec![("Open", Action::OpenBranch(b.id.clone())), ("Review and merge", Action::ReviewBranch(b.id.clone()))];
|
||||
if active {
|
||||
items.push(("Archive", Action::ArchiveBranch(b.id.clone())));
|
||||
}
|
||||
for (label, a) in items {
|
||||
if crate::right::menu_item(ui, label, "") {
|
||||
out = Some(a);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::right::menu_popup(&more).show(&mut menu);
|
||||
crate::right::context_popup(&resp).show(&mut menu);
|
||||
if resp.clicked() && out.is_none() {
|
||||
out = Some(Action::OpenBranch(b.id.clone()));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// While a version is shown: a banner over the canvas naming it, with Restore and Back.
|
||||
pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
||||
let Some((id, name, time)) = app.session.ds().ok().and_then(|ds| ds.preview.as_ref().map(|p| (p.id.clone(), p.name.clone(), p.time))) else {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~12% (estimated):** comments and version
|
||||
history work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
server ordering, client reconciliation, convergence tests), and live sessions (preview) let
|
||||
several apps co-edit a file hosted by one desktop app or kept on a self-hosted `figcraft-server`
|
||||
with share links to edit or view, joined from the desktop app or the browser, with others'
|
||||
cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md), [`server.md`](server.md)). Remaining: 55–110 h
|
||||
(multiplayer 45–90 h, shared libraries 10–20 h), plus branching 8–15 h.
|
||||
(multiplayer 45–90 h, shared libraries 10–20 h); branching landed (its tail: 1–3 h).
|
||||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
|
|
@ -18,7 +18,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr
|
|||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. No accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file | — |
|
||||
| Branching and merging | yes (paid plans) | no | 8–15 |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived), update from main, archive. Left: a branch's own version history, list properties (fills, text runs) merged item by item, branches in live sessions | 1–3 |
|
||||
| Team / organization libraries | yes | local library files only ([components-parity](components-parity.md)) | 10–20 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
|
|
@ -26,6 +26,7 @@ cursors, selections and text carets shown live ([`collab-protocol.md`](collab-pr
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Collaboration wording after the standalone server: `figcraft-server` keeps many files, persisted as a snapshot plus a log of what changed (compacted, checksummed, damaged files skipped and reported), restarts lose nothing confirmed and apps resume where they were; the app publishes its file to it; share links per file let people edit or only view (the server refuses a viewer's edits), are revocable and stored as hashes; TLS comes from a reverse proxy (the desktop app has none of its own yet); percentages and hours unchanged until the next re-measure |
|
||||
| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged |
|
||||
| 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure |
|
||||
|
|
|
|||
21
docs/gaps.md
21
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -30,7 +30,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 14 | Variables: extended collections' tail | Feature | 1–2 | [components-parity](components-parity.md) |
|
||||
| 15 | Prototyping: media triggers and actions, gamepad | Feature | 3–6 | [prototyping-parity](prototyping-parity.md) |
|
||||
| 16 | Dev Mode: Code Connect, editor integrations | Feature | 4–8 | [dev-mode-parity](dev-mode-parity.md) |
|
||||
| 17 | Branching and merging | Feature | 8–15 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 17 | Branching: per-branch version history, finer merges of list properties (branches landed 2026-10-11) | Feature | 1–3 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 18 | Display P3 on screen; one window only | Hardware | 6–12 | [hardware-parity](hardware-parity.md) |
|
||||
| 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) |
|
||||
| 20 | Mobile apps (view, mirror, comment) | Platforms | 20–40 | [hardware-parity](hardware-parity.md) |
|
||||
|
|
@ -201,8 +201,20 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **Estimate:** 4–8 h.
|
||||
|
||||
### 17. Branching and merging
|
||||
- **Missing:** branches of a file, review and merge.
|
||||
- **Estimate:** 8–15 h (versions and Compare changes are a base).
|
||||
- **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches
|
||||
list in version history) copies the file into a branch tab that remembers the file as it was (its
|
||||
base) and makes its ids in its own id space; edits on the branch are saved with the main file.
|
||||
Review and merge (the banner on a branch tab) shows the main file and the branch side by side or
|
||||
as an overlay, what changed on each side since the base (layers with their property changes as
|
||||
Inspect shows them; styles, variables, comments) and the conflicts: the same property changed on
|
||||
both sides, or a layer deleted on one side while the other edited it or what's inside it. Each
|
||||
conflict is resolved for the branch or main; nothing conflicting merges until it is. Merging is
|
||||
one undo step on the main file and archives the branch; Update from main brings main's changes
|
||||
into the branch the same way; branches can be archived without merging. Three-way merge over the
|
||||
collaboration change sets (`figcraft_collab::merge`), fuzz-tested with random real commands.
|
||||
- **Missing:** a branch's own version history; a list property (fills, text runs…) changed on both
|
||||
sides conflicts as a whole; branches of a file in a live session are refused.
|
||||
- **Estimate:** 1–3 h.
|
||||
|
||||
### 18. Display P3 on screen; one window only
|
||||
- **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on
|
||||
|
|
@ -242,6 +254,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
|
|
|
|||
34
docs/mcp.md
34
docs/mcp.md
|
|
@ -535,6 +535,40 @@ closing or saving a version ends the preview first, and `version.list` marks the
|
|||
has unsaved changes since its last version; the app runs it every 30 minutes for each open document,
|
||||
and a file keeps the newest 20 autosaves (user versions are never dropped for them). `file.versionHistory` returns the same list and opens the panel in the app.
|
||||
|
||||
### Branches
|
||||
|
||||
A branch is a copy of the file to try changes in, reviewed against the main file and merged back.
|
||||
Branches are kept in the main `.figcraft` file (saving either tab saves the main file with its
|
||||
branches). Driven with `run_command`:
|
||||
|
||||
- `branch.create {name?}` copies the file into a new branch tab and returns `{id, name, index}`.
|
||||
The branch remembers the file as it is now (its base) and makes new ids in its own id space, so
|
||||
layers, styles and comments made on both sides never clash.
|
||||
- `branch.list` lists the branches of the file shown (or of the file a branch tab belongs to):
|
||||
`[{id, name, time, state: active|merged|archived, index?}]` (`index`: its open tab).
|
||||
- `branch.open {id}` opens a branch in a tab (or switches to it). Edit it like any document.
|
||||
- `branch.review {id?}` (default: the branch tab shown) returns what changed on each side since the
|
||||
base and the conflicts: `{branch: {layers, document, truncated}, main: {…}, conflicts: [{key, id?,
|
||||
name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property
|
||||
changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables,
|
||||
comments and file settings. A conflict is the same property changed on both sides to different
|
||||
values (key `<layer id>/<property>`, e.g. `4/fills`; a layer's parent and position are one
|
||||
property, `parent`), a layer deleted on one side while the other edited it or what's inside it
|
||||
(`<layer id>/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides
|
||||
(`style/<id>`, `variable/<id>`, `comment/<id>`, `doc/<property>`…).
|
||||
- `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step,
|
||||
archives it (`state: merged`), closes its tab and shows the main file. `resolutions` maps every
|
||||
conflict key to `"branch"` or `"main"`; the merge is refused while a conflict has none, so
|
||||
neither side's work is dropped without a choice.
|
||||
- `branch.updateFromMain {id?, resolutions?}` brings the main file's changes since the base into the
|
||||
branch the same way (the new base is the main file as it is now). It is not undoable: the branch
|
||||
tab's undo history starts over.
|
||||
- `branch.archive {id?}` sets a branch aside without merging (it stays in the file and can still be
|
||||
opened, but not merged).
|
||||
|
||||
Versions are saved on the main file (`file.saveVersion` is refused on a branch tab), and branches
|
||||
aren't available in a live session.
|
||||
|
||||
### Collaboration changes
|
||||
|
||||
`collab.diff {against? (undo: the last undoable action, the default | saved: since the last
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
> **Generated:** 2026-10-11 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (472 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (479 commands in the engine).
|
||||
|
||||
**203 / 203 menu items (100.0%)**
|
||||
**204 / 204 menu items (100.0%)**
|
||||
|
||||
Every catalog item has a command.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -71,6 +71,7 @@ blockers.
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%; ready ~47%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -15,13 +15,13 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (203 / 203 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) |
|
||||
| Menu/command breadth | **100%** (204 / 204 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-11) |
|
||||
| Feature breadth (does each Figma Design feature exist, weighted) | **~79%** (core editor areas 1–16: ~90%) | estimated from the area table below |
|
||||
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
|
||||
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
|
||||
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) |
|
||||
| Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~380–725 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~378–722 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
|
|
@ -109,23 +109,23 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| 13 | Prototyping | 6 | 88% | 70% | Video triggers and media actions, gamepad triggers, real prototype QA ([prototyping-parity](prototyping-parity.md)) | 5–9 |
|
||||
| 14 | Import/export | 6 | 65% | 35% | **`.fig` import** (the blocking gap), `.fig` export, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 30–60 |
|
||||
| 15 | Dev Mode / inspect | 2 | 80% | 62% | Code Connect, plugins in Dev Mode, editor integrations ([dev-mode-parity](dev-mode-parity.md)) | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 80% | 58% | Comments are local only; branching | 4–8 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 85% | 60% | Comments are local only; a branch's own version history (branches with review and merge landed) | 2–5 |
|
||||
| 17 | Multiplayer & sharing | 8 | 15% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view. Left: accounts and teams, cursor chat, TLS in the desktop app ([collaboration-parity](collaboration-parity.md)) | 45–90 |
|
||||
| 18 | Plugins & widgets | 4 | 35% | 20% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK, 2 examples). Left: plugin UI at run time, settings storage, distribution, widgets; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 25–55 |
|
||||
| 19 | Team libraries & organization | 2 | 40% | 25% | Local library files exist; no shared/cloud library, no library analytics | 10–20 |
|
||||
| 20 | Figma Draw extras | 2 | 45% | 30% | Have: variable width, lasso, shape builder, noise/texture. Missing: brushes (stretch/scatter), pressure, text on a path, pattern fills, multi-edit | 10–18 |
|
||||
| 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **208–414** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 88–171 |
|
||||
| | **Weighted (÷113)** | 113 | **~79%** | **~57%** | | **206–411** |
|
||||
| | Core editor only (1–16, ÷94) | 94 | ~90% | ~67% | | 86–168 |
|
||||
|
||||
## By dimension (ready for real work)
|
||||
|
||||
| Dimension | Weight | Ready | Hours | Evidence | Doc |
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 57% | 68–129 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
|
||||
| Features (depth) | 35 | 57% | 66–126 (areas 1–13, 15, 16, 20) | The table above; core editor ~67% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (cursors, modifiers while dragging, hover states, popovers) not yet compared; accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 40% | 30–60 | No `.fig` read or write; REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 12% | 55–110 | Local comments and version history; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Collaboration | 10 | 13% | 55–110 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 50% | 15–30 | Trackpad pinch and HiDPI yes; no pen pressure, no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
|
||||
|
|
@ -133,7 +133,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 20% | 25–55 | Own WASM plugins with a Rust SDK and two examples (#101); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~47%** | **~380–725** | | |
|
||||
| **Ready for real work** | 100 | **~47%** | **~378–722** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
|
|
@ -577,6 +577,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h |
|
||||
| 2026-10-11 | minor | Animated GIF fills and TIFF images (gap 12, without video and HEIC): GIF fills keep the original file, show the first frame on the canvas and in exports and play in the presenter and the preview (frames decoded on first play, at most 500 frames and 128 MB; always looping; the window wakes only for a GIF's next frame); TIFF images open, import, place and paste. Paint breadth 85% → 87%, 5–9 h → 4–7 h; gap 12 6–12 h → 5–10 h (video playback and HEIC left, both needing non-Rust decoders); feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | The last three catalogued menu items got commands (gap 21): File ▸ Open recent (recent files kept in the preferences, at most 20, a file that's gone greyed in the menu and dropped when picked, Clear recent), View ▸ Outlines ▸ Show outlines for selection and View ▸ Mask outlines: menu breadth 98.5% → 100% (203/203); Canvas & navigation breadth 95% → 97%, 3–6 h → 2–5 h; feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding |
|
||||
| 2026-10-11 | minor | Extended collections, variables import/export and gradient stop and variant property bindings landed (gap 14): Styles & variables breadth 85% → 92%, ready 70% → 72%, 4–8 h → 1–2 h; feature breadth unchanged (~79%); ready for real work unchanged (~47%); full parity ~385–730 h → ~380–725 h |
|
||||
|
|
|
|||
Loading…
Reference in New Issue