M6: view a version on the canvas before restoring it (#36)

Clicking a version in Version history shows it in place of the document,
read-only, with a banner to restore it or go back to the current version
(clicking Current version or closing the panel goes back too). Edits,
undo and gestures are refused while a version is shown; saving, closing
and restoring end the preview first, so the document and its unsaved
state are never touched by it.

New commands version.preview and version.exitPreview; version.list marks
the shown version.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 05:34:06 +07:00 committed by GitHub
parent 360b6df206
commit ce317cdf42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 326 additions and 15 deletions

View File

@ -159,7 +159,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
| Comments, versions, misc | 3 | 35% | version preview on the canvas, autosave checkpoints, comment polish, plugins (WASM) | 12–20 |
| Comments, versions, misc | 3 | 40% | autosave checkpoints, comment polish, plugins (WASM) | 10–17 |
Weighted feature depth ≈ **58%** (self-graded; the table's arithmetic gives ~59%).

View File

@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"version.list",
"List versions",
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds)}]",
"{} the version history, oldest first: [{id, index, name, description, time (Unix seconds), previewing?}]",
false,
|s, _| Ok(version_list(s.ds()?))
),
@ -125,9 +125,30 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("version.delete", "Delete version", "{id | index}", false, |s, p| {
let ds = s.ds_mut()?;
let i = version_index("version.delete", ds, p)?;
ds.versions_mut().remove(i);
let v = ds.versions_mut().remove(i);
if ds.preview.as_ref().is_some_and(|pv| pv.id == v.id) {
ds.end_preview();
}
ok()
}),
spec!(
"version.preview",
"View version",
"{id | index} show the version on the canvas in place of the document (read-only; `version.restore` keeps it, `version.exitPreview` goes back)",
false,
|s, p| {
let ds = s.ds_mut()?;
let i = version_index("version.preview", ds, p)?;
let v = ds.versions.get(i).ok_or_else(|| bad("version.preview", "no such version"))?;
let (id, name, time, out) = (v.id.clone(), v.name.clone(), v.time, version_json(i, v));
let doc = read_version(v)?;
ds.start_preview(&id, &name, time, doc);
Ok(out)
}
),
spec!("version.exitPreview", "Back to current version", "{} end a version preview", false, |s, _| {
Ok(json!({"ended": s.ds_mut()?.end_preview()}))
}),
spec!(
"version.duplicate",
"Duplicate version",
@ -173,7 +194,20 @@ fn version_json(index: usize, v: &Version) -> Value {
}
fn version_list(ds: &DocState) -> Value {
Value::Array(ds.versions.iter().enumerate().map(|(i, v)| version_json(i, v)).collect())
let previewing = ds.preview.as_ref().map(|p| p.id.as_str());
Value::Array(
ds.versions
.iter()
.enumerate()
.map(|(i, v)| {
let mut j = version_json(i, v);
if previewing == Some(v.id.as_str()) {
j["previewing"] = json!(true);
}
j
})
.collect(),
)
}
/// The version named by `id`, else by `index` (validated: never negative, never past the end).

View File

@ -226,8 +226,29 @@ pub struct DocState {
pub versions: Vec<figcraft_format::Version>,
/// The version history changed since the last save.
versions_changed: bool,
/// A version shown in place of the document (read-only).
pub preview: Option<VersionPreview>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
/// preview ends, which puts the document back as it was.
#[derive(Clone, Debug)]
pub struct VersionPreview {
pub id: String,
pub name: String,
/// When the version was saved (Unix seconds).
pub time: u64,
doc: Arc<Document>,
selection: Vec<NodeId>,
page: NodeId,
}
/// Why edits are refused while a version is shown.
pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore it or go back to the current version to edit";
/// Commands that first end a version preview: they act on the document itself.
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl DocState {
@ -253,6 +274,7 @@ impl DocState {
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
versions: Vec::new(),
versions_changed: false,
preview: None,
};
st.zoom_to_fit();
st
@ -271,7 +293,44 @@ impl DocState {
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved_doc) || self.versions_changed
let doc = self.preview.as_ref().map_or(&self.doc, |p| &p.doc);
!Arc::ptr_eq(doc, &self.saved_doc) || self.versions_changed
}
/// Show `version` in place of the document (or switch to it from another preview).
pub fn start_preview(&mut self, id: &str, name: &str, time: u64, doc: Arc<Document>) {
self.end_preview();
self.preview = Some(VersionPreview {
id: id.to_string(),
name: name.to_string(),
time,
doc: std::mem::replace(&mut self.doc, doc),
selection: std::mem::take(&mut self.selection),
page: self.page,
});
self.page = Some(self.page)
.filter(|pg| self.doc.pages.contains(pg))
.or_else(|| self.doc.current_page.filter(|pg| self.doc.pages.contains(pg)))
.or_else(|| self.doc.first_page())
.unwrap_or(self.page);
self.text_edit = None;
self.vector_edit = None;
self.interaction = None;
self.revision += 1;
}
/// End a version preview, putting the document, selection and page back. False when none.
pub fn end_preview(&mut self) -> bool {
let Some(p) = self.preview.take() else { return false };
self.doc = p.doc;
self.selection = p.selection;
self.page = p.page;
self.text_edit = None;
self.vector_edit = None;
self.interaction = None;
self.clean_selection();
self.revision += 1;
true
}
pub fn mark_saved(&mut self) {
@ -462,8 +521,15 @@ 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()))?;
if ENDS_PREVIEW.contains(&id)
&& let Ok(ds) = self.ds_mut()
{
ds.end_preview();
}
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
let result = guard::run(id, || (spec.run)(self, params)).and_then(|v| self.check_instance_structure(id, &before).map(|()| v));
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
@ -490,6 +556,16 @@ impl Session {
result
}
/// A version shown in place of the document is read-only: refuse an edit (it is rolled back).
fn check_preview(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
if ds.preview.is_none() || Arc::ptr_eq(&ds.doc, bdoc) || id == "version.preview" {
return Ok(());
}
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// Instances can't gain, lose or reorder layers, and library copies are read-only: refuse such
/// an edit (it is rolled back).
fn check_instance_structure(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@ -535,6 +611,9 @@ impl Session {
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
let ds = self.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
}
if ds.interaction.is_none() {
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() });
}
@ -585,6 +664,9 @@ impl Session {
pub fn undo(&mut self) -> Result<bool> {
let ds = self.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled("edit.undo".into(), PREVIEW_READ_ONLY.into()));
}
ds.interaction = None;
ds.text_edit = None;
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
@ -602,6 +684,9 @@ impl Session {
pub fn redo(&mut self) -> Result<bool> {
let ds = self.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled("edit.redo".into(), PREVIEW_READ_ONLY.into()));
}
ds.interaction = None;
ds.text_edit = None;
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
@ -787,4 +872,6 @@ mod variable_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod version_preview_tests;
#[cfg(test)]
mod version_tests;

View File

@ -0,0 +1,99 @@
//! Viewing a version on the canvas: read-only, restore from it, back to the current version, and
//! the document (and its unsaved state) untouched by the preview.
use serde_json::json;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
use crate::{EngineError, Session};
fn rect(s: &mut Session, x: f64) -> figcraft_doc::NodeId {
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"]).unwrap()
}
fn count(s: &Session) -> usize {
s.doc().unwrap().node_count()
}
/// A session with versions "One" (one box) and "Two" (two), then a third box and a selection.
fn setup() -> (Session, figcraft_doc::NodeId) {
let mut s = Session::new();
rect(&mut s, 0.0);
s.execute("file.saveVersion", &json!({"name": "One"})).unwrap();
rect(&mut s, 20.0);
s.execute("file.saveVersion", &json!({"name": "Two"})).unwrap();
let r = rect(&mut s, 40.0);
s.execute("select.set", &json!({"ids": [r]})).unwrap();
(s, r)
}
#[test]
fn preview_shows_the_version_read_only_and_goes_back() {
let (mut s, r) = setup();
let three = count(&s);
let undo_depth = s.ds().unwrap().history.undo.len();
let v = s.execute("version.preview", &json!({"index": 0})).unwrap();
assert_eq!(v["name"], "One");
assert_eq!(count(&s), three - 2);
assert!(s.selection().is_empty());
let l = s.execute("version.list", &json!({})).unwrap();
assert_eq!(l[0]["previewing"], true);
assert!(l[1].get("previewing").is_none());
// Edits are refused and leave it as it was; so do undo and gestures.
let err = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap_err();
assert!(matches!(err, EngineError::Disabled(..)), "{err:?}");
assert_eq!(count(&s), three - 2);
assert!(s.execute("edit.undo", &json!({})).is_err());
assert_eq!(s.ds().unwrap().history.undo.len(), undo_depth, "history untouched");
assert!(s.begin_interaction("Move").is_err());
let _ = s.pointer(&PointerEvent::at(PointerKind::Down, 5.0, 5.0, 1.0));
let _ = s.pointer(&PointerEvent::at(PointerKind::Move, 50.0, 50.0, 1.0));
let _ = s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0));
assert_eq!(count(&s), three - 2);
// Switching to another version, then back to the current one.
s.execute("version.preview", &json!({"index": 1})).unwrap();
assert_eq!(count(&s), three - 1);
assert_eq!(s.execute("version.exitPreview", &json!({})).unwrap()["ended"], true);
assert_eq!(count(&s), three);
assert_eq!(s.selection(), vec![r], "selection comes back");
assert_eq!(s.execute("version.exitPreview", &json!({})).unwrap()["ended"], false);
// Editing works again.
rect(&mut s, 60.0);
assert_eq!(count(&s), three + 1);
}
#[test]
fn restore_from_a_preview_is_one_undo_step() {
let (mut s, _) = setup();
let three = count(&s);
s.execute("version.preview", &json!({"index": 0})).unwrap();
s.execute("version.restore", &json!({"index": 0})).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert_eq!(count(&s), three - 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(count(&s), three, "undo goes back to the document, not the preview");
}
#[test]
fn preview_keeps_the_unsaved_state_and_saving_saves_the_document() {
let dir = std::env::temp_dir().join(format!("figcraft-vpreview-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("preview.figcraft").to_string_lossy().to_string();
let (mut s, _) = setup();
let three = count(&s);
s.execute("file.save", &json!({"path": path})).unwrap();
s.execute("version.preview", &json!({"index": 0})).unwrap();
assert!(!s.ds().unwrap().is_dirty(), "looking at a version isn't a change");
// Saving ends the preview and saves the document itself.
s.execute("file.save", &json!({})).unwrap();
assert!(s.ds().unwrap().preview.is_none());
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
assert_eq!(count(&t), three);
// Deleting the version being viewed ends the preview.
s.execute("version.preview", &json!({"index": 1})).unwrap();
s.execute("version.delete", &json!({"index": 1})).unwrap();
assert!(s.ds().unwrap().preview.is_none());
assert_eq!(count(&s), three);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -172,6 +172,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::handles::draw(app, &painter);
crate::comments::draw(app, &painter);
crate::comments::popovers(app, &ctx);
crate::versions::preview_banner(app, &ctx, rect);
// Cursor.
if resp.hovered() {
let c = if app.canvas.space_pan || app.canvas.middle_pan.is_some() {

View File

@ -114,6 +114,7 @@ pub enum Icon {
Reset,
Detach,
Variable,
Clock,
Eraser,
Swap,
Link,
@ -645,6 +646,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
p.arc(8.0, 8.0, 5.0, -2.2, 2.6);
p.poly(&[(2.8, 2.8), (3.3, 5.8), (6.3, 5.3)], false);
}
Clock => {
// A dial with hands at ten past two-ish (version history).
p.circle(8.0, 8.0, 6.0);
p.line(8.0, 8.0, 8.0, 4.5);
p.line(8.0, 8.0, 10.5, 9.5);
}
Eraser => {
// A tilted block with a band near its tip, over a short trace line.
p.poly(&[(3.0, 10.0), (9.5, 3.5), (13.0, 7.0), (6.5, 13.5)], true);

View File

@ -1,6 +1,7 @@
//! 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.
//! 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.
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
@ -58,6 +59,8 @@ struct Row {
enum Action {
Close,
Preview(String),
ExitPreview,
New,
Submit,
CancelForm,
@ -112,11 +115,14 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
.collect()
})
.unwrap_or_default();
let previewing = app.session.ds().ok().and_then(|ds| ds.preview.as_ref().map(|p| p.id.clone()));
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
// Rows touch so the timeline is continuous.
ui.spacing_mut().item_spacing.y = 0.0;
ui.add_space(4.0);
current_row(ui, !rows.is_empty());
if current_row(ui, !rows.is_empty(), previewing.is_none()) {
action = Some(Action::ExitPreview);
}
let last = rows.len().saturating_sub(1);
for (i, row) in rows.iter().enumerate() {
let a = if app.ui.versions.confirm_delete.as_deref() == Some(row.id.as_str()) {
@ -124,7 +130,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
} else if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.as_deref() == Some(row.id.as_str())) {
inline_row(ui, i == last, |ui| form(ui, f, "Save", TEXT_X))
} else {
version_row(ui, row, i == last)
version_row(ui, row, i == last, previewing.as_deref() == Some(row.id.as_str()))
};
if a.is_some() {
action = a;
@ -155,7 +161,16 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
let st = &mut app.ui.versions;
let name_of = |id: &str| rows.iter().find(|r| r.id == id).map(|r| r.name.clone()).unwrap_or_default();
match a {
Action::Close => *st = VersionUi::default(),
Action::Close => {
*st = VersionUi::default();
app.run("version.exitPreview", json!({}));
}
Action::Preview(id) => {
app.run("version.preview", json!({"id": id}));
}
Action::ExitPreview => {
app.run("version.exitPreview", json!({}));
}
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, "", "")) };
@ -266,14 +281,21 @@ fn timeline(ui: &Ui, r: Rect, dot_y: f32, top: bool, bottom: bool, current: bool
}
}
fn current_row(ui: &mut Ui, more: bool) {
/// The current version's row (`active` when it's what the canvas shows); true when clicked.
fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::hover());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click());
if active {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row);
} else if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
timeline(ui, r, r.center().y, false, more, true);
ui.painter().text(pos2(r.min.x + TEXT_X, r.center().y), Align2::LEFT_CENTER, "Current version", semibold(11.0), t.text);
!active && resp.on_hover_text("Back to the current version").clicked()
}
fn version_row(ui: &mut Ui, row: &Row, last: bool) -> Option<Action> {
fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<Action> {
let t = Tokens::get(ui.ctx());
let w = ui.available_width();
let text_w = (w - TEXT_X - 36.0).max(40.0);
@ -286,7 +308,9 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool) -> Option<Action> {
let desc_h = desc.as_ref().map(|g| g.size().y + 4.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, 50.0 + desc_h), Sense::click());
let hovered = ui.rect_contains_pointer(r);
if hovered {
if previewing {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.selected_row);
} else if hovered {
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
}
let name_y = r.min.y + 17.0;
@ -327,10 +351,65 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool) -> Option<Action> {
egui::Popup::context_menu(&resp).show(&mut menu);
if resp.double_clicked() {
out = Some(Action::Edit(row.id.clone()));
} else if resp.clicked() && !previewing && out.is_none() {
out = Some(Action::Preview(row.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 {
return;
};
let t = Tokens::get(ctx);
let mut action = None;
egui::Area::new(Id::new("version_preview_banner"))
.order(egui::Order::Foreground)
.pivot(Align2::CENTER_TOP)
.fixed_pos(pos2(canvas.center().x, canvas.min.y + 12.0))
.show(ctx, |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.border))
.corner_radius(CornerRadius::same(8))
.shadow(egui::epaint::Shadow { offset: [0, 2], blur: 12, spread: 0, color: t.shadow })
.inner_margin(egui::Margin { left: 14, right: 8, top: 8, bottom: 8 })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 8.0;
icons::paint(
ui.painter(),
Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)),
Icon::Clock,
t.icon_secondary,
);
ui.add_space(20.0);
ui.label(egui::RichText::new(format!("Viewing “{name}”")).font(semibold(11.0)).color(t.text));
ui.label(egui::RichText::new(crate::comments::ago_long(time)).font(regular(11.0)).color(t.text_secondary));
ui.add_space(4.0);
if widgets::button(ui, "Back to current", false, true).clicked() {
action = Some(false);
}
if widgets::button(ui, "Restore this version", true, true).clicked() {
action = Some(true);
}
});
});
});
match action {
Some(true) => {
if app.run("version.restore", json!({"id": id})).is_some() {
app.toast(format!("Restored “{name}”"));
}
}
Some(false) => {
app.run("version.exitPreview", json!({}));
}
None => {}
}
}
/// A row holding a form (rename, delete confirmation), with the timeline running past it.
fn inline_row(ui: &mut Ui, last: bool, add: impl FnOnce(&mut Ui) -> Option<Action>) -> Option<Action> {
let top = ui.cursor().min.y;

View File

@ -177,7 +177,11 @@ a new, renamed or deleted version marks the file as changed). Driven with `run_c
versions are named "Version N"), `version.list` (oldest first; `time` is Unix seconds),
`version.restore {id | index}` (replaces the document with the version as one undo step; the file
name stays), `version.rename {id | index, name, description?}`, `version.delete {id | index}` and
`version.duplicate {id | index}` (opens the version as a new document tab). Indexes are validated
`version.duplicate {id | index}` (opens the version as a new document tab).
`version.preview {id | index}` shows a version in place of the document, read-only: edits, undo and
gestures are refused until `version.restore` keeps it or `version.exitPreview` goes back. Saving,
closing or saving a version ends the preview first, and `version.list` marks the shown version
`previewing`. Indexes are validated
(a negative or out-of-range index is an error). A file keeps at most 100 versions; there are no
automatic versions. `file.versionHistory` returns the same list and opens the panel in the app.