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
54/// How many commands [`Session::take_ran`] keeps between two calls.
55const 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    /// The store was changed directly during the command (undo, open…): don't commit.
71    replaced: bool,
72    /// Merge key shared by every edit of the command (`Some(None)`: edits without one).
73    merge: Option<Option<String>>,
74    effects: usize,
75    view: Map<String, Value>,
76    view_gen: u64,
77    started: Option<std::time::Instant>,
78}
79
80/// The state commands run on: the document [`Store`], the file it came from, view state and the
81/// effects commands ask of the window. Commands are `fn(&mut Session<D>, &Value)`.
82pub struct Session<D: Document> {
83    store: Store<D>,
84    journal: Journal,
85    /// Top-level commands run since the runtime last took them (the frame log and perf warnings
86    /// name them); at most [`RAN_CAP`].
87    ran: Vec<&'static str>,
88    format: Arc<Format<D>>,
89    path: Option<PathBuf>,
90    /// Schema version of the opened file when it was migrated on load.
91    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    /// Who is changing the document now (set around agent requests and job applies).
100    source: Option<Origin>,
101    /// The revision the latest change produced and who made it.
102    last_change: Option<(Revision, Origin)>,
103}
104
105/// Who made a change to the document: views pick motion from it. Changes made by the user's own
106/// hand ([`Origin::User`], [`Origin::Drag`]) show at once (`motion::set`); the rest
107/// ([`Origin::Undo`], [`Origin::Agent`], [`Origin::Job`]) animate, so a person watching an agent
108/// can follow what moved.
109#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
110pub enum Origin {
111    /// An edit or command from the person at the keyboard or pointer.
112    User,
113    /// An edit folded into the previous one (a drag): it follows the hand.
114    Drag,
115    /// Undo or redo.
116    Undo,
117    /// A control request (CLI, MCP, the control port).
118    Agent,
119    /// A background job's apply.
120    Job,
121}
122
123impl Origin {
124    /// Whether a change from this origin should animate (false: show it at once).
125    pub fn animates(self) -> bool {
126        matches!(self, Origin::Undo | Origin::Agent | Origin::Job)
127    }
128}
129
130impl<D: Document> Session<D> {
131    /// A session with a new, empty document.
132    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    /// Whether a window can show dialogs (false in CLI / MCP sessions).
157    pub fn is_windowed(&self) -> bool {
158        self.windowed
159    }
160
161    // ------------------------------------------------------------------ the document
162
163    /// The current document (inside a command: its working copy).
164    pub fn doc(&self) -> &D {
165        match &self.work {
166            Some(w) => &w.doc,
167            None => self.store.doc(),
168        }
169    }
170
171    /// The current document as an `Arc` (for a job or another thread).
172    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    /// The store (history, revision, dirty state). Changing it directly from inside a command
180    /// drops the command's own edits; use [`Session::edit`] there.
181    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    /// Change the document. Inside a command the change joins the command's single undo step;
205    /// outside (a view, a job's apply) it is a step of its own called `label`.
206    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    /// [`Session::edit`] that folds into the previous step with the same label and merge key
211    /// (one undo step for a whole drag).
212    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    /// Run `f` with every change it makes recorded as coming from `origin` (an app's own agent
217    /// bridge, a sync engine). The runtime does this for control requests and job applies.
218    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    /// Who made the change that produced the current revision; `None` when the document has not
226    /// changed since it was created or opened (or was changed through [`Session::store_mut`]).
227    pub fn origin(&self) -> Option<Origin> {
228        self.last_change.filter(|(rev, _)| *rev == self.store.revision()).map(|(_, o)| o)
229    }
230
231    /// Set who is changing the document from now on; returns the previous setting.
232    pub(crate) fn swap_source(&mut self, origin: Option<Origin>) -> Option<Origin> {
233        std::mem::replace(&mut self.source, origin)
234    }
235
236    /// Record who made the change from `before` to the current revision (nothing when unchanged).
237    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    /// A change that may fail: on error nothing changes.
245    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    /// Undo the last step; returns its label.
291    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    /// Redo the next step; returns its label.
300    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    /// After a direct store change inside a command, the working copy follows the store.
309    fn sync_work(&mut self) {
310        if let Some(w) = &mut self.work {
311            w.doc = self.store.snapshot();
312        }
313    }
314
315    // ------------------------------------------------------------------ files
316
317    /// The file the document was opened from or saved to.
318    pub fn path(&self) -> Option<&Path> {
319        self.path.as_deref()
320    }
321
322    /// The document's display name: the file name, or "Untitled".
323    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    /// The schema version the open file had before it was upgraded on load, if it was.
328    pub fn migrated_from(&self) -> Option<u64> {
329        self.migrated_from
330    }
331
332    /// Changes when the file (path) changes; with the revision it keys caches of file state.
333    pub fn file_generation(&self) -> u64 {
334        self.file_gen
335    }
336
337    /// Start a new, empty document.
338    pub fn new_document(&mut self) {
339        self.replace(D::default(), None, None);
340    }
341
342    /// Open `path` (migrating older files). On error the current document stays.
343    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    /// Replace the document with `doc` as if opened from `path`: clears the history, clean.
352    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    /// Replace the document with `doc` as an undoable, unsaved change (recovered work, an
361    /// import).
362    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    /// Save to the current file. Errors when the document has never been saved.
378    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    /// Save to `path` and make it the document's file.
387    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    // ------------------------------------------------------------------ view state
401
402    /// App-defined view state (`ui.set` merges into it; it is not part of the document).
403    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    /// Changes whenever the view state changes.
428    pub fn view_generation(&self) -> u64 {
429        self.view_gen
430    }
431
432    // ------------------------------------------------------------------ effects
433
434    /// Ask the window for something (ignored headless).
435    pub fn request(&mut self, effect: Effect) {
436        self.effects.push(effect);
437    }
438
439    /// Show a message (headless: logged).
440    pub fn toast(&mut self, level: ToastLevel, text: impl Into<String>) {
441        self.effects.push(Effect::Toast { level, text: text.into() });
442    }
443
444    /// The top-level commands run since the last call (oldest first), and forget them.
445    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    /// Drop effects a headless session cannot act on (toasts go to the log).
454    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    // ------------------------------------------------------------------ command steps
466
467    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    /// End a top-level command: commit its working copy as one step, or undo everything it did.
480    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(); // leaves a redo line, which must survive the failure
632        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}