1pub(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
35pub(crate) const EDIT_TYPE: &str = "tilo.app.history-edit";
37pub(crate) const VIEW_TYPE: &str = "tilo.app.history";
39
40const MAX_STEPS: i64 = 50;
42const MAX_LISTED: i64 = 500;
44
45#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
54 by: Option<String>,
55 #[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 fn change_kind(&self) -> ChangeKind {
82 match self {
83 Self::Checkpoint { .. } => ChangeKind::Housekeeping,
84 _ => ChangeKind::Content,
85 }
86 }
87
88 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 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
129pub(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#[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#[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 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
275pub(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
418pub(crate) fn is_edit(action: &str) -> bool {
420 matches!(
421 action,
422 "undo" | "redo" | "restore" | "restore-reviewed" | "checkpoint"
423 )
424}
425
426pub 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
442fn 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
475fn 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
485pub(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#[derive(Clone, Debug, Default, PartialEq, Eq)]
519pub struct HistoryVersion {
520 pub change: String,
522 pub action: String,
524 pub entry: Option<tilo_actions::ActionContext>,
526 pub author: String,
528 pub run: Option<String>,
531 pub time_ms: i64,
533 pub undone: bool,
535 pub checkpoints: Vec<String>,
537 pub housekeeping: bool,
540}
541
542const AGENT_RUN: &str = "agent-run";
544
545pub(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#[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 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 #[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
642fn 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
680pub(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
705pub(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
740fn 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
764fn 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
793pub(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 #[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, ®istry, 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, ®istry, 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
887fn 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
903fn 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
925fn 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}