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
54pub(crate) fn now() -> Option<std::time::Instant> {
55 #[cfg(not(target_arch = "wasm32"))]
56 {
57 Some(std::time::Instant::now())
58 }
59 #[cfg(target_arch = "wasm32")]
60 {
61 None
62 }
63}
64
65struct Work<D> {
66 doc: Arc<D>,
67 replaced: bool,
69 merge: Option<Option<String>>,
71 effects: usize,
72 view: Map<String, Value>,
73 view_gen: u64,
74 started: Option<std::time::Instant>,
75}
76
77pub struct Session<D: Document> {
80 store: Store<D>,
81 journal: Journal,
82 format: Arc<Format<D>>,
83 path: Option<PathBuf>,
84 migrated_from: Option<u64>,
86 work: Option<Work<D>>,
87 view: Map<String, Value>,
88 view_gen: u64,
89 file_gen: u64,
90 effects: Vec<Effect>,
91 windowed: bool,
92 pub(crate) telemetry: Telemetry,
93}
94
95impl<D: Document> Session<D> {
96 pub fn new(format: Arc<Format<D>>) -> Session<D> {
98 Session {
99 store: Store::new(D::default()),
100 journal: Journal::new(),
101 format,
102 path: None,
103 migrated_from: None,
104 work: None,
105 view: Map::new(),
106 view_gen: 0,
107 file_gen: 0,
108 effects: Vec::new(),
109 windowed: false,
110 telemetry: Telemetry::default(),
111 }
112 }
113
114 pub(crate) fn set_windowed(&mut self, yes: bool) {
115 self.windowed = yes;
116 }
117
118 pub fn is_windowed(&self) -> bool {
120 self.windowed
121 }
122
123 pub fn doc(&self) -> &D {
127 match &self.work {
128 Some(w) => &w.doc,
129 None => self.store.doc(),
130 }
131 }
132
133 pub fn snapshot(&self) -> Arc<D> {
135 match &self.work {
136 Some(w) => Arc::clone(&w.doc),
137 None => self.store.snapshot(),
138 }
139 }
140
141 pub fn store(&self) -> &Store<D> {
144 &self.store
145 }
146
147 pub fn store_mut(&mut self) -> &mut Store<D> {
148 if let Some(w) = &mut self.work {
149 w.replaced = true;
150 }
151 &mut self.store
152 }
153
154 pub fn revision(&self) -> Revision {
155 self.store.revision()
156 }
157
158 pub fn is_dirty(&self) -> bool {
159 self.store.is_dirty()
160 }
161
162 pub fn format(&self) -> &Format<D> {
163 &self.format
164 }
165
166 pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
169 self.edit_with(label, None, f)
170 }
171
172 pub fn edit_merged<R>(&mut self, label: &str, merge_key: &str, f: impl FnOnce(&mut D) -> R) -> R {
175 self.edit_with(label, Some(merge_key), f)
176 }
177
178 pub fn try_edit<R, E>(&mut self, label: &str, f: impl FnOnce(&mut D) -> Result<R, E>) -> Result<R, E> {
180 match &mut self.work {
181 Some(w) => {
182 let mut copy = Arc::clone(&w.doc);
183 let r = f(Arc::make_mut(&mut copy))?;
184 w.doc = copy;
185 note_merge(w, None);
186 Ok(r)
187 }
188 None => self.store.try_edit(label, f),
189 }
190 }
191
192 fn edit_with<R>(&mut self, label: &str, merge_key: Option<&str>, f: impl FnOnce(&mut D) -> R) -> R {
193 match &mut self.work {
194 Some(w) => {
195 let r = f(Arc::make_mut(&mut w.doc));
196 note_merge(w, merge_key);
197 r
198 }
199 None => match merge_key {
200 Some(k) => self.store.edit_merged(label, k, f),
201 None => self.store.edit(label, f),
202 },
203 }
204 }
205
206 pub fn can_undo(&self) -> bool {
207 self.store.can_undo()
208 }
209
210 pub fn can_redo(&self) -> bool {
211 self.store.can_redo()
212 }
213
214 pub fn undo(&mut self) -> Option<String> {
216 let r = self.store_mut().undo();
217 self.sync_work();
218 r
219 }
220
221 pub fn redo(&mut self) -> Option<String> {
223 let r = self.store_mut().redo();
224 self.sync_work();
225 r
226 }
227
228 fn sync_work(&mut self) {
230 if let Some(w) = &mut self.work {
231 w.doc = self.store.snapshot();
232 }
233 }
234
235 pub fn path(&self) -> Option<&Path> {
239 self.path.as_deref()
240 }
241
242 pub fn display_name(&self) -> String {
244 self.path.as_deref().and_then(Path::file_name).map_or_else(|| "Untitled".to_string(), |n| n.to_string_lossy().into_owned())
245 }
246
247 pub fn migrated_from(&self) -> Option<u64> {
249 self.migrated_from
250 }
251
252 pub fn file_generation(&self) -> u64 {
254 self.file_gen
255 }
256
257 pub fn new_document(&mut self) {
259 self.replace(D::default(), None, None);
260 }
261
262 pub fn open(&mut self, path: &Path) -> Result<(), FormatError> {
264 let loaded = self.format.load(path)?;
265 let current = self.format.schema_version();
266 let migrated = loaded.migrated(current).then_some(loaded.envelope.schema_version);
267 self.replace(loaded.doc, Some(path.to_path_buf()), migrated);
268 Ok(())
269 }
270
271 pub fn replace(&mut self, doc: D, path: Option<PathBuf>, migrated_from: Option<u64>) {
273 self.store_mut().replace(doc, ());
274 self.sync_work();
275 self.path = path;
276 self.migrated_from = migrated_from;
277 self.file_gen = self.file_gen.wrapping_add(1);
278 }
279
280 pub fn replace_as_edit(&mut self, label: &str, doc: D) {
283 let doc = Arc::new(doc);
284 match &mut self.work {
285 Some(w) => {
286 w.doc = doc;
287 note_merge(w, None);
288 }
289 None => self.store.commit(label, None, doc),
290 }
291 }
292
293 pub fn save(&mut self) -> Result<PathBuf, FormatError> {
295 let Some(path) = self.path.clone() else {
296 return Err(FormatError::Io(std::io::Error::other("the document has no file yet; use Save As")));
297 };
298 self.save_as(&path)?;
299 Ok(path)
300 }
301
302 pub fn save_as(&mut self, path: &Path) -> Result<(), FormatError> {
304 let snap = self.store.snapshot();
305 self.format.save(path, &snap)?;
306 self.store.mark_saved();
307 if self.path.as_deref() != Some(path) {
308 self.path = Some(path.to_path_buf());
309 self.file_gen = self.file_gen.wrapping_add(1);
310 }
311 self.migrated_from = None;
312 self.telemetry.saved();
313 Ok(())
314 }
315
316 pub fn view_state(&self) -> &Map<String, Value> {
320 &self.view
321 }
322
323 pub fn view<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
324 self.view.get(key).and_then(|v| T::deserialize(v).ok())
325 }
326
327 pub fn view_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
328 self.view(key).unwrap_or(default)
329 }
330
331 pub fn set_view<T: Serialize + ?Sized>(&mut self, key: &str, value: &T) {
332 let Ok(v) = serde_json::to_value(value) else { return };
333 self.set_view_value(key, v);
334 }
335
336 pub fn set_view_value(&mut self, key: &str, value: Value) {
337 if self.view.get(key) != Some(&value) {
338 self.view.insert(key.to_string(), value);
339 self.view_gen = self.view_gen.wrapping_add(1);
340 }
341 }
342
343 pub fn view_generation(&self) -> u64 {
345 self.view_gen
346 }
347
348 pub fn request(&mut self, effect: Effect) {
352 self.effects.push(effect);
353 }
354
355 pub fn toast(&mut self, level: ToastLevel, text: impl Into<String>) {
357 self.effects.push(Effect::Toast { level, text: text.into() });
358 }
359
360 pub(crate) fn take_effects(&mut self) -> Vec<Effect> {
361 std::mem::take(&mut self.effects)
362 }
363
364 pub(crate) fn drain_headless(&mut self) {
366 for e in self.effects.drain(..) {
367 if let Effect::Toast { level, text } = e {
368 match level {
369 ToastLevel::Error | ToastLevel::Warning => log::warn!("{text}"),
370 _ => log::info!("{text}"),
371 }
372 }
373 }
374 }
375
376 fn begin(&mut self) {
379 self.work = Some(Work {
380 doc: self.store.snapshot(),
381 replaced: false,
382 merge: None,
383 effects: self.effects.len(),
384 view: self.view.clone(),
385 view_gen: self.view_gen,
386 started: now(),
387 });
388 }
389
390 fn end(&mut self, label: &str, ok: bool) -> Option<std::time::Duration> {
392 let w = self.work.take()?;
393 let took = w.started.map(|s| s.elapsed());
394 if ok {
395 if !w.replaced {
396 let merge = w.merge.flatten();
397 self.store.commit(label.trim_end_matches('…'), merge.as_deref(), w.doc);
398 }
399 } else {
400 self.effects.truncate(w.effects);
401 if self.view_gen != w.view_gen {
402 self.view = w.view;
403 self.view_gen = self.view_gen.wrapping_add(1);
404 }
405 }
406 took
407 }
408}
409
410fn note_merge<D>(w: &mut Work<D>, key: Option<&str>) {
411 let key = key.map(str::to_string);
412 w.merge = match w.merge.take() {
413 None => Some(key),
414 Some(prev) if prev == key => Some(prev),
415 Some(_) => Some(None),
416 };
417}
418
419impl<D: Document> Host for Session<D> {
420 type Error = CommandError;
421
422 fn journal(&mut self) -> &mut Journal {
423 &mut self.journal
424 }
425
426 fn before(&mut self, _spec: &CommandSpec<Self>, _params: &mut Value, depth: usize) -> Result<(), CommandError> {
427 if depth == 0 {
428 self.begin();
429 }
430 Ok(())
431 }
432
433 fn after(&mut self, spec: &CommandSpec<Self>, _params: &Value, result: &Result<Value, CommandError>, depth: usize) {
434 if depth != 0 {
435 return;
436 }
437 let took = self.end(spec.label, result.is_ok());
438 self.telemetry.command(spec.id, result.is_ok(), took);
439 match result {
440 Ok(_) => log::debug!("command {} ok", spec.id),
441 Err(e) => log::info!("command {} failed: {e}", spec.id),
442 }
443 }
444
445 fn rejected(&mut self, id: &str, error: &CommandError, depth: usize) {
446 if depth == 0 {
447 self.end("", false);
448 log::debug!("command {id} refused: {error}");
449 }
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use ashlar_command::{Param, Registry, always, cmd};
457 use serde::Deserialize;
458 use serde_json::json;
459
460 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
461 struct Doc {
462 items: Vec<String>,
463 }
464
465 type S = Session<Doc>;
466
467 fn add_two(s: &mut S, _: &Value) -> Result<Value, CommandError> {
468 s.edit("a", |d| d.items.push("one".into()));
469 s.edit("b", |d| d.items.push("two".into()));
470 Ok(Value::Null)
471 }
472
473 fn add_then_fail(s: &mut S, _: &Value) -> Result<Value, CommandError> {
474 s.edit("a", |d| d.items.push("half".into()));
475 s.set_view("selected", &3);
476 s.toast(ToastLevel::Info, "never shown");
477 Err(CommandError::failed("", "nope"))
478 }
479
480 fn nested(s: &mut S, _: &Value) -> Result<Value, CommandError> {
481 s.edit("x", |d| d.items.push("outer".into()));
482 registry().execute(s, "add_two", json!({}))
483 }
484
485 fn panics(s: &mut S, _: &Value) -> Result<Value, CommandError> {
486 s.edit("x", |d| d.items.push("lost".into()));
487 Err(CommandError::Panicked { id: String::new() })
488 }
489
490 fn drag(s: &mut S, p: &Value) -> Result<Value, CommandError> {
491 let v = p.get("v").and_then(Value::as_str).unwrap_or_default().to_string();
492 s.edit_merged("Drag", "drag-1", |d| d.items = vec![v]);
493 Ok(Value::Null)
494 }
495
496 fn registry() -> Registry<S> {
497 Registry::new(vec![
498 cmd!("add_two", "Add Two", [], None, [], always, add_two),
499 cmd!("add_then_fail", "Add Then Fail", [], None, [], always, add_then_fail),
500 cmd!("nested", "Nested", [], None, [], always, nested),
501 cmd!("panics", "Panics", [], None, [], always, panics),
502 cmd!("drag", "Drag", [], None, [Param::string("v")], always, drag),
503 ])
504 }
505
506 fn session() -> S {
507 Session::new(Arc::new(Format::new("test.doc", "test")))
508 }
509
510 #[test]
511 fn a_command_with_several_edits_is_one_undo_step_named_after_it() {
512 let (r, mut s) = (registry(), session());
513 r.execute(&mut s, "add_two", json!({})).unwrap();
514 assert_eq!(s.doc().items, ["one", "two"]);
515 assert_eq!(s.store().history().depth(), 1);
516 assert_eq!(s.store().history().undo_label(), Some("Add Two"));
517 assert!(s.is_dirty());
518 s.undo();
519 assert!(s.doc().items.is_empty());
520 assert!(!s.is_dirty());
521 }
522
523 #[test]
524 fn nested_commands_join_the_top_level_step() {
525 let (r, mut s) = (registry(), session());
526 r.execute(&mut s, "nested", json!({})).unwrap();
527 assert_eq!(s.doc().items, ["outer", "one", "two"]);
528 assert_eq!(s.store().history().depth(), 1);
529 assert_eq!(s.store().history().undo_label(), Some("Nested"));
530 }
531
532 #[test]
533 fn a_failed_command_changes_nothing() {
534 let (r, mut s) = (registry(), session());
535 r.execute(&mut s, "add_two", json!({})).unwrap();
536 s.undo(); let rev = s.revision();
538 assert!(r.execute(&mut s, "add_then_fail", json!({})).is_err());
539 assert!(r.execute(&mut s, "panics", json!({})).is_err());
540 assert!(r.execute(&mut s, "drag", json!({"bogus": 1})).is_err(), "bad params are refused");
541 assert_eq!(s.revision(), rev);
542 assert!(s.doc().items.is_empty());
543 assert!(s.can_redo(), "redo line kept");
544 assert_eq!(s.view::<i32>("selected"), None, "view state restored");
545 assert!(s.take_effects().is_empty(), "effects of the failed command dropped");
546 assert_eq!(s.store().history().depth(), 0);
547 }
548
549 #[test]
550 fn repeated_commands_with_one_merge_key_merge() {
551 let (r, mut s) = (registry(), session());
552 for v in ["a", "b", "c"] {
553 r.execute(&mut s, "drag", json!({"v": v})).unwrap();
554 }
555 assert_eq!(s.doc().items, ["c"]);
556 assert_eq!(s.store().history().depth(), 1);
557 }
558
559 #[test]
560 fn edits_outside_commands_are_steps_of_their_own() {
561 let mut s = session();
562 s.edit("One", |d| d.items.push("1".into()));
563 s.edit("Two", |d| d.items.push("2".into()));
564 assert_eq!(s.store().history().depth(), 2);
565 }
566
567 #[test]
568 fn save_open_round_trip() {
569 let dir = tempfile::tempdir().unwrap();
570 let p = dir.path().join("a.doc");
571 let (r, mut s) = (registry(), session());
572 r.execute(&mut s, "add_two", json!({})).unwrap();
573 s.save_as(&p).unwrap();
574 assert!(!s.is_dirty());
575 assert_eq!(s.display_name(), "a.doc");
576 let mut t = session();
577 t.open(&p).unwrap();
578 assert_eq!(t.doc().items, ["one", "two"]);
579 assert!(!t.can_undo());
580 }
581}