1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use ashlar_command::{CommandError, CommandSpec, Host, Journal};
13use ashlar_store::{Format, FormatError, Revision, Store};
14use ashlar_ui::toasts::ToastLevel;
15use ashlar_ui::tokens::ThemeMode;
16use serde::Serialize;
17use serde::de::DeserializeOwned;
18use serde_json::{Map, Value};
19
20use crate::telemetry::Telemetry;
21
22pub trait Document: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static {}
25impl<T: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static> Document for T {}
26
27#[derive(Clone, Debug, PartialEq)]
30pub enum Effect {
31 Toast {
32 level: ToastLevel,
33 text: String,
34 },
35 OpenPalette,
36 OpenKeymap,
37 Theme(ThemeMode),
38 PickOpen,
40 PickSaveAs,
42 ConfirmDiscard {
45 command: String,
46 params: Value,
47 },
48 Quit {
50 discard: bool,
51 },
52}
53
54const RAN_CAP: usize = 64;
56
57pub(crate) fn now() -> Option<std::time::Instant> {
58 #[cfg(not(target_arch = "wasm32"))]
59 {
60 Some(std::time::Instant::now())
61 }
62 #[cfg(target_arch = "wasm32")]
63 {
64 None
65 }
66}
67
68struct Work<D> {
69 doc: Arc<D>,
70 replaced: bool,
72 merge: Option<Option<String>>,
74 effects: usize,
75 view: Map<String, Value>,
76 view_gen: u64,
77 started: Option<std::time::Instant>,
78}
79
80pub struct Session<D: Document> {
83 store: Store<D>,
84 journal: Journal,
85 ran: Vec<&'static str>,
88 format: Arc<Format<D>>,
89 path: Option<PathBuf>,
90 migrated_from: Option<u64>,
92 work: Option<Work<D>>,
93 view: Map<String, Value>,
94 view_gen: u64,
95 file_gen: u64,
96 effects: Vec<Effect>,
97 windowed: bool,
98 pub(crate) telemetry: Telemetry,
99 source: Option<Origin>,
101 last_change: Option<(Revision, Origin)>,
103}
104
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
110pub enum Origin {
111 User,
113 Drag,
115 Undo,
117 Agent,
119 Job,
121}
122
123impl Origin {
124 pub fn animates(self) -> bool {
126 matches!(self, Origin::Undo | Origin::Agent | Origin::Job)
127 }
128}
129
130impl<D: Document> Session<D> {
131 pub fn new(format: Arc<Format<D>>) -> Session<D> {
133 Session {
134 store: Store::new(D::default()),
135 journal: Journal::new(),
136 ran: Vec::new(),
137 format,
138 path: None,
139 migrated_from: None,
140 work: None,
141 view: Map::new(),
142 view_gen: 0,
143 file_gen: 0,
144 effects: Vec::new(),
145 windowed: false,
146 telemetry: Telemetry::default(),
147 source: None,
148 last_change: None,
149 }
150 }
151
152 pub(crate) fn set_windowed(&mut self, yes: bool) {
153 self.windowed = yes;
154 }
155
156 pub fn is_windowed(&self) -> bool {
158 self.windowed
159 }
160
161 pub fn doc(&self) -> &D {
165 match &self.work {
166 Some(w) => &w.doc,
167 None => self.store.doc(),
168 }
169 }
170
171 pub fn snapshot(&self) -> Arc<D> {
173 match &self.work {
174 Some(w) => Arc::clone(&w.doc),
175 None => self.store.snapshot(),
176 }
177 }
178
179 pub fn store(&self) -> &Store<D> {
182 &self.store
183 }
184
185 pub fn store_mut(&mut self) -> &mut Store<D> {
186 if let Some(w) = &mut self.work {
187 w.replaced = true;
188 }
189 &mut self.store
190 }
191
192 pub fn revision(&self) -> Revision {
193 self.store.revision()
194 }
195
196 pub fn is_dirty(&self) -> bool {
197 self.store.is_dirty()
198 }
199
200 pub fn format(&self) -> &Format<D> {
201 &self.format
202 }
203
204 pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
207 self.edit_with(label, None, f)
208 }
209
210 pub fn edit_merged<R>(&mut self, label: &str, merge_key: &str, f: impl FnOnce(&mut D) -> R) -> R {
213 self.edit_with(label, Some(merge_key), f)
214 }
215
216 pub fn with_origin<R>(&mut self, origin: Origin, f: impl FnOnce(&mut Self) -> R) -> R {
219 let outer = self.source.replace(origin);
220 let r = f(self);
221 self.source = outer;
222 r
223 }
224
225 pub fn origin(&self) -> Option<Origin> {
228 self.last_change.filter(|(rev, _)| *rev == self.store.revision()).map(|(_, o)| o)
229 }
230
231 pub(crate) fn swap_source(&mut self, origin: Option<Origin>) -> Option<Origin> {
233 std::mem::replace(&mut self.source, origin)
234 }
235
236 fn mark(&mut self, before: Revision, natural: Origin) {
238 let now = self.store.revision();
239 if now != before {
240 self.last_change = Some((now, self.source.unwrap_or(natural)));
241 }
242 }
243
244 pub fn try_edit<R, E>(&mut self, label: &str, f: impl FnOnce(&mut D) -> Result<R, E>) -> Result<R, E> {
246 match &mut self.work {
247 Some(w) => {
248 let mut copy = Arc::clone(&w.doc);
249 let r = f(Arc::make_mut(&mut copy))?;
250 w.doc = copy;
251 note_merge(w, None);
252 Ok(r)
253 }
254 None => {
255 let before = self.store.revision();
256 let r = self.store.try_edit(label, f);
257 self.mark(before, Origin::User);
258 r
259 }
260 }
261 }
262
263 fn edit_with<R>(&mut self, label: &str, merge_key: Option<&str>, f: impl FnOnce(&mut D) -> R) -> R {
264 match &mut self.work {
265 Some(w) => {
266 let r = f(Arc::make_mut(&mut w.doc));
267 note_merge(w, merge_key);
268 r
269 }
270 None => {
271 let before = self.store.revision();
272 let (r, natural) = match merge_key {
273 Some(k) => (self.store.edit_merged(label, k, f), Origin::Drag),
274 None => (self.store.edit(label, f), Origin::User),
275 };
276 self.mark(before, natural);
277 r
278 }
279 }
280 }
281
282 pub fn can_undo(&self) -> bool {
283 self.store.can_undo()
284 }
285
286 pub fn can_redo(&self) -> bool {
287 self.store.can_redo()
288 }
289
290 pub fn undo(&mut self) -> Option<String> {
292 let before = self.store.revision();
293 let r = self.store_mut().undo();
294 self.mark(before, Origin::Undo);
295 self.sync_work();
296 r
297 }
298
299 pub fn redo(&mut self) -> Option<String> {
301 let before = self.store.revision();
302 let r = self.store_mut().redo();
303 self.mark(before, Origin::Undo);
304 self.sync_work();
305 r
306 }
307
308 fn sync_work(&mut self) {
310 if let Some(w) = &mut self.work {
311 w.doc = self.store.snapshot();
312 }
313 }
314
315 pub fn path(&self) -> Option<&Path> {
319 self.path.as_deref()
320 }
321
322 pub fn display_name(&self) -> String {
324 self.path.as_deref().and_then(Path::file_name).map_or_else(|| "Untitled".to_string(), |n| n.to_string_lossy().into_owned())
325 }
326
327 pub fn migrated_from(&self) -> Option<u64> {
329 self.migrated_from
330 }
331
332 pub fn file_generation(&self) -> u64 {
334 self.file_gen
335 }
336
337 pub fn new_document(&mut self) {
339 self.replace(D::default(), None, None);
340 }
341
342 pub fn open(&mut self, path: &Path) -> Result<(), FormatError> {
344 let loaded = self.format.load(path)?;
345 let current = self.format.schema_version();
346 let migrated = loaded.migrated(current).then_some(loaded.envelope.schema_version);
347 self.replace(loaded.doc, Some(path.to_path_buf()), migrated);
348 Ok(())
349 }
350
351 pub fn replace(&mut self, doc: D, path: Option<PathBuf>, migrated_from: Option<u64>) {
353 self.store_mut().replace(doc, ());
354 self.sync_work();
355 self.path = path;
356 self.migrated_from = migrated_from;
357 self.file_gen = self.file_gen.wrapping_add(1);
358 }
359
360 pub fn replace_as_edit(&mut self, label: &str, doc: D) {
363 let doc = Arc::new(doc);
364 match &mut self.work {
365 Some(w) => {
366 w.doc = doc;
367 note_merge(w, None);
368 }
369 None => {
370 let before = self.store.revision();
371 self.store.commit(label, None, doc);
372 self.mark(before, Origin::User);
373 }
374 }
375 }
376
377 pub fn save(&mut self) -> Result<PathBuf, FormatError> {
379 let Some(path) = self.path.clone() else {
380 return Err(FormatError::Io(std::io::Error::other("the document has no file yet; use Save As")));
381 };
382 self.save_as(&path)?;
383 Ok(path)
384 }
385
386 pub fn save_as(&mut self, path: &Path) -> Result<(), FormatError> {
388 let snap = self.store.snapshot();
389 self.format.save(path, &snap)?;
390 self.store.mark_saved();
391 if self.path.as_deref() != Some(path) {
392 self.path = Some(path.to_path_buf());
393 self.file_gen = self.file_gen.wrapping_add(1);
394 }
395 self.migrated_from = None;
396 self.telemetry.saved();
397 Ok(())
398 }
399
400 pub fn view_state(&self) -> &Map<String, Value> {
404 &self.view
405 }
406
407 pub fn view<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
408 self.view.get(key).and_then(|v| T::deserialize(v).ok())
409 }
410
411 pub fn view_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
412 self.view(key).unwrap_or(default)
413 }
414
415 pub fn set_view<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) {
416 let Ok(v) = serde_json::to_value(value) else { return };
417 self.set_view_value(key, v);
418 }
419
420 pub fn set_view_value(&mut self, key: &str, value: Value) {
421 if self.view.get(key) != Some(&value) {
422 self.view.insert(key.to_string(), value);
423 self.view_gen = self.view_gen.wrapping_add(1);
424 }
425 }
426
427 pub fn view_generation(&self) -> u64 {
429 self.view_gen
430 }
431
432 pub fn request(&mut self, effect: Effect) {
436 self.effects.push(effect);
437 }
438
439 pub fn toast(&mut self, level: ToastLevel, text: impl Into<String>) {
441 self.effects.push(Effect::Toast { level, text: text.into() });
442 }
443
444 pub(crate) fn take_ran(&mut self) -> Vec<&'static str> {
446 std::mem::take(&mut self.ran)
447 }
448
449 pub(crate) fn take_effects(&mut self) -> Vec<Effect> {
450 std::mem::take(&mut self.effects)
451 }
452
453 pub(crate) fn drain_headless(&mut self) {
455 for e in self.effects.drain(..) {
456 if let Effect::Toast { level, text } = e {
457 match level {
458 ToastLevel::Error | ToastLevel::Warning => log::warn!("{text}"),
459 _ => log::info!("{text}"),
460 }
461 }
462 }
463 }
464
465 fn begin(&mut self) {
468 self.work = Some(Work {
469 doc: self.store.snapshot(),
470 replaced: false,
471 merge: None,
472 effects: self.effects.len(),
473 view: self.view.clone(),
474 view_gen: self.view_gen,
475 started: now(),
476 });
477 }
478
479 fn end(&mut self, label: &str, ok: bool) -> Option<std::time::Duration> {
481 let w = self.work.take()?;
482 let took = w.started.map(|s| s.elapsed());
483 if ok {
484 if !w.replaced {
485 let merge = w.merge.flatten();
486 let natural = if merge.is_some() { Origin::Drag } else { Origin::User };
487 let before = self.store.revision();
488 self.store.commit(label.trim_end_matches('…'), merge.as_deref(), w.doc);
489 self.mark(before, natural);
490 }
491 } else {
492 self.effects.truncate(w.effects);
493 if self.view_gen != w.view_gen {
494 self.view = w.view;
495 self.view_gen = self.view_gen.wrapping_add(1);
496 }
497 }
498 took
499 }
500}
501
502fn note_merge<D>(w: &mut Work<D>, key: Option<&str>) {
503 let key = key.map(str::to_string);
504 w.merge = match w.merge.take() {
505 None => Some(key),
506 Some(prev) if prev == key => Some(prev),
507 Some(_) => Some(None),
508 };
509}
510
511impl<D: Document> Host for Session<D> {
512 type Error = CommandError;
513
514 fn journal(&mut self) -> &mut Journal {
515 &mut self.journal
516 }
517
518 fn before(&mut self, _spec: &CommandSpec<Self>, _params: &mut Value, depth: usize) -> Result<(), CommandError> {
519 if depth == 0 {
520 self.begin();
521 }
522 Ok(())
523 }
524
525 fn after(&mut self, spec: &CommandSpec<Self>, _params: &Value, result: &Result<Value, CommandError>, depth: usize) {
526 if depth != 0 {
527 return;
528 }
529 let took = self.end(spec.label, result.is_ok());
530 if self.ran.len() < RAN_CAP {
531 self.ran.push(spec.id);
532 }
533 self.telemetry.command(spec.id, result.is_ok(), took);
534 match result {
535 Ok(_) => log::debug!("command {} ok", spec.id),
536 Err(e) => log::info!("command {} failed: {e}", spec.id),
537 }
538 }
539
540 fn rejected(&mut self, id: &str, error: &CommandError, depth: usize) {
541 if depth == 0 {
542 self.end("", false);
543 log::debug!("command {id} refused: {error}");
544 }
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use ashlar_command::{Param, Registry, always, cmd};
552 use serde::Deserialize;
553 use serde_json::json;
554
555 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
556 struct Doc {
557 items: Vec<String>,
558 }
559
560 type S = Session<Doc>;
561
562 fn add_two(s: &mut S, _: &Value) -> Result<Value, CommandError> {
563 s.edit("a", |d| d.items.push("one".into()));
564 s.edit("b", |d| d.items.push("two".into()));
565 Ok(Value::Null)
566 }
567
568 fn add_then_fail(s: &mut S, _: &Value) -> Result<Value, CommandError> {
569 s.edit("a", |d| d.items.push("half".into()));
570 s.set_view("selected", &3);
571 s.toast(ToastLevel::Info, "never shown");
572 Err(CommandError::failed("", "nope"))
573 }
574
575 fn nested(s: &mut S, _: &Value) -> Result<Value, CommandError> {
576 s.edit("x", |d| d.items.push("outer".into()));
577 registry().execute(s, "add_two", json!({}))
578 }
579
580 fn panics(s: &mut S, _: &Value) -> Result<Value, CommandError> {
581 s.edit("x", |d| d.items.push("lost".into()));
582 Err(CommandError::Panicked { id: String::new() })
583 }
584
585 fn drag(s: &mut S, p: &Value) -> Result<Value, CommandError> {
586 let v = p.get("v").and_then(Value::as_str).unwrap_or_default().to_string();
587 s.edit_merged("Drag", "drag-1", |d| d.items = vec![v]);
588 Ok(Value::Null)
589 }
590
591 fn registry() -> Registry<S> {
592 Registry::new(vec![
593 cmd!("add_two", "Add Two", [], None, [], always, add_two),
594 cmd!("add_then_fail", "Add Then Fail", [], None, [], always, add_then_fail),
595 cmd!("nested", "Nested", [], None, [], always, nested),
596 cmd!("panics", "Panics", [], None, [], always, panics),
597 cmd!("drag", "Drag", [], None, [Param::string("v")], always, drag),
598 ])
599 }
600
601 fn session() -> S {
602 Session::new(Arc::new(Format::new("test.doc", "test")))
603 }
604
605 #[test]
606 fn a_command_with_several_edits_is_one_undo_step_named_after_it() {
607 let (r, mut s) = (registry(), session());
608 r.execute(&mut s, "add_two", json!({})).unwrap();
609 assert_eq!(s.doc().items, ["one", "two"]);
610 assert_eq!(s.store().history().depth(), 1);
611 assert_eq!(s.store().history().undo_label(), Some("Add Two"));
612 assert!(s.is_dirty());
613 s.undo();
614 assert!(s.doc().items.is_empty());
615 assert!(!s.is_dirty());
616 }
617
618 #[test]
619 fn nested_commands_join_the_top_level_step() {
620 let (r, mut s) = (registry(), session());
621 r.execute(&mut s, "nested", json!({})).unwrap();
622 assert_eq!(s.doc().items, ["outer", "one", "two"]);
623 assert_eq!(s.store().history().depth(), 1);
624 assert_eq!(s.store().history().undo_label(), Some("Nested"));
625 }
626
627 #[test]
628 fn a_failed_command_changes_nothing() {
629 let (r, mut s) = (registry(), session());
630 r.execute(&mut s, "add_two", json!({})).unwrap();
631 s.undo(); let rev = s.revision();
633 assert!(r.execute(&mut s, "add_then_fail", json!({})).is_err());
634 assert!(r.execute(&mut s, "panics", json!({})).is_err());
635 assert!(r.execute(&mut s, "drag", json!({"bogus": 1})).is_err(), "bad params are refused");
636 assert_eq!(s.revision(), rev);
637 assert!(s.doc().items.is_empty());
638 assert!(s.can_redo(), "redo line kept");
639 assert_eq!(s.view::<i32>("selected"), None, "view state restored");
640 assert!(s.take_effects().is_empty(), "effects of the failed command dropped");
641 assert_eq!(s.store().history().depth(), 0);
642 }
643
644 #[test]
645 fn repeated_commands_with_one_merge_key_merge() {
646 let (r, mut s) = (registry(), session());
647 for v in ["a", "b", "c"] {
648 r.execute(&mut s, "drag", json!({"v": v})).unwrap();
649 }
650 assert_eq!(s.doc().items, ["c"]);
651 assert_eq!(s.store().history().depth(), 1);
652 }
653
654 #[test]
655 fn edits_outside_commands_are_steps_of_their_own() {
656 let mut s = session();
657 s.edit("One", |d| d.items.push("1".into()));
658 s.edit("Two", |d| d.items.push("2".into()));
659 assert_eq!(s.store().history().depth(), 2);
660 }
661
662 #[test]
663 fn save_open_round_trip() {
664 let dir = tempfile::tempdir().unwrap();
665 let p = dir.path().join("a.doc");
666 let (r, mut s) = (registry(), session());
667 r.execute(&mut s, "add_two", json!({})).unwrap();
668 s.save_as(&p).unwrap();
669 assert!(!s.is_dirty());
670 assert_eq!(s.display_name(), "a.doc");
671 let mut t = session();
672 t.open(&p).unwrap();
673 assert_eq!(t.doc().items, ["one", "two"]);
674 assert!(!t.can_undo());
675 }
676}