Skip to main content

ashlar_app/
session.rs

1//! The session: the state every command runs on (document, file, view state), and the host glue
2//! that makes each top-level command exactly one undo step.
3//!
4//! While a top-level command runs, its edits go to a working copy of the document. When the
5//! command succeeds the copy is committed to the [`Store`] as one step named after the command;
6//! when it fails or panics the copy is dropped, and the document, history, view state and
7//! requested effects are as they were.
8
9use 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
22/// What a document type needs: cheap to clone (keep large parts in `Arc`s), a default (the new,
23/// empty document) and serde (the file format).
24pub trait Document: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static {}
25impl<T: Clone + Default + Serialize + DeserializeOwned + Send + Sync + 'static> Document for T {}
26
27/// Something a command asks the window to do. A headless session (CLI, MCP) has no window and
28/// drops them (toasts go to the log).
29#[derive(Clone, Debug, PartialEq)]
30pub enum Effect {
31    Toast {
32        level: ToastLevel,
33        text: String,
34    },
35    OpenPalette,
36    OpenKeymap,
37    Theme(ThemeMode),
38    /// Ask the user which file to open (then run `file.open` with it).
39    PickOpen,
40    /// Ask the user where to save (then run `file.save_as` with it).
41    PickSaveAs,
42    /// The document has unsaved changes: ask "save, don't save, cancel" before running
43    /// `command` again with `discard: true`.
44    ConfirmDiscard {
45        command: String,
46        params: Value,
47    },
48    /// Close the app (asking first when there are unsaved changes, unless `discard`).
49    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    /// The store was changed directly during the command (undo, open…): don't commit.
68    replaced: bool,
69    /// Merge key shared by every edit of the command (`Some(None)`: edits without one).
70    merge: Option<Option<String>>,
71    effects: usize,
72    view: Map<String, Value>,
73    view_gen: u64,
74    started: Option<std::time::Instant>,
75}
76
77/// The state commands run on: the document [`Store`], the file it came from, view state and the
78/// effects commands ask of the window. Commands are `fn(&mut Session<D>, &Value)`.
79pub struct Session<D: Document> {
80    store: Store<D>,
81    journal: Journal,
82    format: Arc<Format<D>>,
83    path: Option<PathBuf>,
84    /// Schema version of the opened file when it was migrated on load.
85    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    /// A session with a new, empty document.
97    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    /// Whether a window can show dialogs (false in CLI / MCP sessions).
119    pub fn is_windowed(&self) -> bool {
120        self.windowed
121    }
122
123    // ------------------------------------------------------------------ the document
124
125    /// The current document (inside a command: its working copy).
126    pub fn doc(&self) -> &D {
127        match &self.work {
128            Some(w) => &w.doc,
129            None => self.store.doc(),
130        }
131    }
132
133    /// The current document as an `Arc` (for a job or another thread).
134    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    /// The store (history, revision, dirty state). Changing it directly from inside a command
142    /// drops the command's own edits; use [`Session::edit`] there.
143    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    /// Change the document. Inside a command the change joins the command's single undo step;
167    /// outside (a view, a job's apply) it is a step of its own called `label`.
168    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    /// [`Session::edit`] that folds into the previous step with the same label and merge key
173    /// (one undo step for a whole drag).
174    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    /// A change that may fail: on error nothing changes.
179    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    /// Undo the last step; returns its label.
215    pub fn undo(&mut self) -> Option<String> {
216        let r = self.store_mut().undo();
217        self.sync_work();
218        r
219    }
220
221    /// Redo the next step; returns its label.
222    pub fn redo(&mut self) -> Option<String> {
223        let r = self.store_mut().redo();
224        self.sync_work();
225        r
226    }
227
228    /// After a direct store change inside a command, the working copy follows the store.
229    fn sync_work(&mut self) {
230        if let Some(w) = &mut self.work {
231            w.doc = self.store.snapshot();
232        }
233    }
234
235    // ------------------------------------------------------------------ files
236
237    /// The file the document was opened from or saved to.
238    pub fn path(&self) -> Option<&Path> {
239        self.path.as_deref()
240    }
241
242    /// The document's display name: the file name, or "Untitled".
243    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    /// The schema version the open file had before it was upgraded on load, if it was.
248    pub fn migrated_from(&self) -> Option<u64> {
249        self.migrated_from
250    }
251
252    /// Changes when the file (path) changes; with the revision it keys caches of file state.
253    pub fn file_generation(&self) -> u64 {
254        self.file_gen
255    }
256
257    /// Start a new, empty document.
258    pub fn new_document(&mut self) {
259        self.replace(D::default(), None, None);
260    }
261
262    /// Open `path` (migrating older files). On error the current document stays.
263    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    /// Replace the document with `doc` as if opened from `path`: clears the history, clean.
272    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    /// Replace the document with `doc` as an undoable, unsaved change (recovered work, an
281    /// import).
282    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    /// Save to the current file. Errors when the document has never been saved.
294    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    /// Save to `path` and make it the document's file.
303    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    // ------------------------------------------------------------------ view state
317
318    /// App-defined view state (`ui.set` merges into it; it is not part of the document).
319    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    /// Changes whenever the view state changes.
344    pub fn view_generation(&self) -> u64 {
345        self.view_gen
346    }
347
348    // ------------------------------------------------------------------ effects
349
350    /// Ask the window for something (ignored headless).
351    pub fn request(&mut self, effect: Effect) {
352        self.effects.push(effect);
353    }
354
355    /// Show a message (headless: logged).
356    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    /// Drop effects a headless session cannot act on (toasts go to the log).
365    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    // ------------------------------------------------------------------ command steps
377
378    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    /// End a top-level command: commit its working copy as one step, or undo everything it did.
391    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(); // leaves a redo line, which must survive the failure
537        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}