Skip to main content

tilo_app/framework/
history.rs

1//! The resource's history as actions every framework app answers, so the
2//! people, scripts and agents working on it can look back, compare and
3//! take back through the same door they edit through:
4//!
5//! - `undo` / `redo`: the caller's own last steps (an agent's are those of
6//!   its run), or a named agent run's;
7//! - `restore`: a past version, whole or only some items, as a new step;
8//! - `review-restore` / `restore-reviewed`: pin a causal review and restore
9//!   exact differing paths only if the live frontier still matches;
10//! - `checkpoint`: name the current version;
11//! - `history`: every step with what it was given, who made it, in which
12//!   run, and which are undone or checkpointed;
13//! - `diff`: the items that differ between two versions.
14//!
15//! Versions are change ids from `history` or checkpoint names. The verbs
16//! are [`ui_core::history_source::steps`]; an app with a richer comparison
17//! of its own (a picture of what changed) publishes its own `diff`, which
18//! takes the place of this one.
19
20pub(crate) mod review;
21
22use std::collections::HashSet;
23use ui_core::automerge::transaction::Transactable;
24
25use serde::{Deserialize, Serialize};
26use serde_json::{Value, json};
27use tilo_actions::{
28    ActionArgs, ActionError, ActionKind, ActionOutput, ActionParam, ActionSpec, ChangeKind,
29    EncodedAction, ExecutionLocus, OperationKind, ParamType, ResultSpec,
30};
31use tilo_resource::history::Version;
32use ui_core::automerge::{AutoCommit, ChangeHash, Patch, PatchAction, Prop};
33use ui_core::history_source::steps::{self, Checkpoint, Scope};
34
35/// The registry type of the history verbs that change the document.
36pub(crate) const EDIT_TYPE: &str = "tilo.app.history-edit";
37/// The operation type of the history views.
38pub(crate) const VIEW_TYPE: &str = "tilo.app.history";
39
40/// The most steps one undo or redo takes.
41const MAX_STEPS: i64 = 50;
42/// The most versions `history` lists.
43const MAX_LISTED: i64 = 500;
44
45/// A history verb that writes the document.
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "kebab-case")]
48pub(crate) enum HistoryEdit {
49    Undo {
50        steps: Option<u32>,
51        run: Option<String>,
52        /// An exact author id from history for steps outside a run.
53        #[serde(default, skip_serializing_if = "Option::is_none")]
54        by: Option<String>,
55        /// Every step after this version instead.
56        #[serde(default, skip_serializing_if = "Option::is_none")]
57        to: Option<String>,
58    },
59    Redo {
60        steps: Option<u32>,
61    },
62    Restore {
63        version: String,
64        ids: Option<Vec<String>>,
65    },
66    RestoreReviewed {
67        review: review::RestoreReview,
68    },
69    Checkpoint {
70        name: String,
71    },
72}
73
74fn limit(steps: Option<u32>, default: usize) -> usize {
75    steps.map_or(default, |steps| usize::try_from(steps).unwrap_or(1))
76}
77
78impl HistoryEdit {
79    /// A checkpoint is housekeeping; any other move through history is a
80    /// step of its own.
81    fn change_kind(&self) -> ChangeKind {
82        match self {
83            Self::Checkpoint { .. } => ChangeKind::Housekeeping,
84            _ => ChangeKind::Content,
85        }
86    }
87
88    /// What history records this step through history did: for an undo of
89    /// one step, the step it takes back.
90    fn described(&self, doc: &tilo_actions::Doc) -> tilo_actions::Described {
91        use tilo_actions::HistoryOp;
92        let (op, steps) = match self {
93            Self::Undo {
94                steps,
95                run: None,
96                to: None,
97                ..
98            } => (HistoryOp::Undo, steps.unwrap_or(1)),
99            Self::Undo { steps, .. } => (HistoryOp::Undo, steps.unwrap_or(0)),
100            Self::Redo { steps } => (HistoryOp::Redo, steps.unwrap_or(1)),
101            Self::Restore { .. } | Self::RestoreReviewed { .. } => (HistoryOp::Restore, 0),
102            Self::Checkpoint { name } => (HistoryOp::Checkpoint { name: name.clone() }, 0),
103        };
104        let of = match self {
105            Self::Undo {
106                run: None,
107                to: None,
108                by,
109                ..
110            } if steps == 1 => {
111                let scope = by.as_ref().map_or_else(
112                    || Scope::invoking(doc),
113                    |author| Scope::Invoker(author.clone()),
114                );
115                let taken = steps::undo_preview(doc, &scope, 1);
116                // A gesture's changes share one description; the newest
117                // names the step.
118                taken
119                    .first()
120                    .and_then(|version| version.context.as_ref())
121                    .and_then(tilo_actions::ActionContext::described)
122            }
123            _ => None,
124        };
125        tilo_actions::Described::new(tilo_actions::Did::history(op, steps, of))
126    }
127}
128
129/// The resource's own registry is the only decoder for its recorded
130/// compensation. Wire context never installs a decoder or a caller.
131pub(crate) struct ResourceHistoryEdit<R> {
132    edit: HistoryEdit,
133    resource: std::marker::PhantomData<fn() -> R>,
134}
135
136impl<R> std::fmt::Debug for ResourceHistoryEdit<R> {
137    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        self.edit.fmt(formatter)
139    }
140}
141
142impl<'de, R> Deserialize<'de> for ResourceHistoryEdit<R> {
143    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
144        let value = Value::deserialize(deserializer)?;
145        Ok(Self {
146            edit: history_edit(value).map_err(serde::de::Error::custom)?,
147            resource: std::marker::PhantomData,
148        })
149    }
150}
151
152/// A history edit from its JSON form, decoded by one function for every
153/// resource.
154#[inline(never)]
155fn history_edit(value: Value) -> Result<HistoryEdit, serde_json::Error> {
156    HistoryEdit::deserialize(value)
157}
158
159impl<R: tilo_resource::Resource> tilo_actions::Action for ResourceHistoryEdit<R> {
160    fn change_kind(&self) -> ChangeKind {
161        self.edit.change_kind()
162    }
163
164    fn describe(&self, doc: &tilo_actions::Doc) -> Option<tilo_actions::Described> {
165        Some(self.edit.described(doc))
166    }
167
168    fn apply(&self, doc: &mut tilo_actions::Doc) -> Result<(), ActionError> {
169        self.edit
170            .apply_registered(doc, tilo_resource::group::register::<R>)
171    }
172}
173
174/// A history view.
175#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(tag = "op", rename_all = "kebab-case")]
177enum HistoryView {
178    History { last: Option<u32> },
179    Diff { from: String, to: Option<String> },
180    ReviewRestore { version: String, paths: Vec<String> },
181}
182
183fn invalid(reason: &str) -> EncodedAction {
184    EncodedAction {
185        type_id: tilo_actions::resource::INVALID_ARGUMENTS_TYPE.to_owned(),
186        payload: reason.as_bytes().to_vec(),
187    }
188}
189
190fn steps_arg(args: &ActionArgs) -> Result<Option<u32>, String> {
191    args.integer("steps")
192        .map(|steps| {
193            if (1..=MAX_STEPS).contains(&steps) {
194                u32::try_from(steps).map_err(|error| error.to_string())
195            } else {
196                Err(format!("steps is 1 to {MAX_STEPS}"))
197            }
198        })
199        .transpose()
200}
201
202fn text_arg(args: &ActionArgs, name: &str) -> Result<String, String> {
203    args.text(name)
204        .map(str::trim)
205        .filter(|text| !text.is_empty())
206        .map(str::to_owned)
207        .ok_or_else(|| format!("{name} is required"))
208}
209
210fn edit(
211    id: &'static str,
212    label: &'static str,
213    description: &'static str,
214    params: Vec<ActionParam>,
215    build: fn(&ActionArgs) -> Result<HistoryEdit, String>,
216) -> ActionSpec {
217    let mut spec = ActionSpec::typed(
218        id,
219        label,
220        label,
221        ExecutionLocus::ClientLocal,
222        params,
223        // Decoded by the resource's own history edit, which applies it.
224        move |args| match build(args)
225            .and_then(|edit| serde_json::to_vec(&edit).map_err(|error| error.to_string()))
226        {
227            Ok(payload) => EncodedAction {
228                type_id: EDIT_TYPE.to_owned(),
229                payload,
230            },
231            Err(reason) => invalid(&reason),
232        },
233    )
234    .reversible()
235    .with_description(description);
236    spec.contract.result = ResultSpec::Json {
237        schema: json!({"type": "object"}),
238    };
239    spec
240}
241
242fn view(
243    id: &'static str,
244    label: &'static str,
245    description: &'static str,
246    params: Vec<ActionParam>,
247    build: fn(&ActionArgs) -> HistoryView,
248) -> ActionSpec {
249    let mut spec = ActionSpec::typed_operation(
250        id,
251        label,
252        label,
253        OperationKind::View,
254        VIEW_TYPE,
255        params,
256        move |args| {
257            EncodedAction::encode_operation(VIEW_TYPE, &build(args)).unwrap_or_else(|_| {
258                EncodedAction {
259                    type_id: VIEW_TYPE.into(),
260                    payload: Vec::new(),
261                }
262            })
263        },
264    )
265    .of_kind(ActionKind::Read)
266    .with_description(description);
267    spec.contract.result = ResultSpec::Json {
268        schema: json!({"type": "object"}),
269    };
270    spec
271}
272
273const VERSION: &str = "A change id from history, or a checkpoint name";
274
275/// The history actions every framework app publishes, unless its resource
276/// already publishes one of the same id.
277pub(crate) fn specs() -> Vec<ActionSpec> {
278    let mut specs = edit_specs();
279    specs.extend(view_specs());
280    specs.extend(review::specs());
281    specs
282}
283
284fn edit_specs() -> Vec<ActionSpec> {
285    let steps = |description: &'static str| {
286        ActionParam::optional("steps", ParamType::Integer, description).nullable()
287    };
288    vec![
289        edit(
290            "undo",
291            "Undo",
292            "Take back your last steps (an agent: its run's) as a new change; with run, that agent run's steps; with by, that author's steps outside a run; with to, all after a version.",
293            vec![
294                steps("How many steps, newest first"),
295                ActionParam::optional("run", ParamType::Text, "An agent run id from history")
296                    .nullable(),
297                ActionParam::optional(
298                    "by",
299                    ParamType::Text,
300                    "Exact author id from history, for steps outside a run",
301                )
302                .nullable(),
303                ActionParam::optional("to", ParamType::Text, VERSION).nullable(),
304            ],
305            |args| {
306                let to = args.text("to").map(str::to_owned);
307                if to.is_some() && args.integer("steps").is_some() {
308                    return Err("give steps or to, not both".into());
309                }
310                let by = args.text("by").map(str::trim).map(str::to_owned);
311                if by.as_ref().is_some_and(|author| {
312                    author.is_empty() || author.len() > 512 || author.chars().any(char::is_control)
313                }) {
314                    return Err(
315                        "author id must be nonempty, at most 512 bytes, and contain no controls"
316                            .into(),
317                    );
318                }
319                if by.is_some() && args.text("run").is_some() {
320                    return Err("choose an author or a run, not both".into());
321                }
322                Ok(HistoryEdit::Undo {
323                    by,
324                    steps: steps_arg(args)?,
325                    run: args.text("run").map(str::to_owned),
326                    to,
327                })
328            },
329        ),
330        edit(
331            "redo",
332            "Redo",
333            "Bring back what your last undo took back.",
334            vec![steps("How many undos, newest first")],
335            |args| {
336                Ok(HistoryEdit::Redo {
337                    steps: steps_arg(args)?,
338                })
339            },
340        ),
341        edit(
342            "restore",
343            "Restore version",
344            "Bring the document back to a version as a new change, or only the items named by ids.",
345            vec![
346                ActionParam::required("version", ParamType::Text, VERSION),
347                ActionParam::optional("ids", ParamType::Array, "Only these item ids")
348                    .nullable()
349                    .with_schema(json!({"type": "array", "items": {"type": "string"}})),
350            ],
351            |args| {
352                let ids = args
353                    .array("ids")
354                    .map(|ids| {
355                        ids.iter()
356                            .map(|id| id.as_str().map(str::to_owned))
357                            .collect::<Option<Vec<_>>>()
358                            .ok_or_else(|| "ids are strings".to_owned())
359                    })
360                    .transpose()?;
361                Ok(HistoryEdit::Restore {
362                    version: text_arg(args, "version")?,
363                    ids,
364                })
365            },
366        ),
367        edit(
368            "checkpoint",
369            "Checkpoint",
370            "Name the current version, to restore or compare with later.",
371            vec![ActionParam::required("name", ParamType::Text, "The name")],
372            |args| {
373                Ok(HistoryEdit::Checkpoint {
374                    name: text_arg(args, "name")?,
375                })
376            },
377        ),
378    ]
379}
380
381fn view_specs() -> Vec<ActionSpec> {
382    vec![
383        view(
384            "history",
385            "History",
386            "List the versions, oldest first: change id (what undo to, restore and diff take), action and its arguments, run, author, time, undone, checkpoints.",
387            vec![
388                ActionParam::optional(
389                    "last",
390                    ParamType::Integer,
391                    "How many recent versions (default 50)",
392                )
393                .nullable(),
394            ],
395            |args| HistoryView::History {
396                last: args
397                    .integer("last")
398                    .and_then(|last| u32::try_from(last.clamp(1, MAX_LISTED)).ok()),
399            },
400        ),
401        view(
402            "diff",
403            "Compare versions",
404            "List the items that differ between two versions.",
405            vec![
406                ActionParam::required("from", ParamType::Text, VERSION),
407                ActionParam::optional("to", ParamType::Text, "The later version (default now)")
408                    .nullable(),
409            ],
410            |args| HistoryView::Diff {
411                from: args.text("from").unwrap_or_default().to_owned(),
412                to: args.text("to").map(str::to_owned),
413            },
414        ),
415    ]
416}
417
418/// Whether `action` is a history verb that writes the document.
419pub(crate) fn is_edit(action: &str) -> bool {
420    matches!(
421        action,
422        "undo" | "redo" | "restore" | "restore-reviewed" | "checkpoint"
423    )
424}
425
426/// A version's name in an answer: its change id, or several for a version
427/// with concurrent heads.
428pub fn version_name(heads: &[ChangeHash]) -> String {
429    heads.iter().map(steps::short).collect::<Vec<_>>().join("+")
430}
431
432fn by(version: &Version) -> String {
433    version
434        .context
435        .as_ref()
436        .and_then(|context| context.invoker.as_ref())
437        .map(|invoker| invoker.actor.clone())
438        .or_else(|| ui_core::history_source::authors::actor_handle(&version.actor))
439        .unwrap_or_else(|| version.actor.chars().take(8).collect())
440}
441
442/// What a change's entry records, as data: what was done (`what`, with
443/// field paths, values and verb arguments), to which `item`, by which
444/// `actor` (`id`, `kind`, and the person it acted `for`), in which `run`,
445/// and what kind of action and change it was.
446fn recorded(context: &tilo_actions::ActionContext, out: &mut serde_json::Map<String, Value>) {
447    if let Some(what) = &context.what {
448        out.insert("what".into(), json!(what));
449    }
450    if let Some(item) = &context.item {
451        out.insert("item".into(), json!(item));
452    }
453    if let Some(invoker) = &context.invoker {
454        let mut actor = serde_json::Map::new();
455        actor.insert("id".into(), invoker.actor.clone().into());
456        actor.insert("kind".into(), json!(invoker.kind));
457        if let Some(person) = &invoker.on_behalf_of {
458            actor.insert("for".into(), person.clone().into());
459        }
460        out.insert("actor".into(), Value::Object(actor));
461    }
462    if let Some(group) = &context.group {
463        out.insert("run".into(), group.label.clone().into());
464        out.insert("run_id".into(), group.id.clone().into());
465        out.insert("run_kind".into(), group.kind.clone().into());
466    }
467    if let Some(kind) = context.kind {
468        out.insert("kind".into(), json!(kind));
469    }
470    if !context.change.is_content() {
471        out.insert("change_kind".into(), json!(context.change));
472    }
473}
474
475/// The sentence for a version in this thread's locale.
476fn sentence(version: &Version) -> String {
477    version
478        .context
479        .as_ref()
480        .and_then(ui_core::history_source::sentence::step_summary)
481        .or_else(|| version.message.clone())
482        .unwrap_or_default()
483}
484
485/// One version as `history` lists it.
486pub(crate) fn entry(
487    version: &Version,
488    undone: &HashSet<String>,
489    checkpoints: &[Checkpoint],
490) -> Value {
491    let mut entry = serde_json::Map::new();
492    entry.insert("change".into(), steps::short(&version.hash).into());
493    let context = version.context.as_ref();
494    if let Some(context) = context {
495        recorded(context, &mut entry);
496    }
497    entry.insert("sentence".into(), sentence(version).into());
498    if context.and_then(|context| context.group.as_ref()).is_none() {
499        entry.insert("by".into(), by(version).into());
500    }
501    entry.insert("time_ms".into(), version.time_ms.into());
502    if undone.contains(&version.hash.to_string()) {
503        entry.insert("undone".into(), true.into());
504    }
505    let named: Vec<&str> = checkpoints
506        .iter()
507        .filter(|checkpoint| checkpoint.heads.contains(&version.hash))
508        .map(|checkpoint| checkpoint.name.as_str())
509        .collect();
510    if !named.is_empty() {
511        entry.insert("checkpoint".into(), json!(named));
512    }
513    Value::Object(entry)
514}
515
516/// One version of the resource as an in-app history list shows it: what
517/// was done, by whom, and whether it was taken back or named.
518#[derive(Clone, Debug, Default, PartialEq, Eq)]
519pub struct HistoryVersion {
520    /// The change id: what `restore`, `diff` and a view's `version` take.
521    pub change: String,
522    /// What was done, as a sentence in the reader's language.
523    pub action: String,
524    /// The entry the change recorded: who did it, for whom, and what.
525    pub entry: Option<tilo_actions::ActionContext>,
526    /// Who did it: the person's handle, or the agent run's label.
527    pub author: String,
528    /// The agent run that made it, when an agent did: what `undo` takes as
529    /// `run` to take the whole run back.
530    pub run: Option<String>,
531    /// Milliseconds since the Unix epoch; 0 when unknown.
532    pub time_ms: i64,
533    /// Taken back by an undo.
534    pub undone: bool,
535    /// The names of the checkpoints at this version.
536    pub checkpoints: Vec<String>,
537    /// Housekeeping about the document (naming a checkpoint), not a change
538    /// to what it holds.
539    pub housekeeping: bool,
540}
541
542/// The kind of work unit an agent's run groups its actions under.
543const AGENT_RUN: &str = "agent-run";
544
545/// The last `last` versions, oldest first, as [`HistoryVersion`]s.
546pub(crate) fn recent(doc: &AutoCommit, last: usize) -> Vec<HistoryVersion> {
547    let versions = tilo_resource::history::committed_versions(doc);
548    let undone = steps::undone(doc);
549    let checkpoints = steps::checkpoints(doc);
550    let total = versions.len();
551    versions
552        .iter()
553        .skip(total.saturating_sub(last))
554        .filter(|version| !steps::is_schema_genesis(version))
555        .map(|version| {
556            let context = version.context.as_ref();
557            let run = context
558                .and_then(|context| context.group.as_ref())
559                .filter(|group| group.kind == AGENT_RUN);
560            HistoryVersion {
561                change: steps::short(&version.hash),
562                action: sentence(version),
563                entry: version.context.clone(),
564                author: run.map_or_else(|| by(version), |run| run.label.clone()),
565                run: run.map(|run| run.id.clone()),
566                time_ms: version.time_ms,
567                undone: undone.contains(&version.hash.to_string()),
568                checkpoints: checkpoints
569                    .iter()
570                    .filter(|checkpoint| checkpoint.heads.contains(&version.hash))
571                    .map(|checkpoint| checkpoint.name.clone())
572                    .collect(),
573                housekeeping: context.is_some_and(tilo_actions::ActionContext::is_housekeeping),
574            }
575        })
576        .collect()
577}
578
579/// The history lists views follow ([`ViewCx::history`](super::ViewCx::history)),
580/// brought up to date when the document moves.
581#[derive(Clone, Default)]
582pub(crate) struct HistoryFeeds(std::rc::Rc<std::cell::RefCell<FeedList>>);
583
584type Feed = (
585    std::rc::Weak<()>,
586    usize,
587    ui_core::LocalState<Vec<HistoryVersion>>,
588);
589
590#[derive(Default)]
591struct FeedList {
592    feeds: Vec<Feed>,
593    /// The heads the feeds last showed; empty forces a refresh.
594    heads: Vec<ChangeHash>,
595}
596
597impl HistoryFeeds {
598    pub(crate) fn follow(
599        &self,
600        alive: std::rc::Weak<()>,
601        last: usize,
602    ) -> ui_core::LocalState<Vec<HistoryVersion>> {
603        let state = ui_core::LocalState::new(Vec::new());
604        let mut registry = self.0.borrow_mut();
605        registry.feeds.push((alive, last, state.clone()));
606        registry.heads.clear();
607        state
608    }
609
610    /// Show the versions up to `doc`'s heads, when they moved.
611    #[inline(never)]
612    pub(crate) fn refresh(&self, doc: &AutoCommit) {
613        let mut list = self.0.borrow_mut();
614        list.feeds.retain(|(alive, _, _)| alive.strong_count() > 0);
615        if list.feeds.is_empty() {
616            return;
617        }
618        let heads = doc.committed_heads();
619        if list.heads == heads {
620            return;
621        }
622        let most = list
623            .feeds
624            .iter()
625            .map(|(_, last, _)| *last)
626            .max()
627            .unwrap_or(0);
628        let versions = recent(doc, most);
629        for (_, last, state) in &list.feeds {
630            let shown = versions
631                .get(versions.len().saturating_sub(*last)..)
632                .unwrap_or_default()
633                .to_vec();
634            if state.with(|current| *current != shown) {
635                state.set(shown);
636            }
637        }
638        list.heads = heads;
639    }
640}
641
642/// Leaves out of each version the author and time it shares with the one
643/// before: a run of forty steps names its run once, not forty times.
644fn carry_over(listed: &mut [Value]) {
645    let mut before = serde_json::Map::new();
646    for entry in listed {
647        let Value::Object(fields) = entry else {
648            continue;
649        };
650        let same_author = ["run_id", "by"].iter().any(|key| {
651            fields
652                .get(*key)
653                .is_some_and(|value| before.get(*key) == Some(value))
654        });
655        let author: Vec<&str> = ["run", "run_id", "by"]
656            .into_iter()
657            .filter(|key| fields.contains_key(*key))
658            .collect();
659        let time = fields.get("time_ms").cloned();
660        let shown = std::mem::take(&mut before);
661        for key in &author {
662            if let Some(value) = fields.get(*key) {
663                before.insert((*key).to_owned(), value.clone());
664            }
665        }
666        if same_author {
667            for key in &author {
668                fields.remove(*key);
669            }
670        }
671        if let Some(time) = time {
672            if shown.get("time_ms") == Some(&time) {
673                fields.remove("time_ms");
674            }
675            before.insert("time_ms".into(), time);
676        }
677    }
678}
679
680/// The versions as `history` lists them: the last `last`, oldest first.
681pub(crate) fn history(doc: &AutoCommit, last: usize) -> Value {
682    let versions = tilo_resource::history::committed_versions(doc);
683    let undone = steps::undone(doc);
684    let checkpoints = steps::checkpoints(doc);
685    let total = versions.len();
686    let mut listed: Vec<Value> = versions
687        .iter()
688        .skip(total.saturating_sub(last))
689        .filter(|version| !steps::is_schema_genesis(version))
690        .map(|version| entry(version, &undone, &checkpoints))
691        .collect();
692    carry_over(&mut listed);
693    json!({
694        "versions": listed,
695        "carried": "run, run_id, by and time_ms, where absent, are the version before's",
696        "total": total,
697        "now": version_name(&doc.base_heads()),
698        "checkpoints": checkpoints.iter().map(|checkpoint| json!({
699            "name": checkpoint.name,
700            "version": version_name(&checkpoint.heads),
701        })).collect::<Vec<_>>(),
702    })
703}
704
705/// Answer a history view.
706///
707/// # Errors
708/// Returns a malformed request or an unknown version.
709pub(crate) fn answer(doc: &AutoCommit, payload: &[u8]) -> Result<ActionOutput, String> {
710    let view: HistoryView = tilo_actions::decode_json(payload)
711        .map_err(|error| format!("not a history view: {error}"))?;
712    let value = match view {
713        HistoryView::History { last } => history(doc, limit(last, 50)),
714        HistoryView::ReviewRestore { version, paths } => review::answer(doc, &version, &paths)?,
715        HistoryView::Diff { from, to } => {
716            let from = steps::resolve(doc, &from)?;
717            let to = match to {
718                Some(to) => steps::resolve(doc, &to)?,
719                None => doc.base_heads(),
720            };
721            let items: Vec<Value> = steps::changed_items(&mut doc.clone(), &from, &to)
722                .into_iter()
723                .map(|item| json!({"item": item.path, "change": item.change}))
724                .collect();
725            json!({
726                "from": version_name(&from),
727                "to": version_name(&to),
728                "from_heads": from.iter().map(ToString::to_string).collect::<Vec<_>>(),
729                "to_heads": to.iter().map(ToString::to_string).collect::<Vec<_>>(),
730                "items": items,
731            })
732        }
733    };
734    Ok(ActionOutput {
735        value: Some(value),
736        resources: Vec::new(),
737    })
738}
739
740/// The root keys `patches` put (`true`) or delete under `prefix`, without it.
741fn ledger(patches: &[Patch], prefix: &str, put: bool) -> Vec<String> {
742    patches
743        .iter()
744        .filter(|patch| patch.path.is_empty())
745        .filter_map(|patch| match (&patch.action, put) {
746            (PatchAction::PutMap { key, value, .. }, true)
747                if prefix != steps::UNDONE_KEY
748                    || value.0.as_i64().is_none_or(|attempt| attempt <= 0) =>
749            {
750                key.strip_prefix(prefix).map(str::to_owned)
751            }
752            (PatchAction::PutMap { key, value, .. }, false)
753                if prefix == steps::UNDONE_KEY
754                    && value.0.as_i64().is_some_and(|attempt| attempt > 0) =>
755            {
756                key.strip_prefix(prefix).map(str::to_owned)
757            }
758            (PatchAction::DeleteMap { key }, false) => key.strip_prefix(prefix).map(str::to_owned),
759            _ => None,
760        })
761        .collect()
762}
763
764/// The items `patches` wrote: root keys, or `map/id` entries.
765fn items(patches: &[Patch]) -> Vec<String> {
766    let mut out: Vec<String> = Vec::new();
767    for patch in patches.iter().filter(|patch| !steps::is_bookkeeping(patch)) {
768        let mut keys = patch
769            .path
770            .iter()
771            .filter_map(|(_, prop)| match prop {
772                Prop::Map(key) => Some(key.clone()),
773                Prop::Seq(_) => None,
774            })
775            .chain(match &patch.action {
776                PatchAction::PutMap { key, .. } | PatchAction::DeleteMap { key } => {
777                    Some(key.clone())
778                }
779                _ => None,
780            });
781        let item = match (keys.next(), keys.next()) {
782            (Some(map), Some(id)) => format!("{map}/{id}"),
783            (Some(key), None) => key,
784            (None, _) => continue,
785        };
786        if !out.contains(&item) {
787            out.push(item);
788        }
789    }
790    out
791}
792
793/// What a history verb did, answered to its caller: the steps it took back
794/// or brought back, the items a restore wrote, the checkpoint it named.
795pub(crate) fn result(action: &str, doc: &AutoCommit, patches: &[Patch]) -> Option<Value> {
796    let mut doc = doc.clone();
797    let now = version_name(&doc.base_heads());
798    let described = |hashes: Vec<String>, doc: &mut AutoCommit| -> Vec<Value> {
799        let versions = tilo_resource::history::committed_versions(doc);
800        versions
801            .iter()
802            .rev()
803            .filter(|version| hashes.contains(&version.hash.to_string()))
804            .map(|version| {
805                let mut entry = serde_json::Map::new();
806                entry.insert("change".into(), steps::short(&version.hash).into());
807                if let Some(context) = &version.context {
808                    recorded(context, &mut entry);
809                }
810                entry.insert("sentence".into(), sentence(version).into());
811                Value::Object(entry)
812            })
813            .collect()
814    };
815    let value = match action {
816        "undo" => {
817            json!({"undone": described(ledger(patches, steps::UNDONE_KEY, true), &mut doc), "now": now})
818        }
819        "redo" => {
820            json!({"redone": described(ledger(patches, steps::UNDONE_KEY, false), &mut doc), "now": now})
821        }
822        "restore" | "restore-reviewed" => json!({"restored": items(patches), "now": now}),
823        "checkpoint" => {
824            let name = ledger(patches, steps::CHECKPOINT_KEY, true);
825            let version = steps::checkpoints(&doc)
826                .into_iter()
827                .find(|checkpoint| name.contains(&checkpoint.name))
828                .map(|checkpoint| version_name(&checkpoint.heads));
829            json!({"checkpoint": name.first(), "version": version})
830        }
831        _ => return None,
832    };
833    Some(value)
834}
835
836impl HistoryEdit {
837    /// Apply the edit, decoding recorded actions with the registry
838    /// `register` fills: one compiled copy for every resource.
839    #[inline(never)]
840    fn apply_registered(
841        &self,
842        doc: &mut tilo_actions::Doc,
843        register: fn(&mut tilo_actions::ActionRegistry),
844    ) -> Result<(), ActionError> {
845        if let Self::RestoreReviewed { review } = self {
846            return review.apply(doc).map_err(ActionError::Refused);
847        }
848        let registry = tilo_actions::ActionRegistry::with(register);
849        let mut original = doc.fork();
850        match self {
851            Self::Undo { steps, run, by, to } => {
852                let (scope, default) = match run {
853                    Some(run) => (Scope::Run(run.clone()), usize::MAX),
854                    None => (
855                        by.as_ref().map_or_else(
856                            || Scope::invoking(doc),
857                            |author| Scope::Invoker(author.clone()),
858                        ),
859                        1,
860                    ),
861                };
862                let compensate = |version: &Version| inverse(&mut original, &registry, version);
863                match to {
864                    Some(to) => steps::resolve(doc, to)
865                        .and_then(|heads| steps::undo_since(doc, &scope, &heads, compensate)),
866                    None => steps::undo(doc, &scope, limit(*steps, default), compensate),
867                }
868                .map(drop)
869            }
870            Self::Redo { steps } => {
871                let scope = Scope::invoking(doc);
872                steps::redo(doc, &scope, limit(*steps, 1), |version| {
873                    restoration(&mut original, &registry, version)
874                })
875                .map(drop)
876            }
877            Self::Restore { version, ids } => steps::resolve(doc, version).and_then(|heads| {
878                steps::restore(doc, &heads, ids.as_deref().unwrap_or_default()).map(drop)
879            }),
880            Self::RestoreReviewed { review } => review.apply(doc),
881            Self::Checkpoint { name } => steps::checkpoint(doc, name),
882        }
883        .map_err(ActionError::Refused)
884    }
885}
886
887/// A step's verified inverse, when it recorded one: replaying its action at
888/// its own state reproduces it, so the inverse is the canonical one.
889fn inverse(
890    original: &mut tilo_actions::Doc,
891    registry: &tilo_actions::ActionRegistry,
892    version: &Version,
893) -> Result<Option<steps::Inverse>, String> {
894    let Some(action) =
895        tilo_actions::executor::verified_compensation(original, version.hash, registry)
896            .map_err(|error| error.to_string())?
897    else {
898        return Ok(None);
899    };
900    local(action).map(Some)
901}
902
903/// How a redo brings back an outside step: its verified action, run again.
904fn restoration(
905    original: &mut tilo_actions::Doc,
906    registry: &tilo_actions::ActionRegistry,
907    version: &Version,
908) -> Result<Option<steps::Inverse>, String> {
909    if inverse(original, registry, version)?.is_none() {
910        return Ok(None);
911    }
912    let recorded = version
913        .context
914        .as_ref()
915        .and_then(|context| context.action.as_ref())
916        .ok_or("verified redo action absent")?;
917    local(
918        registry
919            .decode(recorded)
920            .map_err(|error| error.to_string())?,
921    )
922    .map(Some)
923}
924
925/// History applies only actions it may run here.
926fn local(action: steps::Inverse) -> Result<steps::Inverse, String> {
927    if action.locus() == ExecutionLocus::ClientLocal {
928        Ok(action)
929    } else {
930        Err("history compensation requires another execution locus".into())
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use ui_core::automerge::{ROOT, ReadDoc};
938
939    #[test]
940    fn history_and_diff_leave_the_callers_open_edit_uncommitted() {
941        let mut doc = AutoCommit::new();
942        doc.put(ROOT, "saved", 1_i64).unwrap();
943        let first = doc.commit().unwrap();
944        doc.put(ROOT, "saved", 2_i64).unwrap();
945        let second = doc.commit().unwrap();
946        doc.put(ROOT, "draft", true).unwrap();
947        let before = (doc.base_heads(), doc.pending_ops());
948        assert!(before.1 > 0);
949
950        let listed = history(&doc, 50);
951        assert_eq!(listed["total"], json!(2));
952        assert_eq!(listed["now"], json!(version_name(&[second])));
953        assert_eq!((doc.base_heads(), doc.pending_ops()), before);
954
955        let payload = serde_json::to_vec(&HistoryView::Diff {
956            from: first.to_string(),
957            to: Some(second.to_string()),
958        })
959        .unwrap();
960        let diff = answer(&doc, &payload).unwrap().value.unwrap();
961        assert!(
962            diff["items"]
963                .as_array()
964                .unwrap()
965                .iter()
966                .any(|item| item["item"] == "saved")
967        );
968        assert_eq!((doc.base_heads(), doc.pending_ops()), before);
969        assert_eq!(
970            doc.get(ROOT, "draft").unwrap().unwrap().0.as_bool(),
971            Some(true)
972        );
973    }
974}