Browse / github.com/storytold/craft-libs / patch
Extract reusable branching history for Craft apps
Extract reusable branching history for Craft apps History navigation and archive handling are currently embedded in PhotoCraft, although the same revision workflows are useful across the Craft editors. This extracts that engine into `craft-history`, with an app-owned policy for document types, target context, identity remapping, selection metadata and payload accounting. The library provides stable state/parent/branch identities, retained alternate paths, undo/redo, direct jumps, validated in-memory checkpoints, lazy fallible archives, stale-spill protection, and count/memory-limit policies. It depends only on serde, with no app, UI, filesys
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- TheSeanLavery (imported from pull request #104, open)
- Base
457a078809690994b22debdffb703ff36d3129df- Size
- +1256 −0 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1a8e64ab0abe67709242fe48
How it went
No other operator has reported using it yet.
Diff
raw · .github/workflows/history.yml Cargo.lock Cargo.toml NOTICE README.md crates/history/Cargo.toml crates/history/README.md crates/history/src/lib.rs crates/history/tests/history.rs
new file mode 100644@@ -0,0 +1,50 @@+name: Shared History++on:+ push:+ branches: [main]+ paths: ["crates/history/**", "Cargo.toml", "Cargo.lock", ".github/workflows/history.yml"]+ pull_request:+ paths: ["crates/history/**", "Cargo.toml", "Cargo.lock", ".github/workflows/history.yml"]+ workflow_dispatch:++permissions:+ contents: read++concurrency:+ group: shared-history-${{ github.ref }}+ cancel-in-progress: true++jobs:+ native:+ name: History (${{ matrix.os }})+ timeout-minutes: 15+ strategy:+ fail-fast: false+ matrix:+ os: [ubuntu-latest, windows-latest, macos-latest]+ runs-on: ${{ matrix.os }}+ steps:+ - uses: actions/checkout@v4+ - uses: dtolnay/rust-toolchain@stable+ with:+ components: rustfmt, clippy+ - uses: Swatinem/rust-cache@v2+ with:+ key: craft-history+ - run: cargo fmt --all -- --check+ - run: cargo clippy -p craft-history --all-targets --locked -- -D warnings+ - run: cargo test -p craft-history --locked+ wasm:+ name: History WASM+ timeout-minutes: 15+ runs-on: ubuntu-latest+ steps:+ - uses: actions/checkout@v4+ - uses: dtolnay/rust-toolchain@stable+ with:+ targets: wasm32-unknown-unknown+ - uses: Swatinem/rust-cache@v2+ with:+ key: craft-history-wasm+ - run: cargo check -p craft-history --locked --target wasm32-unknown-unknown@@ -529,6 +529,13 @@ dependencies = [ "rayon", ] +[[package]]+name = "craft-history"+version = "0.1.0"+dependencies = [+ "serde",+]+ [[package]] name = "craft-print" version = "0.1.0"@@ -11,6 +11,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft-<name> = { path = "crates/<name>" }`+craft-history = { path = "crates/history" } craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-print = { path = "crates/print" }@@ -79,3 +79,9 @@ The colour management module in crates/cms is extracted from: Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. Licensed MIT OR Apache-2.0. Its generated ICC profile is dedicated to the public domain (CC0-1.0); see ATTRIBUTION.md.++The branching history engine in crates/history is extracted and generalized from:+- PhotoCraft, crates/ops/src/lib.rs, revision 0e3f37be652cf50883487d59bba3a3aa6ecc6c14.+ https://github.com/TheSeanLavery/photocraft+ Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors.+ Licensed MIT OR Apache-2.0.@@ -23,6 +23,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit ## Libraries +- [`craft-history`](crates/history/README.md): branching snapshot history, lazy archive handles, checkpoints and memory/count policies; document models, serialization, workers and UI stay in each app.+ - [`craft-atomic`](crates/atomic/README.md): Crash-safe atomic file writing and replacement with same-directory staging, directory fsync, Apple `F_FULLFSYNC` fallback, transient lock backoff, and fault injection for the Craft suite. - [`craft-cms`](crates/cms/README.md): pure-Rust ICC v2/v4 colour management: profile parsing and writing, transforms, rendering intents, black point compensation, soft proofing, gamutnew file mode 100644@@ -0,0 +1,14 @@+[package]+name = "craft-history"+version.workspace = true+edition.workspace = true+license.workspace = true+rust-version.workspace = true+repository.workspace = true+description = "App-independent branching snapshot history with lazy archives and checkpoints"++[lints]+workspace = true++[dependencies]+serde = { version = "1", features = ["derive"] }new file mode 100644@@ -0,0 +1,66 @@+# Craft History++`craft-history` is an app-independent snapshot history engine. It retains alternate+branches, stable state/parent/branch identities, undo/redo cursors, direct navigation,+lazy archives, checkpoint validation, state limits, and managed-memory accounting.+It has no dependency on a Craft app, UI toolkit, document format, or filesystem.++Extracted from [PhotoCraft's history port](https://github.com/storytold/photocraft/pull/5404).+PhotoCraft is the first production adapter. Independent spreadsheet-like and+slide/text document models exercise the same engine in `tests/history.rs`;+other applications have not yet been migrated.++## Integration++Implement `Policy` using an app-owned marker type. Supply the document type,+target context (layers, cells, tracks, slides, or `()`), identity remapping,+selection metadata, and deduplicated payload accounting. The policy does not+serialize documents. `History<YourPolicy>` stores immutable `Arc` snapshots.++```rust+use craft_history::{History, Policy};+use std::{collections::HashSet, sync::Arc};++#[derive(Clone, Debug)]+struct TextApp;+impl Policy for TextApp {+ type Document = String;+ type Target = (usize, usize);+ type Id = ();+ fn has_selection(_: &String) -> bool { false }+ fn remap_id(_: &mut Arc<String>, _: ()) {}+ fn document_bytes(text: &String, seen: &mut HashSet<usize>) -> usize {+ if seen.insert(text.as_ptr() as usize) { text.capacity() } else { 0 }+ }+}+let mut history = History::<TextApp>::default();+history.record("Typing", Arc::new("Before".to_owned()), (0, 5));+let restored = history.try_undo(Arc::new("After".to_owned()))?;+# Ok::<(), String>(())+```++An `ArchivedDocument<YourPolicy>` provides fallible restoration and optional resident+backing for compact replay. The app chooses codecs, scratch directories, worker+scheduling, RAM spill policy, durable publication, file locks, and recovery cleanup.+The library does not promise crash durability itself.++`checkpoint`, `timeline`, and selection metadata queries never load archives.+Undo/redo load the destination before modifying navigation; a failed load leaves+the cursor and stacks intact. Direct jumps accept an already-loaded destination,+so the app can perform I/O off the UI thread without loading intervening states.+The app must revalidate its worker result against its document/history revision+before committing it. Allocation identity guards stale asynchronous spill results.++`max_states = 0` and `max_bytes = 0` mean unlimited. An app with unlimited history+must arrange backing storage and reject edits on storage failure rather than use+the destructive bounded-history eviction helpers. Byte accounting is an estimate+of app-managed payloads, not total process RAM; metadata is reported separately.++Checkpoint IDs permit missing parents after eviction, but reject duplicate IDs,+invalid forward/self parent references, invalid branches, exhausted identity ranges,+and excessive labels. Applications must impose serialized input-size/state-count+limits before decoding a checkpoint, and validate document/target semantics.++PhotoCraft keeps pixel serialization, exact raster move replay, its recovery format,+thumbnail production, selection restoration, commands, and egui history panel.+These are adapters, not dependencies of this crate.new file mode 100644@@ -0,0 +1,812 @@+//! Snapshot-based branching history shared by Craft applications.+//!+//! No document format, renderer, filesystem, or app dependency is required. A+//! [`Policy`] supplies document-specific metadata and shared-allocation accounting;+//! [`ArchivedDocument`] supplies fallible lazy storage or compact replay.+#![doc = include_str!("../README.md")]+#![forbid(unsafe_code)]+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]++use serde::{Deserialize, Serialize};+use std::collections::{HashSet, VecDeque};+use std::sync::Arc;++/// Application adapter. Hooks must be deterministic and must not hydrate storage.+/// Keep every allocation visited by `document_bytes` alive during a deduplication pass.+/// Targets can be target, cells, slides, tracks, or an empty value.+pub trait Policy: Clone + std::fmt::Debug + Send + Sync + 'static {+ type Document: Clone + std::fmt::Debug + Send + Sync + 'static;+ type Target: Clone + Default + std::fmt::Debug + Send + Sync + 'static;+ type Id: Copy + std::fmt::Debug + Send + Sync;+ fn has_selection(document: &Self::Document) -> bool;+ fn remap_id(document: &mut Arc<Self::Document>, id: Self::Id);+ fn document_bytes(document: &Self::Document, seen: &mut HashSet<usize>) -> usize;+ fn target_bytes(_target: &Self::Target) -> usize {+ 0+ }+}++#[derive(Clone, Debug)]+pub struct HistoryState<P: Policy> {+ pub label: String,+ /// The target targeted when this state was created.+ pub target: P::Target,+ pub identity: StateIdentity,+ document: StoredDocument<P>,+ has_selection: bool,+}++/// Stable navigation identity; branch colors follow `branch`, not row positions.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]+pub struct StateIdentity {+ pub id: u64,+ pub parent: Option<u64>,+ pub branch: u64,+ #[serde(default)]+ pub revision: u64,+}++#[derive(Clone, Debug)]+pub struct HistoryEntry {+ pub identity: StateIdentity,+ pub label: String,+ pub current: bool,+}++impl<P: Policy> HistoryState<P> {+ /// Build a resident recovery state without copying pixel data.+ pub fn from_document(label: impl Into<String>, document: Arc<P::Document>) -> Self {+ Self {+ label: label.into(),+ target: P::Target::default(),+ identity: StateIdentity::default(),+ has_selection: P::has_selection(&document),+ document: StoredDocument::<P>::Resident(document),+ }+ }++ /// Build a lazily restored recovery state. `has_selection` is metadata for+ /// selection menus, which must not read storage merely to draw themselves.+ pub fn from_archive(label: impl Into<String>, has_selection: bool, archive: Arc<dyn ArchivedDocument<P>>) -> Self {+ Self {+ label: label.into(),+ target: P::Target::default(),+ identity: StateIdentity::default(),+ has_selection,+ document: StoredDocument::<P>::Archived(archive),+ }+ }++ /// May read storage for a cold state; recovery writers call this on a worker.+ pub fn load_document(&self) -> Result<Arc<P::Document>, String> {+ self.document.load()+ }++ pub fn has_selection(&self) -> bool {+ self.has_selection+ }++ pub fn with_target(mut self, target: P::Target) -> Self {+ self.target = target;+ self+ }+}++/// Immutable handles and navigation metadata captured alongside the current+/// document. Undo is oldest first; redo's last entry is the next redo state.+/// Capturing this value never hydrates archived documents.+#[derive(Clone, Debug)]+pub struct HistoryCheckpoint<P: Policy> {+ pub undo: Vec<HistoryState<P>>,+ pub redo: Vec<HistoryState<P>>,+ pub branches: Vec<HistoryState<P>>,+ pub current_identity: StateIdentity,+ pub current_label: String,+ pub current_target: P::Target,+ pub max_states: usize,+ pub max_bytes: usize,+}++/// Engine-owned immutable scratch archive. Loading must preserve the complete document.+pub trait ArchivedDocument<P: Policy>: std::fmt::Debug + Send + Sync {+ fn load(&self) -> Result<Arc<P::Document>, String>;+ fn metadata_bytes(&self) -> usize {+ 0+ }+ /// Resident backing for compact replay states; counted once, never mistaken for disk.+ fn resident(&self) -> Option<&Arc<P::Document>> {+ None+ }+}++#[derive(Debug)]+struct RemappedDocument<P: Policy> {+ source: Arc<dyn ArchivedDocument<P>>,+ id: P::Id,+}+impl<P: Policy> ArchivedDocument<P> for RemappedDocument<P> {+ fn load(&self) -> Result<Arc<P::Document>, String> {+ let mut doc = self.source.load()?;+ P::remap_id(&mut doc, self.id);+ Ok(doc)+ }+ fn metadata_bytes(&self) -> usize {+ std::mem::size_of::<Self>().saturating_add(self.source.metadata_bytes())+ }+ fn resident(&self) -> Option<&Arc<P::Document>> {+ self.source.resident()+ }+}++#[derive(Clone, Debug)]+enum StoredDocument<P: Policy> {+ Resident(Arc<P::Document>),+ Archived(Arc<dyn ArchivedDocument<P>>),+}++impl<P: Policy> StoredDocument<P> {+ fn load(&self) -> Result<Arc<P::Document>, String> {+ match self {+ Self::Resident(doc) => Ok(doc.clone()),+ Self::Archived(archive) => archive.load(),+ }+ }+ fn resident(&self) -> Option<&Arc<P::Document>> {+ match self {+ Self::Resident(doc) => Some(doc),+ Self::Archived(archive) => archive.resident(),+ }+ }+}++#[derive(Clone, Debug)]+pub struct History<P: Policy> {+ /// Past states; the last one is the state *before* the current document.+ undo: VecDeque<HistoryState<P>>,+ redo: Vec<HistoryState<P>>,+ branches: Vec<HistoryState<P>>,+ current_identity: StateIdentity,+ next_id: u64,+ pub max_states: usize,+ /// Pixel memory budget in bytes for the current document plus the tiles only history holds+ /// (0 = unlimited). [`History::<P>::trim`] drops the oldest states beyond it.+ pub max_bytes: usize,+ /// Label of the step that produced the current document.+ current_label: String,+ /// The target targeted when the current document was created (opened or edited).+ current_target: P::Target,+ current_archive: Option<Arc<dyn ArchivedDocument<P>>>,+ current_archive_document: Option<std::sync::Weak<P::Document>>,+}++impl<P: Policy> Default for History<P> {+ fn default() -> Self {+ Self::new(0)+ }+}++impl<P: Policy> History<P> {+ pub fn checkpoint(&self) -> HistoryCheckpoint<P> {+ HistoryCheckpoint {+ undo: self.undo.iter().cloned().collect(),+ redo: self.redo.clone(),+ branches: self.branches.clone(),+ current_identity: self.current_identity,+ current_label: self.current_label.clone(),+ current_target: self.current_target.clone(),+ max_states: self.max_states,+ max_bytes: self.max_bytes,+ }+ }++ /// Restore the exact navigation cursor without reading document storage.+ /// Recovery metadata has independent admission limits; preference limits+ /// may subsequently be applied explicitly through `enforce_state_limit`.+ pub fn from_checkpoint(mut checkpoint: HistoryCheckpoint<P>) -> Result<Self, String> {+ const MAX_LABEL_BYTES: usize = 4096;+ if checkpoint.current_label.len() > MAX_LABEL_BYTES+ || checkpoint.undo.iter().chain(checkpoint.redo.iter()).chain(checkpoint.branches.iter()).any(|state| state.label.len() > MAX_LABEL_BYTES)+ {+ return Err("Recovery history label exceeds 4096 bytes".into());+ }+ // Legacy checkpoints have no identities. Assign their linear path once.+ if checkpoint.current_identity == StateIdentity::default()+ && checkpoint.undo.iter().chain(checkpoint.redo.iter()).all(|state| state.identity == StateIdentity::default())+ && checkpoint.branches.is_empty()+ {+ let mut id = 0u64;+ for state in &mut checkpoint.undo {+ state.identity = StateIdentity { id, parent: id.checked_sub(1), branch: 0, revision: 0 };+ id = id.saturating_add(1);+ }+ checkpoint.current_identity = StateIdentity { id, parent: id.checked_sub(1), branch: 0, revision: 0 };+ for state in checkpoint.redo.iter_mut().rev() {+ id = id.saturating_add(1);+ state.identity = StateIdentity { id, parent: id.checked_sub(1), branch: 0, revision: 0 };+ }+ }+ if checkpoint.current_identity.id > u64::MAX / 2+ || checkpoint.current_identity.revision > u64::MAX / 2+ || checkpoint.current_identity.branch > checkpoint.current_identity.id+ || checkpoint.current_identity.parent.is_some_and(|parent| parent >= checkpoint.current_identity.id)+ {+ return Err("Invalid current history identity".into());+ }+ let mut ids = HashSet::from([checkpoint.current_identity.id]);+ let mut next_id = checkpoint.current_identity.id.checked_add(1).ok_or("History identity exhausted")?;+ for state in checkpoint.undo.iter().chain(checkpoint.redo.iter()).chain(checkpoint.branches.iter()) {+ if !ids.insert(state.identity.id)+ || state.identity.id > u64::MAX / 2+ || state.identity.revision > u64::MAX / 2+ || state.identity.branch > state.identity.id+ || state.identity.parent.is_some_and(|parent| parent >= state.identity.id)+ {+ return Err("Invalid recovery history identity".into());+ }+ next_id = next_id.max(state.identity.id.checked_add(1).ok_or("History identity exhausted")?);+ }+ Ok(Self {+ undo: checkpoint.undo.into(),+ redo: checkpoint.redo,+ branches: checkpoint.branches,+ current_identity: checkpoint.current_identity,+ next_id,+ current_label: checkpoint.current_label,+ current_target: checkpoint.current_target,+ current_archive: None,+ current_archive_document: None,+ max_states: checkpoint.max_states,+ max_bytes: checkpoint.max_bytes,+ })+ }++ pub fn new(max_states: usize) -> Self {+ Self {+ undo: VecDeque::new(),+ redo: Vec::new(),+ branches: Vec::new(),+ current_identity: StateIdentity::default(),+ next_id: 1,+ max_states,+ max_bytes: 0,+ current_label: "Open".into(),+ current_target: P::Target::default(),+ current_archive: None,+ current_archive_document: None,+ }+ }++ /// Metadata-only stamp for logical history changes (including Purge).+ pub fn checkpoint_signature(&self) -> (usize, usize, &str) {+ (self.undo.len(), self.redo.len(), &self.current_label)+ }++ /// A duplicate recovered tab can be admitted under a fresh identity.+ /// Cold states are remapped only when restored, without reading disk here.+ pub fn remap_document_id(&mut self, id: P::Id) {+ if let Some(source) = self.current_archive.take() {+ self.current_archive = Some(Arc::new(RemappedDocument { source, id }));+ }+ for state in self.undo.iter_mut().chain(self.redo.iter_mut()).chain(self.branches.iter_mut()) {+ match &mut state.document {+ StoredDocument::<P>::Resident(doc) => {+ P::remap_id(doc, id);+ }+ StoredDocument::<P>::Archived(source) => {+ *source = Arc::new(RemappedDocument { source: source.clone(), id });+ }+ }+ }+ }++ /// Record that `before` was replaced by a new current document via step `label`, which left+ /// `target` targeted.+ pub fn record(&mut self, label: impl Into<String>, before: Arc<P::Document>, target: P::Target) {+ self.clear_current_archive();+ let prev_label = std::mem::replace(&mut self.current_label, label.into());+ let prev_layers = std::mem::replace(&mut self.current_target, target);+ self.undo.push_back(HistoryState {+ label: prev_label,+ target: prev_layers,+ identity: self.current_identity,+ has_selection: P::has_selection(&before),+ document: StoredDocument::<P>::Resident(before),+ });+ let branch = if self.redo.is_empty() { self.current_identity.branch } else { self.next_id };+ self.current_identity = StateIdentity { id: self.next_id, parent: Some(self.current_identity.id), branch, revision: 0 };+ self.next_id = self.next_id.saturating_add(1);+ self.branches.append(&mut self.redo);+ self.enforce_state_limit();+ }++ pub fn can_undo(&self) -> bool {+ !self.undo.is_empty()+ }+ pub fn can_redo(&self) -> bool {+ !self.redo.is_empty()+ }+ pub fn undo_label(&self) -> Option<&str> {+ self.can_undo().then_some(self.current_label.as_str())+ }+ pub fn redo_label(&self) -> Option<&str> {+ self.redo.last().map(|s| s.label.as_str())+ }+ /// Labels of the redo states, the next redo first (the History panel's greyed rows).+ pub fn redo_labels(&self) -> impl Iterator<Item = &str> {+ self.redo.iter().rev().map(|s| s.label.as_str())+ }++ /// Set the target the current document targets from its creation on: when it is first+ /// opened, or when its step coalesces or changes the selection after recording.+ pub fn set_current_target(&mut self, target: P::Target) {+ self.current_target = target;+ self.current_identity.revision = self.current_identity.revision.saturating_add(1);+ }++ pub fn current_target(&self) -> &P::Target {+ &self.current_target+ }++ /// Restore first: a failed archive read never moves either history stack.+ pub fn try_undo(&mut self, current: Arc<P::Document>) -> Result<Option<Arc<P::Document>>, String> {+ let Some(prev) = self.undo.back() else { return Ok(None) };+ let restored = prev.document.load()?;+ let Some(prev) = self.undo.pop_back() else { return Ok(None) };+ let label = std::mem::replace(&mut self.current_label, prev.label);+ let target = std::mem::replace(&mut self.current_target, prev.target.clone());+ let has_selection = P::has_selection(¤t);+ let document = self.take_current_document(current);+ self.redo.push(HistoryState { label, has_selection, document, target, identity: self.current_identity });+ self.current_identity = prev.identity;+ self.current_archive = match prev.document {+ StoredDocument::<P>::Archived(archive) => Some(archive),+ _ => None,+ };+ self.current_archive_document = self.current_archive.as_ref().map(|_| Arc::downgrade(&restored));+ Ok(Some(restored))+ }++ pub fn try_redo(&mut self, current: Arc<P::Document>) -> Result<Option<Arc<P::Document>>, String> {+ let Some(next) = self.redo.last() else { return Ok(None) };+ let restored = next.document.load()?;+ let Some(next) = self.redo.pop() else { return Ok(None) };+ let label = std::mem::replace(&mut self.current_label, next.label);+ let target = std::mem::replace(&mut self.current_target, next.target.clone());+ let has_selection = P::has_selection(¤t);+ let document = self.take_current_document(current);+ self.undo.push_back(HistoryState { label, has_selection, document, target, identity: self.current_identity });+ self.current_identity = next.identity;+ self.current_archive = match next.document {+ StoredDocument::<P>::Archived(archive) => Some(archive),+ _ => None,+ };+ self.current_archive_document = self.current_archive.as_ref().map(|_| Arc::downgrade(&restored));+ Ok(Some(restored))+ }++ /// Compatibility wrapper. Interactive callers should use [`Self::try_undo`] to report errors.+ pub fn undo(&mut self, current: Arc<P::Document>) -> Option<(Arc<P::Document>, P::Target)> {+ self.try_undo(current).ok().flatten().map(|doc| (doc, self.current_target.clone()))+ }+ /// Compatibility wrapper. Interactive callers should use [`Self::try_redo`] to report errors.+ pub fn redo(&mut self, current: Arc<P::Document>) -> Option<(Arc<P::Document>, P::Target)> {+ self.try_redo(current).ok().flatten().map(|doc| (doc, self.current_target.clone()))+ }++ /// Entries for a History panel: past labels oldest→newest, then the current label.+ pub fn entries(&self) -> Vec<String> {+ self.undo.iter().map(|s| s.label.clone()).chain(std::iter::once(self.current_label.clone())).collect()+ }++ /// Metadata only: drawing history must never decode a document from disk.+ pub fn timeline(&self) -> Vec<HistoryEntry> {+ let mut entries: Vec<_> = self+ .undo+ .iter()+ .chain(self.redo.iter())+ .chain(self.branches.iter())+ .map(|state| HistoryEntry { identity: state.identity, label: state.label.clone(), current: false })+ .collect();+ entries.push(HistoryEntry { identity: self.current_identity, label: self.current_label.clone(), current: true });+ entries.sort_by_key(|entry| entry.identity.id);+ entries+ }++ /// Delete one branch state and all of its descendants, preserving independent branches.+ /// The caller first moves the current document to the deleted state's predecessor.+ pub fn prune_descendants(&mut self, id: u64) {+ let timeline = self.timeline();+ let mut removed = HashSet::from([id]);+ loop {+ let before = removed.len();+ for entry in &timeline {+ if entry.identity.parent.is_some_and(|parent| removed.contains(&parent)) {+ removed.insert(entry.identity.id);+ }+ }+ if removed.len() == before {+ break;+ }+ }+ self.undo.retain(|state| !removed.contains(&state.identity.id));+ self.redo.retain(|state| !removed.contains(&state.identity.id));+ self.branches.retain(|state| !removed.contains(&state.identity.id));+ }++ pub fn current_id(&self) -> u64 {+ self.current_identity.id+ }++ pub fn timeline_stamp(&self) -> (u64, u64, u64, usize, usize, usize) {+ (self.current_identity.id, self.current_identity.revision, self.next_id, self.undo.len(), self.redo.len(), self.branches.len())+ }++ pub fn adjacent_id(&self, redo: bool) -> Option<u64> {+ (if redo { self.redo.last() } else { self.undo.back() }).map(|state| state.identity.id)+ }++ /// Immutable destination handle, safe to restore on a worker.+ pub fn state_by_id(&self, current: Arc<P::Document>, id: u64) -> Option<HistoryState<P>> {+ if id == self.current_identity.id {+ let mut state = HistoryState::<P>::from_document(self.current_label.clone(), current).with_target(self.current_target.clone());+ state.identity = self.current_identity;+ return Some(state);+ }+ self.undo.iter().chain(self.redo.iter()).chain(self.branches.iter()).find(|state| state.identity.id == id).cloned()+ }++ /// Commit one already-loaded destination. Intervening snapshots stay cold.+ /// Build/validate the path before mutating navigation, so any error is atomic.+ pub fn jump_loaded(&mut self, current: Arc<P::Document>, id: u64, restored: Arc<P::Document>) -> Result<(), String> {+ self.jump_loaded_on_path(current, id, restored, None)+ }++ pub fn navigation_branch(&self) -> u64 {+ self.redo.first().map_or(self.current_identity.branch, |state| state.identity.branch)+ }++ pub fn jump_loaded_on_path(+ &mut self,+ current: Arc<P::Document>,+ id: u64,+ restored: Arc<P::Document>,+ preferred_branch: Option<u64>,+ ) -> Result<(), String> {+ if id == self.current_identity.id {+ return Ok(());+ }+ let mut nodes: std::collections::BTreeMap<_, _> =+ self.undo.iter().chain(self.redo.iter()).chain(self.branches.iter()).map(|state| (state.identity.id, state.clone())).collect();+ let target = nodes.remove(&id).ok_or("History state is no longer available")?;+ let mut current_state =+ HistoryState::<P>::from_document(self.current_label.clone(), current.clone()).with_target(self.current_target.clone());+ current_state.identity = self.current_identity;+ nodes.insert(self.current_identity.id, current_state);+ let mut ancestors = Vec::new();+ let mut parent = target.identity.parent;+ let mut seen = HashSet::from([id]);+ while let Some(key) = parent {+ if !seen.insert(key) {+ return Err("Cyclic history path".into());+ }+ let Some(state) = nodes.remove(&key) else { break };+ parent = state.identity.parent;+ ancestors.push(state);+ }+ ancestors.reverse();+ let branch =+ preferred_branch.unwrap_or_else(|| if target.identity.parent.is_none() { self.navigation_branch() } else { target.identity.branch });+ let mut descendants = Vec::new();+ let mut children: std::collections::BTreeMap<u64, Vec<u64>> = std::collections::BTreeMap::new();+ for state in nodes.values() {+ if let Some(parent) = state.identity.parent {+ children.entry(parent).or_default().push(state.identity.id);+ }+ }+ let mut cursor = id;+ loop {+ let next = children+ .get(&cursor)+ .into_iter()+ .flatten()+ .filter_map(|key| nodes.get(key))+ .max_by_key(|state| (state.identity.branch == branch, state.identity.id))+ .map(|state| state.identity.id);+ let Some(next) = next else { break };+ if !seen.insert(next) {+ return Err("Cyclic history path".into());+ }+ let Some(state) = nodes.remove(&next) else { break };+ cursor = next;+ descendants.push(state);+ }+ // Preserve an archived handle for the previous current document when available.+ let stored_current = self.take_current_document(current);+ for state in ancestors.iter_mut().chain(descendants.iter_mut()).chain(nodes.values_mut()) {+ if state.identity.id == self.current_identity.id {+ state.document = stored_current.clone();+ }+ }+ descendants.reverse();+ self.undo = ancestors.into();+ self.redo = descendants;+ self.branches = nodes.into_values().collect();+ self.current_identity = target.identity;+ self.current_label = target.label;+ self.current_target = target.target;+ self.current_archive = match target.document {+ StoredDocument::<P>::Archived(archive) => Some(archive),+ _ => None,+ };+ self.current_archive_document = self.current_archive.as_ref().map(|_| Arc::downgrade(&restored));+ Ok(())+ }++ /// Document of past entry `i`, indexed like [`History::<P>::entries`] (0 = oldest). The last entry is+ /// the current document, which the history does not hold, so it (and any index past it) is `None`.+ pub fn state(&self, i: usize) -> Option<Arc<P::Document>> {+ self.try_state(i).ok().flatten()+ }++ /// Metadata-only query for menus; never hydrates a cold snapshot.+ pub fn has_past_selection(&self) -> bool {+ self.undo.iter().any(|state| state.has_selection)+ }++ /// Most recent selection-bearing state, found without reading scratch storage.+ pub fn latest_selection_state(&self) -> Option<usize> {+ self.undo.iter().enumerate().rev().find_map(|(index, state)| state.has_selection.then_some(index))+ }++ pub fn resident_state(&self, i: usize) -> Option<Arc<P::Document>> {+ self.undo.get(i).and_then(|state| match &state.document {+ StoredDocument::<P>::Resident(doc) => Some(doc.clone()),+ _ => None,+ })+ }++ pub fn try_state(&self, i: usize) -> Result<Option<Arc<P::Document>>, String> {+ self.undo.get(i).map(|state| state.document.load()).transpose()+ }++ /// Resident snapshots only; this never reads scratch storage.+ pub fn resident_states(&self) -> Vec<Arc<P::Document>> {+ let mut seen = HashSet::new();+ self.undo+ .iter()+ .chain(self.redo.iter())+ .chain(self.branches.iter())+ .filter_map(|state| state.document.resident().cloned())+ .chain(self.current_archive.iter().filter_map(|archive| archive.resident().cloned()))+ .filter(|doc| seen.insert(Arc::as_ptr(doc) as usize))+ .collect()+ }++ /// Concrete snapshots eligible for scratch serialization. Compact replay backing stays resident.+ pub fn spillable_states(&self) -> Vec<Arc<P::Document>> {+ self.undo+ .iter()+ .chain(self.redo.iter())+ .chain(self.branches.iter())+ .filter_map(|state| match &state.document {+ StoredDocument::<P>::Resident(doc) => Some(doc.clone()),+ _ => None,+ })+ .collect()+ }++ /// State descriptors and compact replay records, separate from pixel payload accounting.+ pub fn metadata_bytes(&self) -> usize {+ let mut total = self.current_label.capacity().saturating_add(P::target_bytes(&self.current_target));+ for state in self.undo.iter().chain(self.redo.iter()).chain(self.branches.iter()) {+ total = total+ .saturating_add(std::mem::size_of::<HistoryState<P>>())+ .saturating_add(state.label.capacity())+ .saturating_add(P::target_bytes(&state.target));+ if let StoredDocument::<P>::Archived(archive) = &state.document {+ total = total.saturating_add(archive.metadata_bytes());+ }+ }+ if let Some(archive) = &self.current_archive {+ total = total.saturating_add(archive.metadata_bytes());+ }+ total+ }++ pub fn archived_states(&self) -> usize {+ self.undo+ .iter()+ .chain(self.redo.iter())+ .chain(self.branches.iter())+ .filter(|state| matches!(&state.document, StoredDocument::<P>::Archived(archive) if archive.resident().is_none()))+ .count()+ }++ /// Adjacent resident snapshots pinned for immediate undo/redo.+ pub fn hot_states(&self) -> Vec<Arc<P::Document>> {+ self.undo+ .back()+ .into_iter()+ .chain(self.redo.last())+ .filter_map(|state| state.document.resident().cloned())+ .chain(self.current_archive.iter().filter_map(|archive| archive.resident().cloned()))+ .collect()+ }++ /// Whether this allocation is the immediate undo or redo state.+ /// Used to revalidate asynchronous spill results after intervening navigation.+ pub fn is_hot(&self, doc: &Arc<P::Document>) -> bool {+ self.undo.back().and_then(|state| state.document.resident()).is_some_and(|hot| Arc::ptr_eq(doc, hot))+ || self.redo.last().and_then(|state| state.document.resident()).is_some_and(|hot| Arc::ptr_eq(doc, hot))+ }++ /// Oldest eligible snapshot, keeping the immediate undo and redo states hot.+ pub fn spill_candidate(&self) -> Option<Arc<P::Document>> {+ self.undo+ .iter()+ .take(self.undo.len().saturating_sub(1))+ .chain(self.redo.iter().take(self.redo.len().saturating_sub(1)))+ .filter_map(|state| state.document.resident())+ .find(|doc| {+ !self.undo.back().and_then(|state| state.document.resident()).is_some_and(|hot| Arc::ptr_eq(doc, hot))+ && !self.redo.last().and_then(|state| state.document.resident()).is_some_and(|hot| Arc::ptr_eq(doc, hot))+ })+ .cloned()+ }++ /// Replace only the allocation actually archived; stale worker results are harmless.+ pub fn replace_resident(&mut self, doc: &Arc<P::Document>, archive: Arc<dyn ArchivedDocument<P>>) -> bool {+ let mut replaced = false;+ for state in self.undo.iter_mut().chain(self.redo.iter_mut()).chain(self.branches.iter_mut()) {+ if matches!(&state.document, StoredDocument::<P>::Resident(resident) if Arc::ptr_eq(resident, doc)) {+ state.document = StoredDocument::<P>::Archived(archive.clone());+ replaced = true;+ }+ }+ replaced+ }++ /// Budget fallback, preserving the immediate undo and redo steps.+ pub fn drop_oldest(&mut self) -> bool {+ if !self.branches.is_empty() {+ self.branches.remove(0);+ return true;+ }+ if self.undo.len() > 1 {+ self.undo.pop_front();+ true+ } else if self.redo.len() > 1 {+ self.redo.remove(0);+ true+ } else {+ false+ }+ }++ /// Explicit preference reduction may retire even the final undo/redo state.+ /// The current document is never owned by these stacks.+ pub fn discard_oldest(&mut self) -> bool {+ if !self.branches.is_empty() {+ self.branches.remove(0);+ return true;+ }+ if self.undo.pop_front().is_some() {+ true+ } else if !self.redo.is_empty() {+ self.redo.remove(0);+ true+ } else {+ false+ }+ }++ /// Enforce a lowered history-count preference without reading scratch storage.+ pub fn enforce_state_limit(&mut self) -> usize {+ if self.max_states == 0 {+ return 0;+ }+ let limit = self.max_states.max(1);+ let mut removed = 0;+ while self.undo.len().saturating_add(self.redo.len()).saturating_add(self.branches.len()) > limit {+ if !self.discard_oldest() {+ break;+ }+ removed += 1;+ }+ removed+ }++ /// Number of past states (entries before the current one).+ pub fn past_len(&self) -> usize {+ self.undo.len()+ }++ /// Rename the current step (e.g. after folding several steps into one with [`Self::purge_last`]).+ pub fn set_current_label(&mut self, label: impl Into<String>) {+ self.current_label = label.into();+ }++ /// Forget the most recent undo state and every redo state (Edit › Purge › Undo): the+ /// last step can no longer be undone and its pixels are released.+ pub fn purge_last(&mut self) -> bool {+ self.redo.clear();+ self.undo.pop_back().is_some()+ }++ /// Forget the redo states, e.g. after undoing half of a compound step that failed.+ pub fn clear_redo(&mut self) {+ self.redo.clear();+ }++ /// Invalidate replay provenance after a successful edit that changes the current document.+ pub fn clear_current_archive(&mut self) {+ self.current_archive = None;+ self.current_archive_document = None;+ }++ pub fn set_current_archive(&mut self, current: &Arc<P::Document>, archive: Arc<dyn ArchivedDocument<P>>) {+ self.current_archive_document = Some(Arc::downgrade(current));+ self.current_archive = Some(archive);+ }++ fn take_current_document(&mut self, current: Arc<P::Document>) -> StoredDocument<P> {+ let matches = self.current_archive_document.take().and_then(|bound| bound.upgrade()).is_some_and(|bound| Arc::ptr_eq(&bound, ¤t));+ match self.current_archive.take() {+ Some(archive) if matches => StoredDocument::<P>::Archived(archive),+ _ => StoredDocument::<P>::Resident(current),+ }+ }++ pub fn clear(&mut self) {+ self.clear_current_archive();+ self.undo.clear();+ self.redo.clear();+ self.branches.clear();+ self.current_identity.parent = None;+ }++ /// Approximate unique tile and shared binary-payload bytes held by history.+ pub fn unique_bytes(&self, current: &P::Document) -> usize {+ let mut seen = HashSet::new();+ P::document_bytes(current, &mut seen);+ self.resident_states().iter().fold(0usize, |n, doc| n.saturating_add(P::document_bytes(doc, &mut seen)))+ }++ /// Managed bytes of `current` plus the allocations only history holds (what [`History::<P>::trim`]+ /// bounds by [`History::<P>::max_bytes`]).+ pub fn pixel_bytes(&self, current: &P::Document) -> usize {+ let mut seen = HashSet::new();+ let own = P::document_bytes(current, &mut seen);+ self.resident_states().iter().fold(own, |n, doc| n.saturating_add(P::document_bytes(doc, &mut seen)))+ }++ /// Keep pixel memory within [`History::<P>::max_bytes`]: the current document's tiles plus the+ /// tiles only history holds (newest states first). The oldest undo states that don't fit are+ /// dropped; the most recent one is always kept so the last step can be undone. Returns how+ /// many states were dropped.+ pub fn trim(&mut self, current: &P::Document) -> usize {+ if self.max_bytes == 0 || self.undo.len() <= 1 {+ return 0;+ }+ let mut seen = HashSet::new();+ let mut total = P::document_bytes(current, &mut seen);+ for s in self.redo.iter().rev() {+ if let Some(doc) = s.document.resident() {+ total = total.saturating_add(P::document_bytes(doc, &mut seen));+ }+ }+ let mut keep = 0;+ for s in self.undo.iter().rev() {+ if let Some(doc) = s.document.resident() {+ total = total.saturating_add(P::document_bytes(doc, &mut seen));+ }+ if total > self.max_bytes && keep >= 1 {+ break;+ }+ keep += 1;+ }+ let drop = self.undo.len() - keep;+ self.undo.drain(..drop);+ drop+ }+}new file mode 100644@@ -0,0 +1,298 @@+use craft_history::{ArchivedDocument, History, HistoryState, Policy, StateIdentity};+use std::collections::HashSet;+use std::sync::{+ Arc,+ atomic::{AtomicUsize, Ordering},+};++#[derive(Clone, Debug)]+struct Sheet {+ id: u64,+ cells: Arc<Vec<u8>>,+ selection: bool,+}+#[derive(Clone, Debug)]+struct Sheets;+impl Policy for Sheets {+ type Document = Sheet;+ type Target = Vec<usize>;+ type Id = u64;+ fn has_selection(doc: &Sheet) -> bool {+ doc.selection+ }+ fn remap_id(doc: &mut Arc<Sheet>, id: u64) {+ if doc.id != id {+ Arc::make_mut(doc).id = id;+ }+ }+ fn document_bytes(doc: &Sheet, seen: &mut HashSet<usize>) -> usize {+ if seen.insert(Arc::as_ptr(&doc.cells) as usize) { doc.cells.capacity() } else { 0 }+ }+ fn target_bytes(target: &Vec<usize>) -> usize {+ target.capacity() * size_of::<usize>()+ }+}+fn sheet(value: u8) -> Arc<Sheet> {+ Arc::new(Sheet { id: 1, cells: Arc::new(vec![value; 32]), selection: value.is_multiple_of(2) })+}+fn edit(history: &mut History<Sheets>, current: &mut Arc<Sheet>, value: u8) {+ history.record(format!("Cell {value}"), current.clone(), vec![usize::from(value)]);+ *current = sheet(value);+}+#[derive(Debug)]+struct Archive {+ document: Arc<Sheet>,+ reads: Arc<AtomicUsize>,+ fail: bool,+}+impl ArchivedDocument<Sheets> for Archive {+ fn load(&self) -> Result<Arc<Sheet>, String> {+ self.reads.fetch_add(1, Ordering::Relaxed);+ if self.fail { Err("Storage unavailable".into()) } else { Ok(self.document.clone()) }+ }+}+#[test]+fn branches_survive_navigation_and_checkpoint_restore() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ edit(&mut history, &mut current, 2);+ let alternate = history.current_id();+ current = history.try_undo(current).unwrap().unwrap();+ edit(&mut history, &mut current, 3);+ assert_eq!(history.timeline().len(), 4);+ let checkpoint = history.checkpoint();+ let mut restored = History::<Sheets>::from_checkpoint(checkpoint).unwrap();+ let target = restored.state_by_id(current.clone(), alternate).unwrap().load_document().unwrap();+ assert_eq!(target.cells[0], 2);+ restored.jump_loaded(current, alternate, target).unwrap();+ assert_eq!(restored.current_id(), alternate);+ assert_eq!(restored.current_target(), &vec![2]);+ assert_eq!(restored.timeline().len(), 4);+}+#[test]+fn cold_navigation_reads_only_destination_and_menus_read_nothing() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ for value in 1..80 {+ edit(&mut history, &mut current, value);+ }+ let reads = Arc::new(AtomicUsize::new(0));+ for state in history.checkpoint().undo {+ let document = state.load_document().unwrap();+ history.replace_resident(&document, Arc::new(Archive { document: document.clone(), reads: reads.clone(), fail: false }));+ }+ let _ = history.timeline();+ let _ = history.checkpoint();+ assert!(history.has_past_selection());+ assert!(history.latest_selection_state().is_some());+ assert_eq!(reads.load(Ordering::Relaxed), 0);+ let target = history.state_by_id(current.clone(), 0).unwrap().load_document().unwrap();+ history.jump_loaded(current, 0, target).unwrap();+ assert_eq!(reads.load(Ordering::Relaxed), 1);+ assert_eq!(history.archived_states(), 78);+}+#[test]+fn failed_undo_and_redo_leave_navigation_unchanged() {+ for redo in [false, true] {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ if redo {+ current = history.try_undo(current).unwrap().unwrap();+ }+ let target = history.state_by_id(current.clone(), history.adjacent_id(redo).unwrap()).unwrap().load_document().unwrap();+ history.replace_resident(&target, Arc::new(Archive { document: target.clone(), reads: Arc::new(AtomicUsize::new(0)), fail: true }));+ let before = history.timeline_stamp();+ let checkpoint = history.checkpoint();+ let result = if redo { history.try_redo(current) } else { history.try_undo(current) };+ assert!(result.is_err());+ assert_eq!(history.timeline_stamp(), before);+ assert_eq!(history.checkpoint().current_label, checkpoint.current_label);+ }+}+#[test]+fn shared_allocations_are_counted_once_and_targets_count_as_metadata() {+ let current = sheet(0);+ let mut history = History::<Sheets>::default();+ history.record("First", current.clone(), vec![3, 4]);+ history.record("Second", Arc::new((*current).clone()), vec![5]);+ assert_eq!(history.pixel_bytes(¤t), 32);+ assert_eq!(history.unique_bytes(¤t), 0);+ assert!(history.metadata_bytes() > 0);+ assert_eq!(history.spillable_states().len(), 2);+}+#[test]+fn cold_identity_remap_is_lazy_and_preserves_original() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ let original = history.try_state(0).unwrap().unwrap();+ let reads = Arc::new(AtomicUsize::new(0));+ history.replace_resident(&original, Arc::new(Archive { document: original.clone(), reads: reads.clone(), fail: false }));+ history.remap_document_id(99);+ assert_eq!(reads.load(Ordering::Relaxed), 0);+ assert_eq!(history.try_state(0).unwrap().unwrap().id, 99);+ assert_eq!(original.id, 1);+}+#[test]+fn unlimited_default_and_explicit_limits_preserve_current() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ for value in 1..120 {+ edit(&mut history, &mut current, value);+ }+ assert_eq!(history.past_len(), 119);+ let id = history.current_id();+ history.max_states = 2;+ assert_eq!(history.enforce_state_limit(), 117);+ assert_eq!(history.past_len(), 2);+ assert_eq!(history.current_id(), id);+ history.max_bytes = 1;+ assert_eq!(history.trim(¤t), 1);+ assert!(history.can_undo());+}+#[test]+fn recovery_rejects_duplicate_and_invalid_identity_and_oversized_labels() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ let good = history.checkpoint();+ let mut bad = good.clone();+ bad.undo[0].identity = bad.current_identity;+ assert!(History::<Sheets>::from_checkpoint(bad).is_err());+ let mut bad = good.clone();+ bad.current_identity.parent = Some(bad.current_identity.id);+ assert!(History::<Sheets>::from_checkpoint(bad).is_err());+ let mut bad = good.clone();+ bad.current_identity.id = u64::MAX;+ assert!(History::<Sheets>::from_checkpoint(bad).is_err());+ let mut bad = good;+ bad.current_label = "X".repeat(4097);+ assert!(History::<Sheets>::from_checkpoint(bad).is_err());+}+#[test]+fn legacy_checkpoint_gets_a_valid_linear_identity_path() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ edit(&mut history, &mut current, 2);+ let mut checkpoint = history.checkpoint();+ checkpoint.current_identity = StateIdentity::default();+ for state in &mut checkpoint.undo {+ state.identity = StateIdentity::default();+ }+ let recovered = History::<Sheets>::from_checkpoint(checkpoint).unwrap();+ assert_eq!(recovered.timeline().iter().map(|entry| entry.identity.id).collect::<Vec<_>>(), vec![0, 1, 2]);+}+#[test]+fn stale_spill_result_does_not_replace_different_snapshot() {+ let mut history = History::<Sheets>::default();+ let mut current = sheet(0);+ edit(&mut history, &mut current, 1);+ let other = sheet(0);+ assert!(!history.replace_resident(&other, Arc::new(Archive { document: other.clone(), reads: Arc::new(AtomicUsize::new(0)), fail: true })));+ assert!(history.try_undo(current).unwrap().is_some());+}+#[derive(Clone, Debug)]+struct Slides;+impl Policy for Slides {+ type Document = String;+ type Target = (usize, usize);+ type Id = ();+ fn has_selection(_document: &String) -> bool {+ false+ }+ fn remap_id(_document: &mut Arc<String>, _id: ()) {}+ fn document_bytes(document: &String, seen: &mut HashSet<usize>) -> usize {+ if seen.insert(document.as_ptr() as usize) { document.capacity() } else { 0 }+ }+}+#[test]+fn independent_text_document_and_slide_target_use_same_engine() {+ let mut history = History::<Slides>::default();+ history.set_current_target((0, 0));+ history.record("Slide title", Arc::new("Original".to_owned()), (2, 3));+ let current = Arc::new("Updated".to_owned());+ let restored = history.try_undo(current).unwrap().unwrap();+ assert_eq!(&*restored, "Original");+ assert_eq!(history.current_target(), &(0, 0));+ let updated = history.try_redo(restored).unwrap().unwrap();+ assert_eq!(&*updated, "Updated");+ assert_eq!(history.current_target(), &(2, 3));+ assert!(!history.has_past_selection());+ let state = HistoryState::<Slides>::from_document("Preview", updated);+ assert!(!state.has_selection());+}++#[test]+fn mixed_edits_branches_jumps_and_recovery_preserve_every_revision() {+ let mut history = History::<Slides>::default();+ let mut current = Arc::new("Root".to_owned());+ let mut expected = std::collections::BTreeMap::from([(0, current.clone())]);+ let mut random = 7u64;+ for step in 0..256 {+ random = random.wrapping_mul(6364136223846793005).wrapping_add(1);+ match random % 4 {+ 0 => {+ let before = current.clone();+ current = Arc::new(format!("Revision {step}"));+ history.record(format!("Edit {step}"), before, (step, 0));+ expected.insert(history.current_id(), current.clone());+ }+ 1 | 2 => {+ let redo = random % 4 == 2;+ let destination = history.adjacent_id(redo);+ let restored = if redo { history.try_redo(current.clone()) } else { history.try_undo(current.clone()) }.unwrap();+ if let Some(restored) = restored {+ current = restored;+ assert_eq!(history.current_id(), destination.unwrap());+ } else {+ assert!(destination.is_none());+ }+ }+ _ => {+ let index = (random as usize) % expected.len();+ let id = *expected.keys().nth(index).unwrap();+ let restored = history.state_by_id(current.clone(), id).unwrap().load_document().unwrap();+ history.jump_loaded(current, id, restored.clone()).unwrap();+ current = restored;+ }+ }+ assert_eq!(¤t, expected.get(&history.current_id()).unwrap());+ assert_eq!(history.timeline().len(), expected.len());+ if step % 7 == 0 {+ history = History::<Slides>::from_checkpoint(history.checkpoint()).unwrap();+ }+ for (&id, original) in &expected {+ let restored = history.state_by_id(current.clone(), id).unwrap().load_document().unwrap();+ assert_eq!(&restored, original);+ }+ }+}++#[test]+fn pruning_one_alternate_path_preserves_other_branch_and_root() {+ let mut history = History::<Slides>::default();+ let root = Arc::new("Root".to_owned());+ history.record("A", root.clone(), (0, 0));+ let a = Arc::new("A".to_owned());+ history.record("B", a.clone(), (0, 0));+ let b = Arc::new("B".to_owned());+ history.record("C", b, (0, 0));+ let c = Arc::new("C".to_owned());+ history.jump_loaded(c, 1, a.clone()).unwrap();+ history.record("D", a, (0, 0));+ history.prune_descendants(2);+ assert_eq!(history.timeline().iter().map(|entry| entry.identity.id).collect::<Vec<_>>(), vec![0, 1, 4]);+ assert_eq!(history.current_id(), 4);+}++#[test]+fn text_adapter_deduplicates_current_payload_shared_with_history() {+ let current = Arc::new("Title".to_owned());+ let mut history = History::<Slides>::default();+ history.record("Shared payload", current.clone(), (0, 0));+ assert_eq!(history.pixel_bytes(¤t), current.capacity());+ assert_eq!(history.unique_bytes(¤t), 0);+}