diff --git a/ROADMAP.md b/ROADMAP.md index 0e7811f..43b5582 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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%). diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3661e6e..4624060 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { 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 { 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). diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c122a3f..50ba56c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -226,8 +226,29 @@ pub struct DocState { pub versions: Vec, /// The version history changed since the last save. versions_changed: bool, + /// A version shown in place of the document (read-only). + pub preview: Option, } +/// 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, + selection: Vec, + 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) { + 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 { 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, Vec, 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, Vec, 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 { 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 { 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; diff --git a/crates/engine/src/version_preview_tests.rs b/crates/engine/src/version_preview_tests.rs new file mode 100644 index 0000000..e910006 --- /dev/null +++ b/crates/engine/src/version_preview_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b31f5d0..5d4f964 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7dd9ea8..2bdeb2e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -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); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 5db22c9..c0f5d1f 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -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 { +fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option { 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 { 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 { 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) -> Option { let top = ui.cursor().min.y; diff --git a/docs/mcp.md b/docs/mcp.md index 151880e..bbaf1be 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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.