Skip to main content

provui_core/
session.rs

1//! [`DocumentSession`] — one open prov document, edited through two coordinated
2//! editors.
3//!
4//! A prov document is a metadata region plus a prose body. The session owns:
5//!
6//! - a [`flower_core::Model<ProvBackend>`] for the **metadata** (structural,
7//!   lossless, through prov's `MetaEditor`), and
8//! - a [`leaf_core::Doc`] for the **body** (a rich-text editor over its own
9//!   buffer).
10//!
11//! The two regions are independent — there are no shared byte offsets — so they
12//! edit freely and reconcile only at [`save`](DocumentSession::save): the body's
13//! current text is spliced back into the document (leaving the metadata edits in
14//! place), and the reassembled bytes are written to disk.
15//!
16//! ## One undo over two histories
17//!
18//! Each editor keeps its own history and neither knows the other exists, so the
19//! session keeps a **journal of which one took each step** — nothing more. A
20//! host calls [`sync_history`](DocumentSession::sync_history) once per event
21//! loop, which reads both editors' change counters and records whichever moved;
22//! [`undo`](DocumentSession::undo) pops the most recent entry and calls that
23//! editor's own undo. So "body edit, metadata edit, body edit" undoes in that
24//! order, and the *meaning* of a step stays with the editor that owns the bytes.
25//!
26//! A workspace-maintained key still refuses its undo, because flower replays the
27//! inverse through the same backend the edit went through. And leaf still
28//! decides its own step boundaries: see `sync_history` for the one limit that
29//! follows from that.
30//!
31//! A workspace [`Schema`](flower_core::Schema) can be supplied at open
32//! (`*_with_schema`) so the metadata model validates controlled fields and offers
33//! pickers; without one the session behaves exactly as a schema-free editor.
34//!
35//! File I/O lives here (not in flower-core/leaf-core, which stay fs-agnostic). A
36//! frontend that wants fixity and the `updated` stamp maintained routes the write
37//! through prov's `Storage`/`mutate` layer instead; this foundation writes the
38//! bytes directly, which is the unopinionated floor a frontend builds on.
39//!
40//! This is the surface a UniFFI facade will wrap, and the surface a TUI drives
41//! directly.
42
43use std::collections::HashMap;
44use std::path::{Path, PathBuf};
45
46use fig::Value;
47use flower_core::annotate;
48use flower_core::{Annotation, Choice, Model, Schema, Seg, ViewMode};
49use leaf_core::{Doc, Format as BodyFormat};
50use prov::{Document, MetaCarrier};
51
52use crate::ProvBackend;
53use crate::body_links::BodyLink;
54use crate::findings::{Finding, Severity, Site};
55
56/// The answer for a document whose metadata block does not resolve — a `&Value`
57/// to hand back without an allocation or an `Option` every caller would unwrap
58/// the same way.
59static EMPTY_META: Value = Value::Null;
60
61/// A session error, carrying a human-readable message. UniFFI-friendly to widen
62/// into a typed enum later.
63#[derive(Debug)]
64pub struct SessionError(pub String);
65
66impl std::fmt::Display for SessionError {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        f.write_str(&self.0)
69    }
70}
71
72impl std::error::Error for SessionError {}
73
74fn se(e: impl std::fmt::Display) -> SessionError {
75    SessionError(e.to_string())
76}
77
78/// A heading in a document's prose body.
79///
80/// The piece of a document a `#locator` names: prov carries a locator on a link
81/// target and never resolves it, leaf's [`Doc::locate`](leaf_core::Doc::locate)
82/// resolves one it is given, and this is the third corner — the locator you
83/// would *write* for where the caret is now.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct Heading {
86    /// The heading's own words, without its `#` marker.
87    pub text: String,
88    /// `1` for an `# H1`, `6` for an `###### H6`.
89    pub level: u32,
90    /// Byte range of the whole heading line within the **body text** — the same
91    /// coordinates [`crate::BodyLink::span`] and the caret are in.
92    pub span: std::ops::Range<usize>,
93}
94
95/// One open prov document: a metadata editor and a body editor over the same
96/// file, reconciled on save.
97pub struct DocumentSession {
98    path: PathBuf,
99    metadata: Model<ProvBackend>,
100    body: Doc,
101    /// Whether the document has an editable prose body (a fenced carrier). A
102    /// whole-file config document has none — its body editor stays empty.
103    has_body: bool,
104    /// The body text as of the last open/save, for dirty tracking.
105    saved_body: String,
106    /// The findings most recently handed to
107    /// [`apply_findings`](DocumentSession::apply_findings). Kept because the
108    /// body half of them becomes leaf highlights the widget draws by itself,
109    /// while the metadata half has nowhere to go yet — see
110    /// [`meta_findings`](DocumentSession::meta_findings).
111    findings: Vec<Finding>,
112    /// Which editor took each step, oldest first — the order
113    /// [`undo`](DocumentSession::undo) walks back through. See
114    /// [`sync_history`](DocumentSession::sync_history).
115    journal: Vec<Region>,
116    /// The steps [`undo`](DocumentSession::undo) has taken back, most recent
117    /// last. Cleared by the next fresh edit in either region.
118    redo_journal: Vec<Region>,
119    /// leaf's [`revision`](leaf_core::Doc::revision) as of the last
120    /// [`sync_history`](DocumentSession::sync_history).
121    seen_body: u64,
122    /// flower's [`edit_seq`](flower_core::Model::edit_seq) as of the last
123    /// [`sync_history`](DocumentSession::sync_history).
124    seen_meta: u64,
125}
126
127/// Which of a session's two editors a history step belongs to.
128///
129/// The whole of the session's journal: an ordered list of these is what makes
130/// one undo out of two independent histories, and neither editor learns that
131/// the other exists.
132#[derive(Clone, Copy, Debug, PartialEq, Eq)]
133pub enum Region {
134    /// The prose body — a [`leaf_core::Doc`] step.
135    Body,
136    /// The metadata — a [`flower_core::Model`] step.
137    Meta,
138}
139
140/// The grammar a document's body is written in, from its path.
141///
142/// leaf is grammar-agnostic and prov already knows which extensions mean what,
143/// so the only thing missing was asking. A path prov does not recognize as
144/// content falls back to Markdown — including a whole-file config document,
145/// which has no body for the format to apply to.
146fn body_format_of(path: &Path) -> BodyFormat {
147    match prov::ContentFormat::from_extension(path) {
148        Some(prov::ContentFormat::Djot) => BodyFormat::Djot,
149        Some(prov::ContentFormat::Html) => BodyFormat::Html,
150        Some(prov::ContentFormat::Markdown) | None => BodyFormat::Markdown,
151    }
152}
153
154/// The metadata half of a finding list, in flower's own vocabulary.
155///
156/// Split out from [`DocumentSession::apply_findings`] because it is the whole
157/// of the translation, and a frontend composing its own annotation list wants
158/// the prov half of it without the ownership.
159pub fn annotations_of(findings: &[Finding]) -> Vec<Annotation> {
160    findings
161        .iter()
162        .filter_map(|finding| {
163            let path = match &finding.site {
164                Site::Meta(path) => path.clone(),
165                // The empty path is flower's "the document".
166                Site::Document => Vec::new(),
167                Site::Body(_) => return None,
168            };
169            let severity = match finding.severity {
170                Severity::Error => annotate::Severity::Error,
171                Severity::Warning => annotate::Severity::Warning,
172            };
173            Some(Annotation::new(path, severity, finding.message.clone()))
174        })
175        .collect()
176}
177
178impl DocumentSession {
179    /// Open a prov document from disk, parsing the body in the grammar its
180    /// extension declares, with no schema.
181    pub fn open(path: impl Into<PathBuf>) -> Result<Self, SessionError> {
182        let path = path.into();
183        let format = body_format_of(&path);
184        Self::open_with(path, format, None)
185    }
186
187    /// Open a prov document from disk carrying the workspace `schema`.
188    pub fn open_with_schema(
189        path: impl Into<PathBuf>,
190        schema: Schema,
191    ) -> Result<Self, SessionError> {
192        let path = path.into();
193        let format = body_format_of(&path);
194        Self::open_with(path, format, Some(schema))
195    }
196
197    /// Open a prov document declaring the keys the **workspace** maintains, so
198    /// the metadata model draws their rows and declines every edit to them.
199    ///
200    /// `derived` is
201    /// [`Facets::managed_key_names`](crate::Facets::managed_key_names) — `id`,
202    /// `content_hash`, and whatever the workspace named as its `updated` stamp.
203    /// It is a separate entry point rather than something
204    /// [`open_with_schema`](Self::open_with_schema) does for you because
205    /// declining an edit is a *policy*, and a repair tool that means to rewrite a
206    /// stale `id` is as legitimate a frontend as an editor that must not. This
207    /// crate hands over the list and lets the frontend decide (see
208    /// [`crate::facets`]); most editors want it, and this is the one line that
209    /// says so.
210    ///
211    /// The set has to arrive here rather than being applied afterwards: flower
212    /// takes it before it builds its first row list.
213    pub fn open_managed(
214        path: impl Into<PathBuf>,
215        schema: Option<Schema>,
216        derived: Vec<String>,
217    ) -> Result<Self, SessionError> {
218        let path = path.into();
219        let format = body_format_of(&path);
220        let text = std::fs::read_to_string(&path)
221            .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
222        Self::build(path, &text, format, schema, derived)
223    }
224
225    /// Open a prov document from disk, parsing the body as `body_format`, with an
226    /// optional workspace schema.
227    pub fn open_with(
228        path: impl Into<PathBuf>,
229        body_format: BodyFormat,
230        schema: Option<Schema>,
231    ) -> Result<Self, SessionError> {
232        let path = path.into();
233        let text = std::fs::read_to_string(&path)
234            .map_err(|e| SessionError(format!("reading {}: {e}", path.display())))?;
235        Self::from_text(path, &text, body_format, schema)
236    }
237
238    /// Build a session from in-memory `text`. The `path` still drives prov's
239    /// carrier/format detection (extension for a config doc, content sniffing for a
240    /// fenced block). `schema` governs the metadata model when present.
241    pub fn from_text(
242        path: impl Into<PathBuf>,
243        text: &str,
244        body_format: BodyFormat,
245        schema: Option<Schema>,
246    ) -> Result<Self, SessionError> {
247        Self::build(path, text, body_format, schema, Vec::new())
248    }
249
250    /// The one constructor the rest are written in terms of.
251    fn build(
252        path: impl Into<PathBuf>,
253        text: &str,
254        body_format: BodyFormat,
255        schema: Option<Schema>,
256        derived: Vec<String>,
257    ) -> Result<Self, SessionError> {
258        let path = path.into();
259        let parsed = Document::parse(&path, text).map_err(se)?;
260        let has_body = matches!(parsed.carrier, Some(MetaCarrier::Fenced(_)));
261
262        let backend = match schema {
263            Some(schema) => ProvBackend::open_with_schema(&path, text, schema),
264            None => ProvBackend::open(&path, text),
265        }
266        .map_err(se)?;
267        // `with_managed`, not `new`: a derived key keeps its row and declines
268        // every edit, which is a different thing from hiding it. Empty `hidden`
269        // — nothing about a prov document is a key this host should make
270        // invisible, and a frontend that wants one says so itself.
271        let metadata = Model::with_managed(backend, Vec::new(), derived).map_err(se)?;
272        let body = Doc::from_source(parsed.body, body_format).map_err(se)?;
273        let saved_body = body.source.clone();
274
275        Ok(Self {
276            seen_body: body.revision(),
277            seen_meta: metadata.edit_seq(),
278            path,
279            metadata,
280            body,
281            has_body,
282            saved_body,
283            findings: Vec::new(),
284            journal: Vec::new(),
285            redo_journal: Vec::new(),
286        })
287    }
288
289    pub fn path(&self) -> &Path {
290        &self.path
291    }
292
293    /// Whether the document has an editable prose body.
294    pub fn has_body(&self) -> bool {
295        self.has_body
296    }
297
298    /// The metadata editor (its `rows` are what a metadata pane renders).
299    pub fn metadata(&self) -> &Model<ProvBackend> {
300        &self.metadata
301    }
302
303    pub fn metadata_mut(&mut self) -> &mut Model<ProvBackend> {
304        &mut self.metadata
305    }
306
307    /// The metadata value tree — what [`crate::links`] and [`crate::facets`] ask
308    /// their questions of.
309    ///
310    /// The model's own copy, not a reparse: it is rebuilt on every edit, so this
311    /// is current and free.
312    pub fn meta(&self) -> &Value {
313        self.metadata.value_at(&[]).unwrap_or(&EMPTY_META)
314    }
315
316    /// The metadata path the cursor is on, whichever projection the model is
317    /// showing.
318    ///
319    /// flower has two — a flat row list and a page stack — and asks the question
320    /// a different way in each. A frontend that wants "the row under the cursor"
321    /// should not have to know which one it set, least of all a frontend that
322    /// switches between them; getting it wrong reads as a link that follows the
323    /// wrong document rather than as an error.
324    pub fn cursor_path(&self) -> Option<Vec<Seg>> {
325        match self.metadata.view() {
326            ViewMode::Pages => self.metadata.page_item().map(|item| item.path.clone()),
327            _ => self.metadata.selected_path(),
328        }
329    }
330
331    /// The body editor.
332    pub fn body(&self) -> &Doc {
333        &self.body
334    }
335
336    /// The grammar the body is written in, as **prov** spells it.
337    ///
338    /// leaf's `Format` is twig's, which is the wider list — it also names XML
339    /// and AsciiDoc, which prov has no content format for. Anything outside
340    /// prov's three reads as Markdown, which is the same fallback
341    /// [`DocumentSession::open`] applies on the way in, so the answer here is
342    /// the format the body was actually parsed under rather than a second
343    /// guess at it.
344    pub fn body_format(&self) -> prov::ContentFormat {
345        match self.body.format {
346            BodyFormat::Djot => prov::ContentFormat::Djot,
347            BodyFormat::Html => prov::ContentFormat::Html,
348            _ => prov::ContentFormat::Markdown,
349        }
350    }
351
352    /// Every link the prose body declares, as it stands.
353    ///
354    /// Parsed on each call rather than cached: the body is a live buffer, and a
355    /// cached span list is one edit away from pointing at the wrong bytes.
356    /// Following a link is a keystroke, not a frame, so one twig parse of one
357    /// document's prose is the right price for an answer that is never stale.
358    pub fn body_links(&self) -> Result<Vec<BodyLink>, SessionError> {
359        crate::body_links::body_links(&self.body.source, self.body_format())
360    }
361
362    /// The body link the caret is standing inside, if any — the body pane's
363    /// half of "the row under the cursor, is that a link?".
364    ///
365    /// leaf keeps the caret as a byte offset into the same buffer the spans are
366    /// measured in ([`leaf_core::Doc::caret`]), so the two meet without a
367    /// conversion.
368    pub fn body_link_at_caret(&self) -> Result<Option<BodyLink>, SessionError> {
369        let links = self.body_links()?;
370        Ok(crate::body_links::body_link_at(&links, self.body.caret).cloned())
371    }
372
373    pub fn body_mut(&mut self) -> &mut Doc {
374        &mut self.body
375    }
376
377    /// The heading the caret is under — the nearest one at or above it, and
378    /// `None` when the caret sits above the document's first heading (or there
379    /// are none).
380    ///
381    /// "At or above" is the rule every table of contents and every anchor
382    /// implementation uses: a caret three paragraphs into a section is in that
383    /// section, and the heading that opened it is the thing a reader would name
384    /// to point at where they are.
385    ///
386    /// Parsed through twig directly — `prov::twig` is the same copy prov and
387    /// leaf are both built on, so this is the tree leaf is already holding
388    /// rather than a second one with its own opinions. It is parsed again here
389    /// because leaf's own `Doc::nodes` is private: `Doc` exposes `locate` (a
390    /// fragment to a landing) and `link_destination_at_caret` (a caret to a
391    /// link) but nothing that hands back the node array, and nothing that
392    /// answers the caret-to-heading question. The parse is one document's prose
393    /// on a keystroke, which is the same price [`body_links`](Self::body_links)
394    /// pays and for the same reason.
395    pub fn heading_at_caret(&self) -> Option<Heading> {
396        use prov::twig;
397
398        let mut parsed = twig::Document::parse_str(&self.body.source, self.body.format).ok()?;
399        let nodes = parsed.nodes().ok()?;
400        let caret = self.body.caret;
401        let node = nodes
402            .iter()
403            .filter(|n| n.kind == twig::Kind::Heading)
404            .filter(|n| n.span.start <= caret)
405            .max_by_key(|n| n.span.start)?;
406        // `content_span` is the words without the `#` marker; `text` is twig's
407        // own flattening of the same, and is what a heading with inline marks
408        // in it (`## The *hard* part`) reads as. Prefer the source slice, so
409        // the locator is derived from what is actually written.
410        let text = node
411            .content_span
412            .clone()
413            .and_then(|span| self.body.source.get(span))
414            .map(str::to_string)
415            .or_else(|| node.text.clone())?;
416        Some(Heading {
417            text: text.trim().to_string(),
418            level: node.level.unwrap_or(1),
419            span: node.span.clone(),
420        })
421    }
422
423    /// The `#locator` naming where the caret is — [`prov::link::slug`] of the
424    /// heading above it.
425    ///
426    /// prov's slug rather than a local one, because prov is what has to read it
427    /// back: the fragment this writes is the fragment `prov check` resolves and
428    /// the fragment leaf's `Doc::locate` lands, and `locate`'s third reading —
429    /// a heading's own words, slugged — is the one that applies to Markdown,
430    /// where there are no ids to name at all.
431    pub fn locator_at_caret(&self) -> Option<String> {
432        self.heading_at_caret()
433            .map(|heading| prov::link::slug(&heading.text))
434    }
435
436    /// Hand the metadata backend the candidate lists a reference field's picker
437    /// should offer, per relation — see
438    /// [`ProvBackend::set_candidates`](crate::ProvBackend::set_candidates) for
439    /// what it costs and
440    /// [`WorkspaceView::candidates_map`](crate::WorkspaceView::candidates_map)
441    /// for where a list comes from.
442    ///
443    /// Through the session rather than through `metadata_mut().backend_mut()`
444    /// because it is the same kind of out-of-band fact as the schema and the
445    /// findings: something only a host with a workspace can know, handed to the
446    /// one document that cannot work it out.
447    pub fn set_candidates(&mut self, candidates: HashMap<String, Vec<Choice>>) {
448        self.metadata.backend_mut().set_candidates(candidates);
449    }
450
451    /// Programmatically set the metadata value at `path` — the flat, by-path edit
452    /// a UI/FFI issues (vs. driving the selection).
453    pub fn set_metadata(&mut self, path: &[Seg], value: Value) {
454        self.metadata.set_value_at(path, value);
455    }
456
457    /// Take on a set of findings: wash the body ones under the text they are
458    /// about, and hold the rest for the host to read.
459    ///
460    /// **The highlight list is owned by this call.** leaf's
461    /// [`set_highlights`](leaf_core::Doc::set_highlights) replaces the whole
462    /// set rather than adding to it — deliberately, so the host and the
463    /// document can never disagree about what is on screen — so there is no way
464    /// to "clear the finding highlights and keep the others". A session whose
465    /// findings are being applied is a session whose body highlights are the
466    /// findings; a host that also wants search hits or annotations in the body
467    /// composes its own list and calls leaf directly instead of calling this.
468    ///
469    /// Each highlight's `id` is the finding's [`kind`](Finding::kind), which is
470    /// what leaf hands back when a reader activates one, and its `marker` is
471    /// `"finding"` — the name is opaque to leaf, and a frontend reads it as
472    /// whatever glyph it draws in the margin.
473    ///
474    /// **The metadata half is owned the same way**, and by the same argument:
475    /// flower's [`set_annotations`](flower_core::Model::set_annotations)
476    /// replaces the whole set rather than adding to it, so the rows a session's
477    /// findings are applied to carry those findings and nothing else. A host
478    /// with annotations of its own composes the list and calls the model
479    /// directly.
480    ///
481    /// A [`Site::Meta`] finding becomes an [`Annotation`](flower_core::Annotation)
482    /// at the same path — so the row `contents[2]` was narrowed to is the row
483    /// that gets the marker — and a [`Site::Document`] one becomes an annotation
484    /// at the **empty** path, which is flower's spelling for "the document".
485    /// That is deliberately not a row: nothing draws the root, so a finding
486    /// about the file rather than about anything written in it stays the host's
487    /// to report, which is what [`findings`](Self::findings) is for.
488    /// [`Site::Body`] findings go to leaf and nowhere else.
489    ///
490    /// The severity map is total in one direction only: this crate draws two
491    /// levels and flower draws three, so nothing here ever produces
492    /// [`Severity::Info`](flower_core::annotate::Severity::Info). prov has no
493    /// severity at all (see [`crate::findings`]), and inventing a third here
494    /// would be inventing it twice.
495    pub fn apply_findings(&mut self, findings: &[Finding]) {
496        let highlights = findings
497            .iter()
498            .filter_map(|finding| match &finding.site {
499                Site::Body(span) => Some(leaf_core::Highlight {
500                    start: span.start,
501                    end: span.end,
502                    id: finding.kind.to_string(),
503                    color: None,
504                    marker: Some("finding".to_string()),
505                }),
506                _ => None,
507            })
508            .collect();
509        self.body.set_highlights(highlights);
510        self.metadata.set_annotations(annotations_of(findings));
511        self.findings = findings.to_vec();
512    }
513
514    /// Every finding [`apply_findings`](Self::apply_findings) was last given.
515    pub fn findings(&self) -> &[Finding] {
516        &self.findings
517    }
518
519    /// The findings that sit in the **metadata**.
520    ///
521    /// [`apply_findings`](Self::apply_findings) has already handed these to the
522    /// model as annotations, so a widget over it draws them; this is the same
523    /// half as prov reported it, for a host that wants the `kind` or the
524    /// severity rather than the sentence.
525    pub fn meta_findings(&self) -> impl Iterator<Item = &Finding> {
526        self.findings
527            .iter()
528            .filter(|f| matches!(f.site, Site::Meta(_)))
529    }
530
531    /// The finding sitting at metadata `path`, if there is one.
532    ///
533    /// Exact, not inherited: a finding on `contents` does not answer for
534    /// `contents[2]`. flower's
535    /// [`annotation_at`](flower_core::Model::annotation_at) is the other
536    /// question and inherits from the nearest annotated ancestor.
537    pub fn meta_finding_at(&self, path: &[Seg]) -> Option<&Finding> {
538        self.meta_findings()
539            .find(|f| matches!(&f.site, Site::Meta(at) if at == path))
540    }
541
542    // ── one history over two editors ─────────────────────────────────────
543
544    /// Notice whatever either editor has just done, and record which one did
545    /// it. **The host calls this once per event-loop iteration**, after
546    /// dispatching the event and before reading the next.
547    ///
548    /// ## Why it is polled rather than pushed
549    ///
550    /// Neither editor has an edit *entry point* the session could wrap. A
551    /// keystroke reaches leaf through `leaf_ratatui::handle_key` and flower
552    /// through `flower_ratatui::handle_key`, both of which take the editor
553    /// directly, and a host holding [`body_mut`](Self::body_mut) and
554    /// [`metadata_mut`](Self::metadata_mut) can edit through either without
555    /// passing through anything of this crate's. What both editors *do* expose
556    /// is a counter that moves on every change and on nothing else —
557    /// [`Doc::revision`](leaf_core::Doc::revision) and
558    /// [`Model::edit_seq`](flower_core::Model::edit_seq) — so the session reads
559    /// those instead of asking the host to remember to tell it. A host that
560    /// forgets to call this loses undo; it cannot get the *order* wrong, which
561    /// is the failure worth designing against.
562    ///
563    /// ## The known limit
564    ///
565    /// **leaf coalesces keystrokes into steps on its own schedule.** Typing a
566    /// word moves the revision once per character, and twig may hold the whole
567    /// word as a single undo step. So a [`Region::Body`] journal entry is not a
568    /// leaf step, and a count of entries is not a count of undos: what
569    /// [`undo`](Self::undo) does is take **one leaf step**, never a keystroke,
570    /// and then drop whatever further `Body` entries leaf has nothing left to
571    /// answer for before it reaches the next `Meta` one. That is what keeps the
572    /// *ordering* exact — body, then metadata, then body undoes in that order —
573    /// while leaving the granularity to the editor that owns the bytes, which
574    /// is the only component that can decide it.
575    ///
576    /// flower has no such coalescing: one commit is one step.
577    ///
578    /// A fresh edit in either region clears the redo journal, the way a fresh
579    /// edit clears either editor's own.
580    pub fn sync_history(&mut self) {
581        let revision = self.body.revision();
582        if revision != self.seen_body {
583            self.seen_body = revision;
584            self.journal.push(Region::Body);
585            self.redo_journal.clear();
586        }
587        let seq = self.metadata.edit_seq();
588        if seq != self.seen_meta {
589            self.seen_meta = seq;
590            self.journal.push(Region::Meta);
591            self.redo_journal.clear();
592        }
593    }
594
595    /// The steps recorded so far, oldest first — what
596    /// [`sync_history`](Self::sync_history) has seen. For a frontend drawing a
597    /// history, and for a test asserting the order.
598    pub fn journal(&self) -> &[Region] {
599        &self.journal
600    }
601
602    /// Whether there is a step to take back. See [`undo`](Self::undo) for why
603    /// this is not `!journal().is_empty()`.
604    ///
605    /// A hint, in the direction hints should err: it can say yes where the body
606    /// entries left are all coalesced away, because leaf's own `can_undo` is a
607    /// step counter rather than its history — see [`undo`](Self::undo). It
608    /// never says no while there is something to take back, which is the half a
609    /// greyed-out menu item needs to be right about.
610    pub fn can_undo(&self) -> bool {
611        self.journal.iter().rev().any(|r| self.has_undo(*r))
612    }
613
614    /// Whether there is an undone step to put back.
615    pub fn can_redo(&self) -> bool {
616        self.redo_journal.iter().rev().any(|r| self.has_redo(*r))
617    }
618
619    /// Take back the most recent step, in whichever editor made it.
620    ///
621    /// The journal says which editor, and that editor's own undo says what —
622    /// `Doc::undo` for the body, `Model::undo` for the metadata. Neither is
623    /// reimplemented here and neither is second-guessed: flower replays an
624    /// inverse op through the same `Backend::apply` the edit went through, so a
625    /// workspace-maintained key refuses its undo exactly as it refuses its
626    /// edit, and a refusal here is a refusal that leaves the journal as it was.
627    ///
628    /// **Entries leaf has nothing to answer for are dropped, not pressed.**
629    /// Because leaf coalesces (see [`sync_history`](Self::sync_history)), eight
630    /// `Body` entries may face one leaf step: the first `undo` spends the step
631    /// and the next one walks past the remaining seven to the `Meta` entry
632    /// underneath. Without that, a reader would press the key seven times for
633    /// nothing before the metadata edit came back.
634    ///
635    /// `true` when something was undone.
636    pub fn undo(&mut self) -> bool {
637        while let Some(region) = self.journal.pop() {
638            if !self.has_undo(region) {
639                continue;
640            }
641            let before = self.counters();
642            // flower's undo says whether the document moved; leaf's does not.
643            // The counters below answer that for both, so neither is asked.
644            match region {
645                Region::Body => self.body.undo(),
646                Region::Meta => {
647                    self.metadata.undo();
648                }
649            }
650            if self.counters() != before {
651                self.mark_seen();
652                self.redo_journal.push(region);
653                return true;
654            }
655            if self.nothing_happened(region) {
656                return false;
657            }
658        }
659        false
660    }
661
662    /// What a step that changed nothing means, which is not the same thing in
663    /// the two editors — and `true` when it means the caller should stop.
664    ///
665    /// **flower's `history_len`/`redo_len` are its actual journals**, so a
666    /// history move that changes nothing there is a *refusal*: a
667    /// workspace-maintained key declining its own undo, which flower reports in
668    /// its status. A refusal has to stop the walk. Reaching past it for an
669    /// older edit would undo something the reader did not ask about, in answer
670    /// to a key press that was answered "no".
671    ///
672    /// **leaf's `can_undo`/`can_redo` are step *counters*,** incremented once
673    /// per edit where twig coalesces several into one step — so they can say
674    /// yes when there is nothing left. A body move that changes nothing is
675    /// therefore an exhausted run rather than a refusal, and the walk carries on
676    /// to the next entry, which is what keeps a metadata edit from being
677    /// stranded behind a word someone typed. (A genuinely read-only body would
678    /// read the same way, and correctly: there is nothing there to take back.)
679    fn nothing_happened(&mut self, region: Region) -> bool {
680        match region {
681            Region::Body => {
682                self.mark_seen();
683                false
684            }
685            Region::Meta => {
686                self.journal.push(region);
687                true
688            }
689        }
690    }
691
692    /// Put back the most recently undone step, in the editor that made it — the
693    /// mirror of [`undo`](Self::undo), exhaustion-skipping and refusals
694    /// included.
695    ///
696    /// `true` when something was redone.
697    pub fn redo(&mut self) -> bool {
698        while let Some(region) = self.redo_journal.pop() {
699            if !self.has_redo(region) {
700                continue;
701            }
702            let before = self.counters();
703            match region {
704                Region::Body => self.body.redo(),
705                Region::Meta => {
706                    self.metadata.redo();
707                }
708            }
709            if self.counters() != before {
710                self.mark_seen();
711                self.journal.push(region);
712                return true;
713            }
714            match region {
715                Region::Body => self.mark_seen(),
716                Region::Meta => {
717                    self.redo_journal.push(region);
718                    return false;
719                }
720            }
721        }
722        false
723    }
724
725    /// Whether `region`'s editor has a step to take back.
726    fn has_undo(&self, region: Region) -> bool {
727        match region {
728            Region::Body => self.has_body && self.body.can_undo(),
729            Region::Meta => self.metadata.history_len() > 0,
730        }
731    }
732
733    /// Whether `region`'s editor has an undone step to put back.
734    fn has_redo(&self, region: Region) -> bool {
735        match region {
736            Region::Body => self.has_body && self.body.can_redo(),
737            Region::Meta => self.metadata.redo_len() > 0,
738        }
739    }
740
741    /// Both editors' change counters, for telling a step that happened from one
742    /// that was declined.
743    fn counters(&self) -> (u64, u64) {
744        (self.body.revision(), self.metadata.edit_seq())
745    }
746
747    /// Take the counters as read without journalling — what an undo or a redo
748    /// does, since both editors count their own history moves as changes and a
749    /// step back is not a new step.
750    fn mark_seen(&mut self) {
751        let (body, meta) = self.counters();
752        self.seen_body = body;
753        self.seen_meta = meta;
754    }
755
756    /// `true` if the metadata or the body has unsaved edits.
757    pub fn dirty(&self) -> bool {
758        self.metadata.dirty || (self.has_body && self.body.source != self.saved_body)
759    }
760
761    /// Reconcile the body edits into the document and return the full reassembled
762    /// text — exactly the bytes [`save`](Self::save) writes. Does not touch disk.
763    pub fn reassemble(&mut self) -> Result<String, SessionError> {
764        if self.has_body {
765            let body = self.body.source.clone();
766            self.metadata.backend_mut().set_body(&body).map_err(se)?;
767        }
768        Ok(self.metadata.source_snapshot())
769    }
770
771    /// Write the reassembled document (metadata edits + body edits) to disk.
772    pub fn save(&mut self) -> Result<(), SessionError> {
773        let full = self.reassemble()?;
774        std::fs::write(&self.path, full.as_bytes())
775            .map_err(|e| SessionError(format!("writing {}: {e}", self.path.display())))?;
776        self.saved_body = self.body.source.clone();
777        self.metadata.mark_saved();
778        Ok(())
779    }
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785
786    const DOC: &str = "\
787---
788# the title
789title: Old Title
790draft: true
791---
792# Heading
793
794Original body.
795";
796
797    fn preview_of(session: &DocumentSession, key: &str) -> Option<String> {
798        session
799            .metadata()
800            .rows
801            .iter()
802            .find(|r| r.path == [Seg::Key(key.into())])
803            .map(|r| r.preview.clone())
804    }
805
806    #[test]
807    fn reassembles_both_edits_without_disk() {
808        let mut session =
809            DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
810        assert!(session.has_body());
811        assert!(!session.dirty());
812
813        // Metadata edit (by path) + body edit (via leaf).
814        session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
815        session.body_mut().insert("Edited: ");
816        assert!(session.dirty());
817
818        let out = session.reassemble().unwrap();
819        assert!(out.contains("title: New Title"), "metadata edit:\n{out}");
820        assert!(out.contains("# the title"), "frontmatter comment:\n{out}");
821        assert!(out.contains("draft: true"), "sibling key:\n{out}");
822        assert!(out.contains("Edited: "), "body edit:\n{out}");
823        assert!(out.contains("Original body."), "rest of body:\n{out}");
824        assert!(out.starts_with("---\n"), "fences:\n{out}");
825    }
826
827    /// A key the workspace maintains keeps its row and refuses the edit — the
828    /// difference between "not shown" and "not yours to type".
829    #[test]
830    fn a_managed_key_is_drawn_and_declines_every_edit() {
831        const WITH_ID: &str = "---\ntitle: A Note\nid: ajp7eq\nmood: rainy\n---\n# Note\n";
832        let path = std::env::temp_dir().join("provui_core_session_managed.md");
833        let _ = std::fs::remove_file(&path);
834        std::fs::write(&path, WITH_ID).unwrap();
835
836        let facets = crate::Facets::default();
837        let mut session =
838            DocumentSession::open_managed(&path, None, facets.managed_key_names()).unwrap();
839
840        // Drawn: the row is there, with its value.
841        assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
842        assert!(session.metadata().is_derived(&[Seg::Key("id".into())]));
843
844        // And declined: the document is untouched and still clean.
845        session.set_metadata(&[Seg::Key("id".into())], Value::Str("typed".into()));
846        assert!(!session.dirty(), "a derived key takes no edit");
847        assert_eq!(preview_of(&session, "id").as_deref(), Some("ajp7eq"));
848
849        // An ordinary key beside it is unaffected.
850        session.set_metadata(&[Seg::Key("mood".into())], Value::Str("clear".into()));
851        assert!(session.dirty());
852
853        let _ = std::fs::remove_file(&path);
854    }
855
856    /// The heading above the caret, and the locator it names — the three
857    /// positions a caret can be in relative to a document's headings.
858    #[test]
859    fn the_heading_above_the_caret_is_what_a_locator_names() {
860        const PROSE: &str = "\
861---
862title: Notes
863---
864Preamble, above everything.
865
866# Crash Safety
867
868Why the journal is written first.
869
870## The *hard* part
871
872And what it costs.
873";
874        let mut session =
875            DocumentSession::from_text("notes.md", PROSE, BodyFormat::Markdown, None).unwrap();
876        let body = session.body().source.clone();
877        let at = |needle: &str| body.find(needle).expect(needle);
878
879        // Above the first heading there is nothing to name — not the document's
880        // title, which is not a place in the prose.
881        session.body_mut().caret = at("Preamble");
882        assert_eq!(session.heading_at_caret(), None);
883        assert_eq!(session.locator_at_caret(), None);
884
885        // Inside a section: the heading that opened it, not the nearest one in
886        // either direction.
887        session.body_mut().caret = at("Why the journal");
888        let heading = session.heading_at_caret().expect("under a heading");
889        assert_eq!(heading.text, "Crash Safety");
890        assert_eq!(heading.level, 1);
891        assert_eq!(&body[heading.span.clone()], "# Crash Safety");
892        assert_eq!(session.locator_at_caret().as_deref(), Some("crash-safety"));
893
894        // Deeper in, under the sub-heading — and its inline emphasis is part of
895        // the words, so the slug drops the markup the way prov's does.
896        session.body_mut().caret = at("And what it costs");
897        let heading = session.heading_at_caret().expect("under a heading");
898        assert_eq!(heading.level, 2);
899        assert_eq!(session.locator_at_caret().as_deref(), Some("the-hard-part"));
900    }
901
902    /// One undo across two editors, in the order the edits were actually made.
903    ///
904    /// The composition's real claim: leaf and flower each keep their own
905    /// history and neither knows the other exists, so a reader pressing undo
906    /// three times should walk back through body, metadata, body — not through
907    /// one editor's history and then the other's.
908    #[test]
909    fn one_undo_walks_back_through_both_editors_in_order() {
910        const DOC: &str = "---\ntitle: Old Title\n---\n# Heading\n\nOriginal body.\n";
911        let mut session =
912            DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
913        let title = [Seg::Key("title".into())];
914        let meta_value = |s: &DocumentSession| {
915            s.meta()
916                .get("title")
917                .and_then(|v| v.as_str())
918                .map(str::to_string)
919        };
920
921        assert!(!session.can_undo(), "nothing has happened yet");
922
923        // Body, metadata, body — each followed by the sync a host does once
924        // per event-loop iteration.
925        session.body_mut().caret = 0;
926        session.body_mut().insert("first ");
927        session.sync_history();
928        session.set_metadata(&title, Value::Str("New Title".into()));
929        session.sync_history();
930        session.body_mut().insert("second ");
931        session.sync_history();
932
933        assert_eq!(
934            session.journal(),
935            [Region::Body, Region::Meta, Region::Body],
936            "who took each step"
937        );
938        assert!(session.can_undo());
939        assert!(!session.can_redo());
940        assert!(session.body().source.contains("second "));
941        assert_eq!(meta_value(&session).as_deref(), Some("New Title"));
942
943        // Back through them, newest first. Each step names one editor, and the
944        // other is untouched by it.
945        assert!(session.undo(), "the second body edit");
946        assert!(!session.body().source.contains("second "));
947        assert!(session.body().source.contains("first "));
948        assert_eq!(
949            meta_value(&session).as_deref(),
950            Some("New Title"),
951            "the metadata edit is not what was undone"
952        );
953
954        assert!(session.undo(), "the metadata edit");
955        assert_eq!(meta_value(&session).as_deref(), Some("Old Title"));
956        assert!(
957            session.body().source.contains("first "),
958            "and the body is where the last undo left it"
959        );
960
961        assert!(session.undo(), "the first body edit");
962        assert!(!session.body().source.contains("first "));
963        assert_eq!(meta_value(&session).as_deref(), Some("Old Title"));
964
965        assert!(!session.can_undo(), "back at the document that was opened");
966        assert!(!session.undo());
967
968        // And forward again, in the order they were made.
969        assert!(session.can_redo());
970        assert!(session.redo());
971        assert!(session.body().source.contains("first "));
972        assert_eq!(meta_value(&session).as_deref(), Some("Old Title"));
973
974        assert!(session.redo());
975        assert_eq!(meta_value(&session).as_deref(), Some("New Title"));
976
977        assert!(session.redo());
978        assert!(session.body().source.contains("second "));
979        assert!(!session.can_redo());
980
981        // A fresh edit closes the redo journal, the way it closes either
982        // editor's own.
983        session.undo();
984        assert!(session.can_redo());
985        session.set_metadata(&title, Value::Str("A Third Title".into()));
986        session.sync_history();
987        assert!(!session.can_redo(), "the branch that was not taken is gone");
988    }
989
990    /// leaf decides its own step boundaries, so a run of typing is some number
991    /// of journal entries and some smaller number of leaf steps. The session
992    /// undoes *one leaf step* and then walks past the entries leaf has nothing
993    /// left to answer for, rather than making a reader press the key once per
994    /// character for nothing.
995    #[test]
996    fn a_coalesced_run_of_typing_does_not_cost_a_keypress_per_character() {
997        const DOC: &str = "---\ntitle: Old Title\n---\n# Heading\n\nOriginal body.\n";
998        let mut session =
999            DocumentSession::from_text("note.md", DOC, BodyFormat::Markdown, None).unwrap();
1000        session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
1001        session.sync_history();
1002
1003        session.body_mut().caret = 0;
1004        for c in "typed".chars() {
1005            session.body_mut().insert(&c.to_string());
1006            session.sync_history();
1007        }
1008        assert_eq!(
1009            session
1010                .journal()
1011                .iter()
1012                .filter(|r| **r == Region::Body)
1013                .count(),
1014            5,
1015            "one entry per character, because one revision per character"
1016        );
1017
1018        // However many leaf steps those five characters became, walking the
1019        // body back to where it started and then once more reaches the metadata
1020        // edit — it is never stranded behind the run.
1021        let mut presses = 0;
1022        while session.body().source.starts_with("typed") {
1023            assert!(session.undo(), "still something to take back");
1024            presses += 1;
1025            assert!(presses <= 5, "no more presses than there were characters");
1026        }
1027        eprintln!(
1028            "JOURNAL {:?} hist={} presses={} body={:?}",
1029            session.journal(),
1030            session.metadata().history_len(),
1031            presses,
1032            &session.body().source[..20.min(session.body().source.len())]
1033        );
1034        assert!(session.undo(), "and the metadata edit is next, not buried");
1035        assert_eq!(
1036            session.meta().get("title").and_then(|v| v.as_str()),
1037            Some("Old Title")
1038        );
1039        assert!(!session.can_undo());
1040    }
1041
1042    /// The whole composition, end to end on disk: open → edit both regions →
1043    /// save → reopen, with comments, fences, untouched keys and untouched body
1044    /// all still there. The in-memory test above proves the splice; this one
1045    /// proves the bytes survive a round trip through the filesystem, which is
1046    /// the only place a lossless claim can actually be falsified.
1047    #[test]
1048    fn open_edit_save_reopen_round_trip_on_disk() {
1049        let path = std::env::temp_dir().join("provui_core_document_session_round_trip.md");
1050        let _ = std::fs::remove_file(&path);
1051        std::fs::write(&path, DOC).unwrap();
1052
1053        // Open, edit both regions, save.
1054        let mut session = DocumentSession::open(&path).unwrap();
1055        assert_eq!(preview_of(&session, "title").as_deref(), Some("Old Title"));
1056        session.set_metadata(&[Seg::Key("title".into())], Value::Str("New Title".into()));
1057        session.body_mut().insert("Edited: ");
1058        session.save().unwrap();
1059        assert!(!session.dirty(), "clean after save");
1060
1061        // The bytes on disk carry both edits, everything else preserved.
1062        let saved = std::fs::read_to_string(&path).unwrap();
1063        assert!(
1064            saved.contains("title: New Title"),
1065            "saved metadata:\n{saved}"
1066        );
1067        assert!(saved.contains("# the title"), "saved comment:\n{saved}");
1068        assert!(saved.contains("Edited: "), "saved body:\n{saved}");
1069        assert!(
1070            saved.contains("Original body."),
1071            "saved body rest:\n{saved}"
1072        );
1073
1074        // Reopening parses cleanly and reflects both edits.
1075        let reopened = DocumentSession::open(&path).unwrap();
1076        assert_eq!(preview_of(&reopened, "title").as_deref(), Some("New Title"));
1077        assert!(reopened.body().source.contains("Edited: "));
1078
1079        let _ = std::fs::remove_file(&path);
1080    }
1081}