History: the New Document button makes a document from the selected state

The History panel gets a New Document from State button at its bottom,
which runs the new edit.newDocumentFromState command. The command makes
a new document holding the art of a History panel row ({index?}, the
current state when omitted) and makes it the active document. An index
that names no state is an error and changes nothing.

The new document takes the next Untitled-N name, is marked unsaved, has
nothing selected, no steps to undo or redo and no modified date, and
leaves symbol, opacity-mask and pattern editing; the art drawn in a
pattern's tile is stored in the pattern, as Done stores it. Its links point at the files the
source finds, including files found in the source's folder away from the
links' paths. Those files are looked for only within the folders the
asking caller may read; for a caller without limits, within the folders
the caller that opened the source may read. The source
document, its steps and its saved state stay as they are; typing or a
drag in progress is first kept as a step, as Undo keeps it. While an IME
composes, the command waits, as Undo does.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-08 12:20:59 +07:00
committed by bflatastic
parent 17dc4ed46d
commit 0ce3147aab
40 changed files with 604 additions and 19 deletions
+59 -3
View File
@@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2};
use super::clipboard::SwatchChoices;
use super::{
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req,
finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, ok,
point_param, str_param, switch_or,
CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param,
f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param,
index, ok, point_param, str_param, switch_or,
};
use crate::{Clipboard, EngineError, inspect};
@@ -116,6 +116,15 @@ pub fn specs() -> Vec<CommandSpec> {
can_clear_history,
clear_history
),
cmd!(
"edit.newDocumentFromState",
"New Document from State",
[],
None,
"{index?: a row of the History panel (0: the oldest state kept; default: the current state), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} make a new document holding the art as it was at that state, and make it the active document (the History panel's New Document from State button). The new document is named with the next Untitled-N, has nothing selected and no steps to undo or redo, is marked unsaved and links to the same files. It is out of symbol, opacity-mask and pattern editing, with the art drawn in a pattern's tile stored in the pattern as object.pattern.done stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step, as edit.undo keeps it; `index` names the row as it was listed before that. An index that names no state is an error and changes nothing → {index: the new document's, title}",
has_doc,
new_document_from_state
),
cmd!(
"edit.undoGroup.begin",
"Begin Undo Group",
@@ -606,6 +615,53 @@ fn clear_history(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({ "cleared": cleared }))
}
/// History panel › New Document from State: a new document with the art of a state, and without
/// the editing state that state may hold (opacity-mask or symbol editing, Puppet Warp pins, new art).
/// Its links point at the files the source finds, which may be in the source's folder away from
/// the links' paths. Later artboards in the two documents take their ids separately. A later save of the new document
/// over the file its art was read from asks first, as a save of the source does.
fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "edit.newDocumentFromState";
let created = date_param(p, "created", C)?;
let st = s.doc()?;
// The row as the panel lists it now: keeping an edit in progress below changes the rows.
let chosen = match p.get("index") {
None | Some(Value::Null) => None,
Some(v) => {
let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row)));
Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
}
};
settle_interaction(s)?;
let st = s.doc()?;
let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc));
let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone());
if let Some(path) = st.path.as_deref() {
// Files are looked for within the asking caller's roots, else within those of the caller that opened the source.
let roots = crate::file_access::current().or_else(|| opened_by.clone());
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path));
}
doc.drop_edit_modes();
if doc.pattern_edit.is_some() {
super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?;
}
doc.puppet = None;
doc.new_art.set(Arc::default());
let retired = vectorcraft_doc::RetiredArtboardId::default();
retired.raise(doc.retired_artboard_id.get());
doc.retired_artboard_id = retired;
doc.title = s.next_untitled();
doc.metadata.created = clock_date(s, "created", created);
doc.metadata.modified = None;
let i = s.add_document(doc, None);
let st = s.doc_mut()?;
st.imported_from = imported_from;
st.import_losses = import_losses;
st.opened_by = st.opened_by.take().or(opened_by);
st.mark_unsaved();
Ok(json!({ "index": i, "title": st.title() }))
}
/// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same.
fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc<vectorcraft_doc::Selection> {
match like {
+22
View File
@@ -851,6 +851,28 @@ pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
Some(d)
}
/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own,
/// to the path where that document finds its file, so the copy finds the same files.
/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
// The new path of each link by what `locate` reads of it: its path, relative path and name.
let mut found: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
let mut look = |l: &LinkInfo| {
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path));
};
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
d.visit_placed(|_, p| look(&p.link));
found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path));
if found.is_empty() {
return;
}
d.update_links(|l| {
if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) {
l.path = f.clone();
}
});
}
// ---------- commands ----------
fn check(s: &mut Session, p: &Value) -> Result<Value> {
+14 -10
View File
@@ -377,20 +377,24 @@ pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result<Value> {
if editing(s).is_some() {
s.end_text_edit();
}
let name = s.edit("Done Editing Pattern", |d, sel| {
let (pe, art) = leave_edit(d, sel)?;
if let Some(def) = d.pattern_mut(&pe.pattern) {
def.art = art;
if def.size_tile_to_art {
def.fit_tile_to_art();
}
}
Ok(pe.pattern)
})?;
let name = s.edit("Done Editing Pattern", finish_edit)?;
after_edit_mode(s, None)?;
Ok(json!({ "name": name }))
}
/// Leave pattern editing mode in `d`, storing the editing layer's art as the pattern's tile art
/// (resizing the tile to the art when the pattern does so) → the pattern's name.
pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result<String> {
let (pe, art) = leave_edit(d, sel)?;
if let Some(def) = d.pattern_mut(&pe.pattern) {
def.art = art;
if def.size_tile_to_art {
def.fit_tile_to_art();
}
}
Ok(pe.pattern)
}
fn pattern_cancel(s: &mut Session, _: &Value) -> Result<Value> {
s.edit("Cancel Pattern Editing", |d, sel| {
let (pe, _) = leave_edit(d, sel)?;
+2 -1
View File
@@ -27,8 +27,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo
/// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the
/// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol);
/// an action leaves them out ([`Session::journal_for_action`]).
const REPLAY_ONLY: [(&str, &str); 21] = [
const REPLAY_ONLY: [(&str, &str); 22] = [
("file.new", "created"),
("edit.newDocumentFromState", "created"),
("document.save", "modified"),
("file.saveAs", "modified"),
("edit.pasteInPlace", "artboard"),
+11
View File
@@ -155,6 +155,17 @@ impl History {
self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum()
}
/// The document at row `row` of the History panel, `now` being the document now: the rows before
/// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first.
pub(crate) fn state<'a>(&'a self, now: &'a Arc<Document>, row: usize) -> Option<&'a Arc<Document>> {
let current = self.undo.len();
match row.cmp(&current) {
std::cmp::Ordering::Less => self.undo.get(row).map(|e| &e.doc),
std::cmp::Ordering::Equal => Some(now),
std::cmp::Ordering::Greater => self.redo.len().checked_sub(row - current).and_then(|i| self.redo.get(i)).map(|e| &e.doc),
}
}
/// Let the oldest steps go while over the step limit or the memory budget: undo steps first
/// (the oldest), then redo steps (the furthest ahead).
pub fn trim(&mut self) {
+1
View File
@@ -70,6 +70,7 @@ mod gradpresets;
mod gradspan;
mod guide_clipboard;
mod halftone;
mod history_document;
mod history_mem;
mod ids_without_selection;
mod inline;
+361
View File
@@ -0,0 +1,361 @@
//! History panel › New Document from State (`edit.newDocumentFromState`).
use std::sync::Arc;
use serde_json::{Value, json};
use super::{rect, session};
use crate::tests_links::{Folder, RED, place, png, write};
use crate::*;
const CMD: &str = "edit.newDocumentFromState";
fn objects(s: &Session) -> usize {
s.doc().unwrap().doc.layers[0].children().unwrap().len()
}
fn x(s: &Session, id: NodeId) -> f64 {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0
}
fn steps(s: &Session) -> (usize, usize) {
let h = &s.doc().unwrap().history;
(h.undo.len(), h.redo.len())
}
/// Three rectangles, the last one undone: rows 0 to 3 hold 0 to 3 of them, the current one row 2.
fn three_rectangles_one_undone() -> Session {
let mut s = session();
for x in [10.0, 200.0, 400.0] {
rect(&mut s, x, 10.0, 100.0, 50.0);
}
s.execute("edit.undo", &json!({})).unwrap();
s
}
/// Each row gives the art of its state, the steps to redo included. An omitted or null index gives
/// the current state. Each new document is added and made active.
#[test]
fn a_new_document_holds_the_art_of_the_state() {
let params = [
(json!({"index": 0}), 0),
(json!({"index": 1}), 1),
(json!({"index": 2}), 2),
(json!({"index": 3}), 3),
(json!({}), 2),
(json!({"index": null}), 2),
];
for (p, art) in params {
let mut s = three_rectangles_one_undone();
let r = s.execute(CMD, &p).unwrap();
assert_eq!((r["index"].as_u64(), s.active_index()), (Some(1), Some(1)), "{p}");
assert_eq!(objects(&s), art, "{p}");
assert_eq!(s.documents().len(), 2);
}
}
/// The source's document, steps, selection and saved state stay as they are, for a modified source
/// and for one at its saved state.
#[test]
fn the_source_stays_as_it_is() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let selection = s.doc().unwrap().selection.clone();
assert!(s.doc().unwrap().is_dirty());
s.execute(CMD, &json!({"index": 0})).unwrap();
let st = &s.documents()[0];
assert!(Arc::ptr_eq(&st.doc, &source));
assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1));
assert_eq!(st.selection, selection);
assert!(st.is_dirty());
let mut s = session();
rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
s.execute(CMD, &json!({"index": 1})).unwrap();
assert!(!s.documents()[0].is_dirty(), "the source stays at its saved state");
assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved");
}
/// An index that names no state, or is not a whole number, is an error and changes nothing: no
/// document, no step and no Untitled number is used up.
#[test]
fn a_bad_index_is_an_error_and_changes_nothing() {
let mut s = three_rectangles_one_undone();
let source = s.doc().unwrap().doc.clone();
let untitled = s.peek_untitled();
let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)];
for index in bad {
let e = s.execute(CMD, &json!({ "index": index })).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}");
assert!(e.to_string().contains(&format!("no history state {index}")), "{e}");
assert_eq!(s.documents().len(), 1, "{index}");
assert_eq!(steps(&s), (2, 1), "{index}");
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}");
assert_eq!(s.peek_untitled(), untitled, "{index}");
}
let e = s.execute(CMD, &json!({"created": "today"})).unwrap_err();
assert!(matches!(e, EngineError::BadParams { .. }), "{e}");
assert_eq!(s.documents().len(), 1);
}
/// With no document the command is disabled.
#[test]
fn it_needs_a_document() {
let mut s = Session::new();
assert!(matches!(s.execute(CMD, &json!({})), Err(EngineError::Disabled(..))));
}
/// An edit in either document leaves the other as it is, and so does taking an artboard id.
#[test]
fn the_two_documents_are_independent() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
rect(&mut s, 200.0, 10.0, 100.0, 50.0);
s.execute(CMD, &json!({"index": 1})).unwrap();
s.execute("object.move", &json!({"ids": [a.0], "dx": 30, "dy": 0})).unwrap();
rect(&mut s, 400.0, 10.0, 100.0, 50.0);
assert_eq!((x(&s, a), objects(&s)), (40.0, 2));
s.execute("document.activate", &json!({"index": 0})).unwrap();
assert_eq!((x(&s, a), objects(&s)), (10.0, 2), "the source");
s.execute("object.move", &json!({"ids": [a.0], "dx": 5, "dy": 0})).unwrap();
assert_eq!(x(&s, a), 15.0);
let source_board = s.doc().unwrap().doc.next_artboard_id();
s.execute("document.activate", &json!({"index": 1})).unwrap();
assert_eq!((x(&s, a), objects(&s)), (40.0, 2), "the new document");
assert!(s.doc().unwrap().doc.retired_artboard_id.get() < source_board, "an artboard id the source took");
}
/// The new document starts with no steps, no selection, no path and the next untitled name, and is
/// unsaved. Undo and Redo then work in it alone, and undoing back to the start leaves it unsaved.
#[test]
fn undo_in_the_new_document() {
let mut s = three_rectangles_one_undone();
s.execute("select.all", &json!({})).unwrap();
let r = s.execute(CMD, &json!({"index": 1})).unwrap();
assert_eq!(r["title"], "Untitled-2");
let st = s.doc().unwrap();
assert_eq!(st.title(), "Untitled-2");
assert_eq!(st.path, None);
assert!(st.selection.objects.is_empty());
assert!(st.is_dirty());
let start = st.doc.clone();
assert_eq!(steps(&s), (0, 0));
assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::Disabled(..))), "the source's steps are not its own");
rect(&mut s, 600.0, 10.0, 100.0, 50.0);
assert_eq!(objects(&s), 2);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(objects(&s), 1);
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &start));
assert!(s.doc().unwrap().is_dirty(), "still unsaved at the state it started from");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(objects(&s), 2);
assert_eq!(s.documents()[0].history.undo.len(), 2, "the source's steps");
}
/// A drag in progress is first kept as a step and its change goes into the new document. An index
/// names the state as listed before that, and a bad one leaves the drag going on.
#[test]
fn a_drag_in_progress_is_kept_first() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
assert!(s.execute(CMD, &json!({"index": 2})).is_err());
assert!(s.in_interaction(), "a bad index ends nothing");
assert_eq!(steps(&s), (1, 0));
s.execute(CMD, &json!({"index": 0})).unwrap();
assert_eq!(objects(&s), 0, "the row listed as 0");
assert_eq!(x_of_source(&s, a), 40.0);
assert_eq!(s.documents()[0].history.undo.len(), 2, "the drag is a step in the source");
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.begin_interaction("Move").unwrap();
s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap();
s.execute(CMD, &json!({})).unwrap();
assert_eq!(x(&s, a), 40.0, "the current state holds the drag");
assert_eq!(x_of_source(&s, a), 40.0);
assert!(s.documents()[0].interaction.is_none());
}
fn x_of_source(s: &Session, id: NodeId) -> f64 {
s.documents()[0].doc.node(id).unwrap().geometric_bounds().unwrap().x0
}
/// A state saved while editing a symbol gives a document out of that mode and without the
/// temporary editing layer. The source stays in the mode.
#[test]
fn a_new_document_leaves_the_editing_modes() {
let mut s = session();
rect(&mut s, 10.0, 10.0, 100.0, 50.0);
s.execute("symbol.new", &json!({"name": "Box"})).unwrap();
let layers = s.doc().unwrap().doc.layers.len();
s.execute("symbol.edit", &json!({})).unwrap();
assert!(s.doc().unwrap().doc.symbol_edit.is_some());
assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1);
s.execute(CMD, &json!({})).unwrap();
let st = s.doc().unwrap();
assert!(st.doc.symbol_edit.is_none());
assert_eq!(st.doc.layers.len(), layers);
assert!(s.documents()[0].doc.symbol_edit.is_some());
}
/// A state saved while editing a pattern gives a document out of that mode, without the editing
/// layer, and with the art drawn in the tile stored in the pattern, as Done stores it. The source
/// stays in the mode.
#[test]
fn a_new_document_finishes_pattern_editing() {
let mut s = session();
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
let layers = s.doc().unwrap().doc.layers.len();
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
rect(&mut s, 2.0, 2.0, 3.0, 3.0);
assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1);
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let st = s.doc().unwrap();
assert!(st.doc.pattern_edit.is_none());
assert_eq!(st.doc.layers.len(), layers);
assert_eq!(st.doc.pattern("P").map(|p| p.art.len()), Some(2));
assert_eq!(st.isolation, None);
assert!(s.documents()[0].doc.pattern_edit.is_some());
}
/// A linked image still finds its file from the new document, which has no folder of its own, and
/// its pixels come along.
#[test]
fn links_resolve_in_the_new_document() {
let dir = Folder::new("history-document-links");
let pic = dir.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
let id = place(&mut s, &pic);
let list = |s: &mut Session| s.execute("links.list", &json!({})).unwrap();
assert_eq!(list(&mut s)["links"][0]["status"], "ok");
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let links = list(&mut s);
assert_eq!((&links["links"][0]["status"], &links["links"][0]["path"]), (&json!("ok"), &json!(pic)), "{links}");
let st = s.doc().unwrap();
assert!(st.doc.node(id).is_some());
assert_eq!(st.doc.images.len(), s.documents()[0].doc.images.len());
}
/// A linked file the source finds in its own folder, away from the link's path, is found from the
/// new document too: its link takes the path where the source finds the file.
#[test]
fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() {
let a = Folder::new("history-document-moved-a");
let b = Folder::new("history-document-moved-b");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
let check = |s: &mut Session| s.execute("links.check", &json!({})).unwrap();
assert_eq!(check(&mut s)["links"][0]["status"], "ok", "the source finds it by name in its folder");
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let c = check(&mut s);
assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}");
}
/// The same holds for a linked image only a symbol shows.
#[test]
fn a_file_a_symbol_shows_is_found_from_the_new_document() {
let a = Folder::new("history-document-symbol-a");
let b = Folder::new("history-document-symbol-b");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("symbol.new", &json!({"name": "Photo"})).unwrap();
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let d = &s.doc().unwrap().doc;
assert!(!d.symbols.is_empty());
let mut paths = vec![];
d.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone())));
assert!(!paths.is_empty() && paths.iter().all(|p| *p == moved), "{paths:?}");
}
/// Files are looked for only within the roots of the caller that opened the source, or of a
/// confined caller asking now: a file outside them is not linked from the new document.
#[test]
fn a_file_outside_the_callers_roots_is_not_looked_for() {
let a = Folder::new("history-document-confined-a");
let b = Folder::new("history-document-confined-b");
let other = Folder::new("history-document-confined-other");
let pic = a.file("photo.png");
write(&pic, &png(60, 30, RED));
let mut s = session();
place(&mut s, &pic);
s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
let moved = b.file("photo.png");
std::fs::rename(&pic, &moved).unwrap();
let narrow = crate::file_access::AutomationRoots::new(&[&other.0], &[]).unwrap().unwrap();
let link = |s: &Session| {
let mut paths = vec![];
s.doc().unwrap().doc.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone())));
paths
};
crate::file_access::confine(Some(&narrow), || s.execute(CMD, &json!({}))).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
assert!(link(&s).iter().all(|p| *p != moved), "a confined caller: {:?}", link(&s));
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.active_mut().unwrap().opened_by = Some(narrow);
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(2), "the second new document");
let paths = link(&s);
assert!(!paths.is_empty() && paths.iter().all(|p| *p != moved), "opened by a confined caller: {paths:?}");
}
/// What reading the source's file left out still guards that file from the new document, and the
/// new document is read within what the caller that opened the source may read.
#[test]
fn the_new_document_keeps_what_guards_the_source() {
let dir = Folder::new("history-document-roots");
let roots = crate::file_access::AutomationRoots::new(&[&dir.0], &[]).unwrap().unwrap();
let mut s = session();
let st = s.active_mut().unwrap();
st.imported_from = Some("art.ai".into());
st.import_losses = vec!["hidden text".into()];
st.opened_by = Some(roots.clone());
s.execute(CMD, &json!({})).unwrap();
assert_eq!(s.active_index(), Some(1), "the new document");
let st = s.doc().unwrap();
assert_eq!((st.imported_from.as_deref(), st.import_losses.as_slice()), (Some("art.ai"), &["hidden text".to_string()][..]));
assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &roots)));
}
/// The created date is the new document's own and joins the journal entry, so a replay gives the
/// same date. An action leaves it out. The new document has no modified date until it is saved.
#[test]
fn the_created_date_is_the_new_documents_own() {
let mut s = session();
Arc::make_mut(&mut s.active_mut().unwrap().doc).metadata.modified = Some(500);
let source = s.doc().unwrap().doc.metadata.created;
s.execute(CMD, &json!({"created": 1000})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.created, Some(1000));
assert_eq!(s.doc().unwrap().doc.metadata.modified, None, "the new document has not been saved");
assert_eq!(s.documents()[0].doc.metadata.modified, Some(500));
assert_eq!(s.documents()[0].doc.metadata.created, source);
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute(CMD, &json!({"created": null})).unwrap();
assert_eq!(s.doc().unwrap().doc.metadata.created, None);
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute(CMD, &json!({})).unwrap();
let noted = s.journal.last().unwrap().1["created"].as_i64();
assert!(noted.is_some() && noted == s.doc().unwrap().doc.metadata.created, "{noted:?}");
let mut action = s.journal.last().unwrap().1.clone();
crate::command_sets::strip_replay_only(CMD, &mut action);
assert_eq!(action, json!({}), "an action leaves the date out");
}
+8
View File
@@ -911,7 +911,15 @@ fn every_command_undoes_exactly() {
let mut s = Fixture::Multi.session();
let before = doc_json(&s.doc().unwrap().doc);
let depth = s.doc().unwrap().history.undo.len();
let source = s.active_index().unwrap();
let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue };
// A command that makes a new document and makes it active leaves the source document as it is.
if s.active_index() != Some(source) {
if s.documents().get(source).map(|d| doc_json(&d.doc)).as_ref() != Some(&before) {
failures.push(format!("{}: changed the source document while making another active", c.id));
}
continue;
}
if s.doc().unwrap().history.undo.len() > depth {
let _ = no_panic(s.execute("edit.undo", &json!({})));
let after = doc_json(&s.doc().unwrap().doc);