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}