Skip to main content

flower_core/
model.rs

1//! The frontend-neutral editor model and its structural operations.
2//!
3//! `Model` is generic over a [`Backend`]: it builds path-addressed [`EditOp`]s,
4//! applies them through the backend, and re-derives its view from
5//! [`Backend::to_value`] after each change. It owns no editor, no format, no
6//! filesystem, and no terminal — the backend owns the document; the embedder
7//! owns file I/O and rendering.
8
9use std::collections::{HashMap, HashSet};
10
11use anyhow::Result;
12use fig::Value;
13
14use crate::annotate::{self, Annotation};
15use crate::backend::{Backend, EditOp};
16use crate::page::{self, InlineBudget, Page, PageItem};
17use crate::schema::{self, Choice, FieldRule, FieldRuleExt, Schema};
18use crate::tree::{self, Row, Seg};
19use fig_schema::{Issue, SegPat, Validation};
20
21/// Which projection the frontend is navigating: the whole-document
22/// [`tree`](crate::tree), or one [`page`](crate::page) at a time.
23///
24/// The document is unaffected — both are views over the same `Value`, and every
25/// edit is path-addressed, so switching mid-session changes what you can see and
26/// nothing about what you can do.
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
28pub enum ViewMode {
29    /// Every visible node at once, indented by depth. Best when the whole
30    /// document fits on a screen and you want to read it as a document.
31    #[default]
32    Tree,
33    /// One container at a time, pushed and popped. Best when it doesn't.
34    Pages,
35}
36
37/// Interaction mode: normal navigation, or editing one text field of a node.
38pub enum Mode {
39    Normal,
40    /// Picking a value off a list rather than typing one
41    /// ([`Model::begin_choose`]).
42    ///
43    /// A mode of its own rather than an editor seeded with a list, because the
44    /// two take different keys: everything printable is a *filter* here and a
45    /// value there, and `Enter` commits a row rather than a buffer.
46    Choosing {
47        /// The node being chosen for.
48        path: Vec<Seg>,
49        /// Everything on offer, unfiltered and in the order it was offered.
50        choices: Vec<Choice>,
51        /// Which of the *filtered* choices the cursor is on — see
52        /// [`Model::visible_choices`].
53        selected: usize,
54        /// What has been typed to narrow the list.
55        filter: String,
56    },
57    Editing {
58        buffer: String,
59        /// The node being edited. Held here rather than re-read from the
60        /// selection on commit, so an edit belongs to a *node* and not to
61        /// whichever list the cursor happens to be in — the two projections
62        /// index differently, and a commit must not care which one opened it.
63        path: Vec<Seg>,
64        /// Which of the node's texts the buffer holds.
65        slot: EditSlot,
66    },
67}
68
69/// The text of a node an inline editor can hold: its value, or one of the two
70/// comments fig anchors to it.
71///
72/// One editor, three targets, because they are typed the same way — a buffer
73/// in a footer, `Enter` to commit — and differ only in what the commit writes.
74/// A frontend that draws a different affordance per slot (a multi-line box for
75/// a leading block) reads this to choose it.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum EditSlot {
78    /// The scalar's value, coerced by shape (or by schema) on commit.
79    Value,
80    /// The own-line comment block above the node. May hold newlines; an empty
81    /// buffer commits as *no comment*, removing the block.
82    LeadingComment,
83    /// The same-line comment after the value. Single-line; an empty buffer
84    /// removes it.
85    TrailingComment,
86}
87
88pub struct Model<B> {
89    backend: B,
90
91    /// Derived view state, rebuilt from `backend.to_value()` after every edit.
92    value: Value,
93    pub rows: Vec<Row>,
94    collapsed: HashSet<Vec<Seg>>,
95    /// Top-level mapping keys to hide from the row projection (but keep in the
96    /// document). Empty for a standalone config; a prov/diaryx embedder passes the
97    /// managed-key set so those fields stay lossless and out of view.
98    hidden: HashSet<String>,
99    /// Top-level mapping keys the *workspace* maintains: shown, but not editable.
100    ///
101    /// The complement of [`hidden`](Self::hidden), for the other kind of managed
102    /// field. A hidden key is edited through some other affordance (a title bar,
103    /// a link view) and would only clutter the list; a derived key — a recomputed
104    /// timestamp, a content hash — has no other affordance because *nothing*
105    /// edits it by hand: the workspace overwrites it on the next write. Hiding
106    /// those two alike leaves a user wondering where a field they can see in the
107    /// file went, so a derived key keeps its row and declines edits instead.
108    derived: HashSet<String>,
109    /// Top-level mapping keys the page projection lists *below* the rest — a
110    /// page's "advanced" section (see [`page::PageItem::demoted`]).
111    ///
112    /// The third answer to "who edits this field?", after `hidden` (something
113    /// else does, and its row would only clutter) and `derived` (nothing does).
114    /// A demoted key is edited here like any other; it is just not what the
115    /// reader came for. Relations, identity, a title the title bar owns: real
116    /// fields, worth showing, worth showing last.
117    ///
118    /// Holds the union with [`derived`](Self::derived), maintained by
119    /// [`set_demoted`](Self::set_demoted) — a key nothing can meaningfully edit
120    /// is the clearest case there is for sinking it below the ones you can.
121    demoted: HashSet<String>,
122    /// What the host has to say about particular nodes — host state, re-attached
123    /// to the rows on every rebuild ([`annotate`](crate::annotate)).
124    annotations: Vec<Annotation>,
125    /// The schema governing this document, if any — from the backend
126    /// ([`Backend::schema`]) or injected by the embedder ([`Model::set_schema`]).
127    /// Drives type-directed parsing and commit-time value validation; absent, the
128    /// model behaves exactly as before.
129    schema: Option<Schema>,
130
131    /// The selected row of the **tree** projection — an index into
132    /// [`rows`](Self::rows), and meaningless against a page.
133    ///
134    /// Private, and the one piece of cursor state that is. A row index only says
135    /// what it means in the projection it was read from, and a public field
136    /// cannot check which projection a caller is in — so writing it goes through
137    /// [`select_row`](Self::select_row), which can.
138    selected: usize,
139    pub mode: Mode,
140    /// The last thing that happened worth saying out loud — almost always a
141    /// refusal (`rejected: ...`, `only mapping keys can be renamed`).
142    ///
143    /// Empty until something happens. A frontend draws this in whatever it uses
144    /// for a status line, and an empty string is what lets it draw *nothing*:
145    /// a bar that opens holding a word nobody asked for teaches the reader to
146    /// stop reading it, which is the one thing a refusal channel cannot afford.
147    pub status: String,
148    pub dirty: bool,
149
150    // ── page view ─────────────────────────────────────────────────────────
151    /// Which projection is being navigated. Both are kept live: the model has no
152    /// idea how much width the frontend has, and rebuilding the unused one costs
153    /// a walk of a tree that was just rebuilt anyway.
154    view: ViewMode,
155    /// How much of a container's subtree the page projection inlines rather
156    /// than drills ([`page::InlineBudget`]). The default is the settings-menu
157    /// rule; an embedder that knows its room raises it
158    /// ([`set_inline_budget`](Self::set_inline_budget)).
159    inline_budget: InlineBudget,
160    /// The container the page view is currently listing. Empty is the root.
161    focus: Vec<Seg>,
162    /// The page at [`focus`](Self::focus).
163    page: Page,
164    /// The root's page. Kept for the "is there anything to navigate at all?"
165    /// question ([`pages_would_degenerate`](Self::pages_would_degenerate)), which
166    /// is about the document rather than about where you are in it.
167    root_page: Page,
168    /// The page one level out from [`focus`](Self::focus) — the list you were
169    /// looking at when you opened the current one.
170    ///
171    /// A two-pane frontend shows this on the left, so the pair of panes is a
172    /// window sliding along the lineage rather than a fixed sidebar: the left is
173    /// always the page the right came out of, at every depth.
174    parent_page: Page,
175    /// The selected item on [`page`](Self::page).
176    page_selected: usize,
177    /// Where the cursor was on each page we have left, so popping back restores
178    /// it rather than dumping you at the top.
179    ///
180    /// Only a fallback: coming back normally re-finds the child you drilled into,
181    /// which survives edits that shift indices. This is what answers when that
182    /// child is *gone* — you opened a key and deleted it — and the cursor would
183    /// otherwise have nothing to return to.
184    page_memory: HashMap<Vec<Seg>, usize>,
185
186    // ── history ───────────────────────────────────────────────────────────
187    /// The edits made, newest last, each with the ops that undo it — the
188    /// journal [`undo`](Self::undo) walks back through.
189    undo_stack: Vec<Change>,
190    /// The edits undone, newest last, each replayable by [`redo`](Self::redo).
191    /// Cleared by the next fresh [`commit`](Self::commit).
192    redo_stack: Vec<Change>,
193    /// A number that goes up on every successful commit, undo and redo — see
194    /// [`edit_seq`](Self::edit_seq).
195    edit_seq: u64,
196    /// The source as of the last save (or the open), against which
197    /// [`dirty`](Self::dirty) is recomputed. Undoing back to it reads as clean,
198    /// which is why the flag is derived from the bytes rather than from how
199    /// deep the journal is.
200    saved_source: String,
201}
202
203/// What the page view was pointing at, by identity, at the moment an edit was
204/// applied — see [`Model::identities`].
205///
206/// A path addresses a sequence item by position, so a reorder or a delete of an
207/// earlier sibling re-points every path after it. Holding the *keys* alongside
208/// the indices is what lets the model put the cursor and the open page back on
209/// the item they were on rather than on whatever has since taken its number.
210struct Identities {
211    /// One entry per segment of the focus.
212    focus: Vec<Option<String>>,
213    /// The remembered cursors, each with the keys of its own path.
214    memory: Vec<(Vec<Seg>, Vec<Option<String>>)>,
215}
216
217/// One committed edit, with what it takes to undo it and to do it again.
218///
219/// The inverse is a *list* because two ops in [`EditOp`]'s vocabulary do not
220/// invert to one: restoring a deleted mapping entry is an insert (which
221/// appends) plus the reorder that puts it back where it was, and restoring a
222/// removed sequence item is an append plus a move. Its comments ride along the
223/// same way — fig anchors a comment to the node, so setting it after the insert
224/// and before the reorder leaves it attached through both.
225#[derive(Debug, Clone)]
226struct Change {
227    /// The op as committed — what [`Model::redo`] replays.
228    forward: EditOp,
229    /// The ops that put the document back, in order.
230    inverse: Vec<EditOp>,
231    /// Where the cursor was anchored by the commit, so undoing puts the row
232    /// that changes back on screen.
233    anchor: Vec<Seg>,
234}
235
236impl<B: Backend> Model<B> {
237    /// Build a model over `backend`.
238    pub fn new(backend: B) -> Result<Self> {
239        Self::with_hidden(backend, Vec::new())
240    }
241
242    /// Build a model that hides the given **top-level** mapping keys from the row
243    /// projection while keeping them in the document (see
244    /// [`tree::build_rows`](crate::tree::build_rows)). For an embedder whose
245    /// format reserves some top-level keys (prov/diaryx-managed frontmatter).
246    pub fn with_hidden(backend: B, hidden: Vec<String>) -> Result<Self> {
247        Self::with_managed(backend, hidden, Vec::new())
248    }
249
250    /// Build a model over `backend` distinguishing the two kinds of managed key:
251    /// `hidden` ones produce no row (edited through another affordance), while
252    /// `derived` ones keep their row but decline every edit (the workspace
253    /// maintains them — see [`derived`](Self::derived)).
254    ///
255    /// A key in both is hidden: no row means nothing to mark read-only.
256    pub fn with_managed(backend: B, hidden: Vec<String>, derived: Vec<String>) -> Result<Self> {
257        Self::with_collapsed(backend, hidden, derived, Vec::new())
258    }
259
260    /// Build a model whose containers at `collapsed` arrive **shut**, before the
261    /// first row list is ever built.
262    ///
263    /// A document can have one field nobody reads as a list: an index document's
264    /// `contents` is one row per child — ninety-five of them in a year index,
265    /// ahead of the four fields anyone types by hand. Such a section wants to open
266    /// as a summary, not a wall you scroll past. Toggling it afterwards through
267    /// [`activate`](Self::activate) would work, but that is the *interactive*
268    /// door: it moves the selection and rebuilds the row list once per container.
269    /// Seeding the set here costs neither — the paths are in place before
270    /// `reload`, so the opening frame is already correct.
271    ///
272    /// A path that names a scalar (or nothing at all) is inert rather than an
273    /// error, so a caller can name the keys it *wants* collapsed without first
274    /// checking which of them turned out to be containers.
275    pub fn with_collapsed(
276        backend: B,
277        hidden: Vec<String>,
278        derived: Vec<String>,
279        collapsed: Vec<Vec<Seg>>,
280    ) -> Result<Self> {
281        // The backend supplies the schema when it knows one (a prov backend);
282        // otherwise it stays `None` until an embedder injects one.
283        let schema = backend.schema();
284        let mut model = Model {
285            backend,
286            value: Value::Null,
287            rows: Vec::new(),
288            collapsed: collapsed.into_iter().collect(),
289            hidden: hidden.into_iter().collect(),
290            // Every derived key starts demoted; `set_demoted` adds the
291            // embedder's own to that floor rather than replacing it.
292            demoted: derived.iter().cloned().collect(),
293            derived: derived.into_iter().collect(),
294            schema,
295            annotations: Vec::new(),
296            selected: 0,
297            mode: Mode::Normal,
298            // Nothing has happened yet, so there is nothing to report. See
299            // `status`.
300            status: String::new(),
301            dirty: false,
302            view: ViewMode::default(),
303            inline_budget: InlineBudget::default(),
304            focus: Vec::new(),
305            page: Page::default(),
306            root_page: Page::default(),
307            parent_page: Page::default(),
308            page_selected: 0,
309            page_memory: HashMap::new(),
310            undo_stack: Vec::new(),
311            redo_stack: Vec::new(),
312            edit_seq: 0,
313            saved_source: String::new(),
314        };
315        model.reload()?;
316        // The bytes the document opened with are the baseline `dirty` is
317        // measured against, until the embedder saves and moves it.
318        model.saved_source = model.source_snapshot();
319        Ok(model)
320    }
321
322    /// Name the top-level keys the page projection sinks below the rest.
323    ///
324    /// Out-of-band like [`set_schema`](Self::set_schema), and for the same
325    /// reason: it is presentation the *embedder* knows and the document does
326    /// not. A diaryx host knows `part_of` is drawn by the sidebar and `id` by
327    /// nothing at all; the fig-backed model reading the same frontmatter has no
328    /// way to tell either from a field somebody typed.
329    ///
330    /// Adds to the derived keys already demoted rather than replacing them, so a
331    /// caller names only what the constructor did not. Rebuilds the pages, so
332    /// the next [`page`](Self::page) already reflects it.
333    ///
334    /// Root keys, matched exactly. A path is demoted when its *first* segment is
335    /// one of these, so naming a container demotes everything under it.
336    pub fn set_demoted(&mut self, keys: Vec<String>) {
337        self.demoted.extend(keys);
338        self.rebuild_pages();
339    }
340
341    /// Set how much of a container's subtree the page projection inlines rather
342    /// than drills.
343    ///
344    /// Out-of-band like [`set_demoted`](Self::set_demoted), and for the same
345    /// reason: the right amount is a fact about the *room* the pages are drawn
346    /// in — a frontmatter panel wants the whole document on one page, a narrow
347    /// pane over a deep config wants a page per level — and only the embedder
348    /// knows which it is. Rebuilds the pages, so the next
349    /// [`page`](Self::page) already reflects it.
350    ///
351    /// The cursor stays on the row it was on, by path rather than by index — a
352    /// budget is the one setting that changes how many rows a page has, so the
353    /// index under the cursor is exactly what it invalidates. Raising the budget
354    /// far enough turns row three of a list into the third field of its first
355    /// entry, and a reader who resized a window did not ask to be moved.
356    pub fn set_inline_budget(&mut self, budget: InlineBudget) {
357        let was_on = self.page_item().map(|i| i.path.clone());
358        self.inline_budget = budget;
359        self.rebuild_pages();
360        if let Some(path) = was_on
361            && let Some(i) = self.page.position_of(&path)
362        {
363            self.page_selected = i;
364        }
365    }
366
367    /// The inline budget the page projection is currently built with.
368    pub fn inline_budget(&self) -> InlineBudget {
369        self.inline_budget
370    }
371
372    /// Set the inline budget from the room a page actually has
373    /// ([`InlineBudget::fitting`]) — `room` being how many rows a frontend can
374    /// draw items into, once its own chrome has taken what it needs.
375    ///
376    /// [`set_inline_budget`](Self::set_inline_budget) is the embedder deciding;
377    /// this is the embedder *measuring*, which is the same decision made against
378    /// the one fact that turns out to settle it. A frontend that can be resized
379    /// calls this whenever the room changes, which is cheap to do every frame:
380    /// the pages are rebuilt only when the answer moves.
381    pub fn fit_to_room(&mut self, room: usize) {
382        let budget = InlineBudget::fitting(&self.value, room);
383        if budget != self.inline_budget {
384            self.set_inline_budget(budget);
385        }
386    }
387
388    /// Open the document at the first page that says something.
389    ///
390    /// A document whose root holds one container — `{repo: [...]}`, and every
391    /// file that is one list under one key — has a root page with a single drill
392    /// row on it, naming the thing you are obviously about to open. That is the
393    /// same page [`PageItem::descend_to`] exists to skip, arrived at from
394    /// outside rather than from a row, and the reasons match: it costs a
395    /// navigation step to be told the name of the file you just opened.
396    ///
397    /// Called once, by the frontend, after the budget is set — it is a decision
398    /// about where to *start*, not a property of the projection, and re-running
399    /// it on every rebuild would take the root page away from a reader who had
400    /// pressed `h` to reach it. Nothing is lost either way: the root page is one
401    /// step out, and the row's own ops are the ops of the container this lands
402    /// in.
403    pub fn enter_document(&mut self) {
404        while self.page.items.len() == 1 && self.page.items[0].is_drill() {
405            self.focus = self.page.items[0].descend_to.clone();
406            self.page_selected = 0;
407            self.rebuild_pages();
408        }
409    }
410
411    /// Whether the node at `path` sits under a demoted top-level key — the
412    /// page projection's own [`is_derived`](Self::is_derived).
413    pub fn is_demoted(&self, path: &[Seg]) -> bool {
414        matches!(path.first(), Some(Seg::Key(k)) if self.demoted.contains(k))
415    }
416
417    /// Inject a schema out-of-band — the embedder precedent, mirroring
418    /// [`with_hidden`](Self::with_hidden). For a host whose backend does not
419    /// supply one but that *knows* the governing schema (a diaryx host feeding a
420    /// fig-backed frontmatter block plus its resolved workspace config).
421    pub fn set_schema(&mut self, schema: Schema) {
422        self.schema = Some(schema);
423    }
424
425    /// The schema governing the document, if any.
426    pub fn schema(&self) -> Option<&Schema> {
427        self.schema.as_ref()
428    }
429
430    /// The schema rule governing the node at `path`, if any — for a frontend
431    /// deciding a widget (a picker for an enum field) or presentation.
432    pub fn rule_at(&self, path: &[Seg]) -> Option<&FieldRule> {
433        self.schema.as_ref().and_then(|s| s.rule_for(path))
434    }
435
436    /// Hand the model the host's findings about this document, replacing
437    /// whatever it was given last — a broken link, a duplicate id, a rule only
438    /// a workspace can check ([`annotate`](crate::annotate)).
439    ///
440    /// Out-of-band like [`set_schema`](Self::set_schema) and
441    /// [`set_demoted`](Self::set_demoted), and for the same reason: flower-core
442    /// is one document with no filesystem, so it can never compute one of
443    /// these. They are re-attached to the rows on every rebuild, so an edit
444    /// does not wipe the markers out from under a reader — but nothing here
445    /// re-checks them either, so a host refreshes after a save (or whenever its
446    /// own check finishes) by calling this again. An empty list clears them.
447    pub fn set_annotations(&mut self, annotations: Vec<Annotation>) {
448        self.annotations = annotations;
449        self.rebuild_rows();
450        self.rebuild_pages();
451    }
452
453    /// The findings the host last supplied, in the order it gave them.
454    pub fn annotations(&self) -> &[Annotation] {
455        &self.annotations
456    }
457
458    /// The finding that applies at `path`: the one addressed exactly at it, or
459    /// failing that the one at its nearest annotated ancestor.
460    ///
461    /// The inheriting answer, for a caller *asking about a node* — an item of a
462    /// list is in trouble when the list is. The rows carry the exact answer
463    /// instead ([`PageItem::annotation`](crate::PageItem::annotation)), because
464    /// a marker that came down a subtree would point at every row but the one
465    /// that is wrong.
466    pub fn annotation_at(&self, path: &[Seg]) -> Option<&Annotation> {
467        annotate::applying_at(&self.annotations, path)
468    }
469
470    /// The kind of the document root, for a frontend deciding how to add a
471    /// top-level entry: `"map"`, `"seq"`, or `"scalar"`.
472    pub fn root_kind(&self) -> &'static str {
473        match self.value {
474            Value::Map(_) => "map",
475            Value::Seq(_) => "seq",
476            _ => "scalar",
477        }
478    }
479
480    /// How many of the hidden top-level keys are actually present in the document
481    /// — for a "N managed fields" affordance.
482    pub fn hidden_present(&self) -> usize {
483        match &self.value {
484            Value::Map(entries) => entries
485                .iter()
486                .filter(|(k, _)| matches!(k, Value::Str(s) if self.hidden.contains(s)))
487                .count(),
488            _ => 0,
489        }
490    }
491
492    /// Whether the node at `path` sits under a workspace-maintained (derived)
493    /// top-level key — for a frontend rendering it read-only rather than as an
494    /// editable control. Edits to it are declined at the commit funnel regardless.
495    pub fn is_derived(&self, path: &[Seg]) -> bool {
496        matches!(path.first(), Some(Seg::Key(k)) if self.derived.contains(k))
497    }
498
499    /// The schema-declared top-level fields the document does **not** yet carry
500    /// — what an "add field" affordance offers, so a declared field is reachable
501    /// before it exists.
502    ///
503    /// Rows are projected from the *document*
504    /// ([`build_rows`](crate::tree::build_rows)), so a field the schema declares
505    /// but the document omits has no row and is otherwise unreachable: the user
506    /// would have to know the key and type it exactly. This closes that gap —
507    /// it is the schema's half of the row list, and the reason a declared type
508    /// is worth writing down for a field that is empty.
509    ///
510    /// Only a rule addressing exactly one top-level key names an addable field:
511    /// an each-item or subtree rule governs *within* a field rather than naming
512    /// one. Hidden (managed) keys are never offered — the embedder reserves
513    /// those. Order follows the schema's own rule order, so a caller can present
514    /// them as declared.
515    pub fn addable_fields(&self) -> Vec<&FieldRule> {
516        let Some(schema) = &self.schema else {
517            return Vec::new();
518        };
519        // Only a map root can take a top-level key at all.
520        let Value::Map(entries) = &self.value else {
521            return Vec::new();
522        };
523        let present: HashSet<&str> = entries
524            .iter()
525            .filter_map(|(k, _)| match k {
526                Value::Str(s) => Some(s.as_str()),
527                _ => None,
528            })
529            .collect();
530        let mut seen = HashSet::new();
531        schema
532            .rules()
533            .iter()
534            .filter(|rule| {
535                let [SegPat::Key(name)] = rule.at.0.as_slice() else {
536                    return false;
537                };
538                !present.contains(name.as_str())
539                    && !self.hidden.contains(name)
540                    && seen.insert(name.as_str())
541            })
542            .collect()
543    }
544
545    /// The canonical serialized document — what the embedder writes on save.
546    pub fn source_snapshot(&self) -> String {
547        self.backend.source().unwrap_or_default()
548    }
549
550    /// The backend, for backend-specific reads (e.g. a prov backend's body).
551    pub fn backend(&self) -> &B {
552        &self.backend
553    }
554
555    /// The backend, for backend-specific operations that do **not** change the
556    /// metadata tree flower renders (e.g. replacing a prov document's prose
557    /// body). An op that *does* change the metadata leaves the view stale — go
558    /// through the model's own edit methods for those.
559    pub fn backend_mut(&mut self) -> &mut B {
560        &mut self.backend
561    }
562
563    pub fn set_status(&mut self, s: impl Into<String>) {
564        self.status = s.into();
565    }
566
567    /// Clear the dirty flag after the embedder has persisted the source.
568    ///
569    /// Moves the baseline [`dirty`](Self::dirty) is measured against to the
570    /// bytes just written, and leaves the journal alone: a save is not a
571    /// history boundary, so undo still runs back through it — and undoing to
572    /// the saved text reads as clean again, because the flag is a comparison
573    /// and not a count.
574    pub fn mark_saved(&mut self) {
575        self.saved_source = self.source_snapshot();
576        self.dirty = false;
577    }
578
579    // ── stable identity for a sequence item ───────────────────────────────
580
581    /// A stable identity for item `index` of the sequence at `seq_path`, or
582    /// `None` when nothing can name it.
583    ///
584    /// The backend first ([`Backend::item_key`]) — it is the component that may
585    /// have a real identity to hand — and otherwise the projection's own guess:
586    /// a mapping item is named by whichever of its fields best names it on a
587    /// page ([`page::title_keys`]/[`page::title_of`], the same answer the row
588    /// already shows), and a scalar item by its own text. Neither is guaranteed
589    /// unique, which is why every use of this treats a repeat as "the first
590    /// one": a list of five identical strings has nothing to tell its items
591    /// apart with, and behaving as though it did would be worse than falling
592    /// back to the index.
593    ///
594    /// Public because a host holding an id of its own — a navigation stack, a
595    /// breadcrumb — needs the same answer the model re-resolves against.
596    pub fn item_key(&self, seq_path: &[Seg], index: usize) -> Option<String> {
597        if let Ok(Some(key)) = self.backend.item_key(seq_path, index) {
598            return Some(key);
599        }
600        let Some(Value::Seq(items)) = self.value_at(seq_path) else {
601            return None;
602        };
603        let item = items.get(index)?;
604        match item {
605            Value::Map(_) => page::title_of(&page::title_keys(items), item),
606            Value::Seq(_) => None,
607            scalar => Some(tree::edit_seed(scalar)),
608        }
609    }
610
611    /// The identity of every indexed step of `path`, taken against the tree as
612    /// it stands — `None` at a step that is a key, or an item nothing names.
613    fn keys_along(&self, path: &[Seg]) -> Vec<Option<String>> {
614        path.iter()
615            .enumerate()
616            .map(|(i, seg)| match seg {
617                Seg::Index(index) => self.item_key(&path[..i], *index),
618                Seg::Key(_) => None,
619            })
620            .collect()
621    }
622
623    /// `path`, with every indexed step moved to wherever the item it named has
624    /// ended up.
625    ///
626    /// A step whose item is gone, or that nothing could name, keeps its index
627    /// and is left to the clamping the rebuild already does: a page whose
628    /// container was deleted pops to the nearest surviving ancestor, which is
629    /// the behaviour that was there before identity was.
630    fn resolve_against(&self, path: &[Seg], keys: &[Option<String>]) -> Vec<Seg> {
631        let mut out: Vec<Seg> = Vec::with_capacity(path.len());
632        for (i, seg) in path.iter().enumerate() {
633            match (seg, keys.get(i).and_then(Option::as_ref)) {
634                (Seg::Index(index), Some(want)) => {
635                    let len = self.seq_len(&out);
636                    let found = (0..len).find(|j| self.item_key(&out, *j).as_ref() == Some(want));
637                    out.push(Seg::Index(found.unwrap_or(*index)));
638                }
639                _ => out.push(seg.clone()),
640            }
641        }
642        out
643    }
644
645    /// Where the page view is standing, by identity rather than by index — what
646    /// an edit elsewhere in the document must not be allowed to re-point.
647    ///
648    /// Taken *before* an edit is applied, because an identity is read off the
649    /// tree the path was taken against, and restored after: see
650    /// [`restore_identities`](Self::restore_identities).
651    fn identities(&self) -> Identities {
652        Identities {
653            focus: self.keys_along(&self.focus),
654            memory: self
655                .page_memory
656                .keys()
657                .map(|path| (path.clone(), self.keys_along(path)))
658                .collect(),
659        }
660    }
661
662    /// Move [`focus`](Self::focus) and the remembered cursors onto whatever the
663    /// items they named have become.
664    ///
665    /// Run against the *new* tree, so every `item_key` call here reads the
666    /// document as the edit left it. A reorder moves a path; a delete of an
667    /// earlier sibling shifts it down; an append leaves it alone — and none of
668    /// the three is a special case, because all three are "where did the thing
669    /// I was looking at go".
670    fn restore_identities(&mut self, ids: Identities) {
671        self.focus = self.resolve_against(&self.focus.clone(), &ids.focus);
672        let memory = std::mem::take(&mut self.page_memory);
673        self.page_memory = ids
674            .memory
675            .into_iter()
676            .filter_map(|(path, keys)| {
677                let selected = memory.get(&path)?;
678                Some((self.resolve_against(&path, &keys), *selected))
679            })
680            .collect();
681    }
682
683    // ── view derivation ───────────────────────────────────────────────────────
684
685    /// Re-derive `value` + `rows` from the backend's current tree.
686    fn reload(&mut self) -> Result<()> {
687        self.reload_keeping(None)
688    }
689
690    /// [`reload`](Self::reload), re-pointing the page view's paths at the items
691    /// they named before the edit when `ids` says what those were.
692    ///
693    /// Between reading the tree and rebuilding the pages, because the focus a
694    /// page is built from must already be the corrected one — rebuilding twice
695    /// would draw one frame of the wrong page.
696    fn reload_keeping(&mut self, ids: Option<Identities>) -> Result<()> {
697        self.value = self
698            .backend
699            .to_value()
700            .map_err(|e| anyhow::anyhow!("reading value tree: {e}"))?;
701        if let Some(ids) = ids {
702            self.restore_identities(ids);
703        }
704        self.rebuild_rows();
705        self.rebuild_pages();
706        Ok(())
707    }
708
709    fn rebuild_rows(&mut self) {
710        self.rows = tree::build_rows(&self.value, &self.collapsed, &self.hidden);
711        for row in &mut self.rows {
712            row.annotation = annotate::exactly_at(&self.annotations, &row.path).cloned();
713        }
714        if self.selected >= self.rows.len() {
715            self.selected = self.rows.len().saturating_sub(1);
716        }
717    }
718
719    /// Re-derive the focused page and the root page from `value`.
720    ///
721    /// Runs on every reload, whichever view is active: see
722    /// [`view`](Self::view) for why both projections are kept live.
723    fn rebuild_pages(&mut self) {
724        self.reanchor_focus();
725        self.root_page = self.page_at(&[]);
726        self.page = if self.focus.is_empty() {
727            self.root_page.clone()
728        } else {
729            self.page_at(&self.focus.clone())
730        };
731        self.parent_page = if self.focus.is_empty() {
732            Page::default()
733        } else {
734            // The pane you came out of is the pane you *actually* came out of.
735            //
736            // One level out is the wrong answer once a row can compress: opening
737            // `exports › journal` skips the `exports` page precisely because it
738            // holds nothing but that one row, and drawing it on the left would
739            // spend half a wide layout on the page the compression existed to
740            // spare you. So walk out past every level a row compressed past, and
741            // stop at the page that actually lists the row that was tapped.
742            let mut parent = &self.focus[..self.focus.len() - 1];
743            while !parent.is_empty()
744                && page::is_compressed_past(&self.value, parent, &self.hidden, self.inline_budget)
745            {
746                parent = &parent[..parent.len() - 1];
747            }
748            self.page_at(parent)
749        };
750        if self.page_selected >= self.page.items.len() {
751            self.page_selected = self.page.items.len().saturating_sub(1);
752        }
753    }
754
755    /// Walk `focus` back to the nearest ancestor that is still a container.
756    ///
757    /// The focus is the one piece of page state the document can invalidate from
758    /// underneath: delete the key you are standing inside, or replace it with a
759    /// scalar, and the page has nothing to list. Popping to the nearest surviving
760    /// ancestor is what a settings menu does when a section disappears — you end
761    /// up one level out, rather than on a blank page or back at the root.
762    fn reanchor_focus(&mut self) {
763        while !self.focus.is_empty()
764            && !tree::value_at(&self.value, &self.focus).is_some_and(page::is_container)
765        {
766            self.focus.pop();
767        }
768    }
769
770    fn selected_row(&self) -> Option<&Row> {
771        self.rows.get(self.selected)
772    }
773
774    /// The path of whatever is selected in the **active** view.
775    ///
776    /// The seam that lets one set of edit operations serve both projections: an
777    /// edit is a path plus a value, and which list the user picked that path from
778    /// is not something [`commit`](Self::commit) should have to know.
779    pub fn selected_path(&self) -> Option<Vec<Seg>> {
780        match self.view {
781            ViewMode::Tree => self.selected_row().map(|r| r.path.clone()),
782            ViewMode::Pages => self.page_item().map(|i| i.path.clone()),
783        }
784    }
785
786    /// Re-anchor selection onto `path` after a rebuild, or clamp if it's gone.
787    ///
788    /// Re-anchors *both* projections, because an edit made from either one moves
789    /// the node in both, and the view the user is not currently looking at is the
790    /// one they will switch to expecting their cursor to still be somewhere sane.
791    fn select_path(&mut self, path: &[Seg]) {
792        if let Some(i) = self.rows.iter().position(|r| r.path == path) {
793            self.selected = i;
794        } else if self.selected >= self.rows.len() {
795            self.selected = self.rows.len().saturating_sub(1);
796        }
797        // A path off the current page (an edit by path elsewhere in the document,
798        // or the anchor of a delete that was the page's own container) leaves the
799        // page cursor where it was, clamped by `rebuild_pages`.
800        if let Some(i) = self.page.position_of(path) {
801            self.page_selected = i;
802        }
803    }
804
805    // ── navigation ────────────────────────────────────────────────────────────
806
807    /// The selected row of the tree projection — an index into
808    /// [`rows`](Self::rows).
809    pub fn selected(&self) -> usize {
810        self.selected
811    }
812
813    /// Put the tree cursor on `index`, clamped to the row list.
814    ///
815    /// **Switches to the tree projection first**, and that is the point of the
816    /// method rather than a side effect. A row index is a coordinate in the row
817    /// list a caller last rendered; it names nothing on a page. A host driving
818    /// both surfaces — a metadata pane beside a settings page — would otherwise
819    /// hand a row index to a model still standing in the page projection, where
820    /// the very next [`delete_selected`](Self::delete_selected) reads the *page*
821    /// cursor and quietly removes a different node.
822    ///
823    /// So the vocabularies assert. Every method that establishes a cursor names
824    /// the projection its coordinates belong to ([`page_enter`](Self::page_enter)
825    /// and the rest do the same for pages), and the methods that merely *read* a
826    /// cursor stay neutral — a delete deletes what is selected, in whichever view
827    /// the user is actually looking at.
828    ///
829    /// A no-op when the model is already in the tree.
830    pub fn select_row(&mut self, index: usize) {
831        self.set_view(ViewMode::Tree);
832        self.selected = if self.rows.is_empty() {
833            0
834        } else {
835            index.min(self.rows.len() - 1)
836        };
837    }
838
839    pub fn move_down(&mut self) {
840        // Tree vocabulary: assert the projection these coordinates belong to.
841        self.set_view(ViewMode::Tree);
842        if self.selected + 1 < self.rows.len() {
843            self.selected += 1;
844        }
845    }
846
847    pub fn move_up(&mut self) {
848        // Tree vocabulary: assert the projection these coordinates belong to.
849        self.set_view(ViewMode::Tree);
850        self.selected = self.selected.saturating_sub(1);
851    }
852
853    /// `l`: expand a collapsed container, else step into its first child.
854    pub fn expand_or_enter(&mut self) {
855        // Tree vocabulary: assert the projection these coordinates belong to.
856        self.set_view(ViewMode::Tree);
857        let Some(row) = self.selected_row() else {
858            return;
859        };
860        if row.is_container() {
861            if !row.expanded {
862                let path = row.path.clone();
863                self.collapsed.remove(&path);
864                self.rebuild_rows();
865                self.select_path(&path);
866            } else if self.selected + 1 < self.rows.len()
867                && self.rows[self.selected + 1].depth > row.depth
868            {
869                self.selected += 1;
870            }
871        }
872    }
873
874    /// `h`: collapse an expanded container, else step out to the parent row.
875    pub fn collapse_or_leave(&mut self) {
876        // Tree vocabulary: assert the projection these coordinates belong to.
877        self.set_view(ViewMode::Tree);
878        let Some(row) = self.selected_row() else {
879            return;
880        };
881        if row.is_container() && row.expanded {
882            let path = row.path.clone();
883            self.collapsed.insert(path.clone());
884            self.rebuild_rows();
885            self.select_path(&path);
886            return;
887        }
888        // Step out: the nearest earlier row at a shallower depth is the parent.
889        let depth = row.depth;
890        if depth == 0 {
891            return;
892        }
893        for i in (0..self.selected).rev() {
894            if self.rows[i].depth < depth {
895                self.selected = i;
896                return;
897            }
898        }
899    }
900
901    // ── page view ─────────────────────────────────────────────────────────
902
903    /// Which projection is active.
904    pub fn view(&self) -> ViewMode {
905        self.view
906    }
907
908    /// Switch projection, carrying the cursor across so the node you were on in
909    /// one view is the node you are on in the other.
910    ///
911    /// Without that, switching would be a jump cut: you fold down to one key in
912    /// the tree, switch to pages, and land at the top of the root page with no
913    /// idea where your key went. Carrying the selection makes the two views two
914    /// ways of looking at one position, which is the only reading under which
915    /// having both is worth it.
916    pub fn set_view(&mut self, view: ViewMode) {
917        if view == self.view {
918            return;
919        }
920        let was = self.selected_path();
921        self.view = view;
922        if let Some(path) = was {
923            match view {
924                ViewMode::Pages => self.focus_on(&path),
925                // The tree may have the node folded away inside a shut ancestor;
926                // open the lineage so there is a row to land on.
927                ViewMode::Tree => {
928                    for i in 0..path.len() {
929                        self.collapsed.remove(&path[..i]);
930                    }
931                    self.rebuild_rows();
932                    self.select_path(&path);
933                }
934            }
935        }
936    }
937
938    /// Toggle between the tree and the page view.
939    pub fn toggle_view(&mut self) {
940        self.set_view(match self.view {
941            ViewMode::Tree => ViewMode::Pages,
942            ViewMode::Pages => ViewMode::Tree,
943        });
944    }
945
946    /// The page currently being listed.
947    pub fn page(&self) -> &Page {
948        &self.page
949    }
950
951    /// The root's page.
952    pub fn root_page(&self) -> &Page {
953        &self.root_page
954    }
955
956    /// The page one level out — what a two-pane frontend draws on the left. Empty
957    /// when [`focus`](Self::focus) is the root, which has no parent.
958    pub fn parent_page(&self) -> &Page {
959        &self.parent_page
960    }
961
962    /// The container the page view is listing. Empty is the document root.
963    pub fn focus(&self) -> &[Seg] {
964        &self.focus
965    }
966
967    /// The index of the selected item on [`page`](Self::page).
968    pub fn page_selected(&self) -> usize {
969        self.page_selected
970    }
971
972    /// The selected page item, if the page has any.
973    pub fn page_item(&self) -> Option<&PageItem> {
974        self.page.items.get(self.page_selected)
975    }
976
977    /// Whether a two-pane layout would waste one pane on this document.
978    ///
979    /// A document whose root has nothing to drill into — a flat list of keys, a
980    /// sequence of scalars, anything a generous budget has poured onto one page
981    /// — has no navigation to put in a sidebar, and splitting the width for it
982    /// would cost half the room and buy nothing. A frontend checks this to fall
983    /// back to a single full-width pane.
984    ///
985    /// The second case is the same waste one level along: when the page the
986    /// cursor is on is the one that leads the split
987    /// ([`page_leads_the_split`](Self::page_leads_the_split)), the right pane is
988    /// a preview of what the cursor would open, and a page with nothing to open
989    /// has no preview to put there.
990    pub fn pages_would_degenerate(&self) -> bool {
991        if !self.root_page.has_drills() {
992            return true;
993        }
994        self.page_leads_the_split() && !self.page.has_drills()
995    }
996
997    /// Whether the page the cursor is on belongs in the *left* pane, with the
998    /// right one previewing what the cursor would open.
999    ///
1000    /// Two panes are two consecutive levels of one lineage, and the left one is
1001    /// the outermost that offers a choice. Usually that is the page the current
1002    /// one was opened from. But a page can be opened from one that has a single
1003    /// row on it — the root of `{repo: [...]}`, or any level
1004    /// [`enter_document`](Self::enter_document) started past — and drawing that
1005    /// on the left spends half the width on a row nobody can choose between.
1006    /// Then this page leads instead, and the pane that would have repeated its
1007    /// parent previews its child.
1008    pub fn page_leads_the_split(&self) -> bool {
1009        self.focus.is_empty() || !self.parent_page.has_choice()
1010    }
1011
1012    /// Point the page view at whichever page *lists* `path`, with the cursor on
1013    /// it — the by-path counterpart to drilling, and how a view switch carries
1014    /// the selection across.
1015    ///
1016    /// It searches from the root outward rather than from `path` inward, because
1017    /// more than one page can contain a node and the outermost is the right one:
1018    /// an inlined group's member is listed on the grandparent's page (that is what
1019    /// inlining means), and also on the group's own page, which is a place page
1020    /// navigation would never have left you. A path that doesn't resolve is inert.
1021    pub fn focus_on(&mut self, path: &[Seg]) {
1022        // Page vocabulary, like the rest. Re-entrant from `set_view`, which calls
1023        // this to carry the cursor across — but by then `view` is already
1024        // `Pages`, so the call below returns immediately rather than recursing.
1025        self.set_view(ViewMode::Pages);
1026        if tree::value_at(&self.value, path).is_none() {
1027            return;
1028        }
1029        let mut focus: Vec<Seg> = Vec::new();
1030        while focus.len() < path.len()
1031            && page::build_page(
1032                &self.value,
1033                &focus,
1034                &self.hidden,
1035                &self.demoted,
1036                self.inline_budget,
1037            )
1038            .position_of(path)
1039            .is_none()
1040        {
1041            focus.push(path[focus.len()].clone());
1042        }
1043        self.focus = focus;
1044        self.rebuild_pages();
1045        self.page_selected = self.page.position_of(path).unwrap_or(0);
1046    }
1047
1048    /// The page listing the container at `path`, without going there.
1049    ///
1050    /// [`page`](Self::page) is where the user *is*; this is any other level, built
1051    /// on demand and thrown away. A frontend whose navigation is a stack needs it:
1052    /// the OS asks "what is the screen for this path element?" for levels the
1053    /// model is not focused on, and answering by moving the focus would make
1054    /// rendering a screen a navigation.
1055    ///
1056    /// Total, like [`build_page`](crate::page::build_page): a path that doesn't
1057    /// resolve, or that names a scalar, yields an empty page.
1058    pub fn page_at(&self, path: &[Seg]) -> Page {
1059        let mut page = page::build_page(
1060            &self.value,
1061            path,
1062            &self.hidden,
1063            &self.demoted,
1064            self.inline_budget,
1065        );
1066        self.annotate_comments(&mut page);
1067        page
1068    }
1069
1070    /// Fill each item's comments from the backend.
1071    ///
1072    /// A pass after the build rather than a parameter to it: the page projection
1073    /// is a pure function of the value tree, and the value tree has no comments —
1074    /// they live in the editor's source, one read per node. Keeping the reads
1075    /// here leaves [`page::build_page`] callable on a bare `Value` (its tests,
1076    /// an embedder without a backend) and puts the only code that knows comments
1077    /// come from the *backend* next to the only code that has one.
1078    ///
1079    /// A read that fails leaves the item's comment `None`: a comment is
1080    /// decoration on a page, and a page that cannot show one is still the page.
1081    fn annotate_comments(&self, page: &mut Page) {
1082        for item in &mut page.items {
1083            item.leading_comment = self.backend.leading_comment(&item.path).ok().flatten();
1084            item.trailing_comment = self.backend.trailing_comment(&item.path).ok().flatten();
1085            // The host's findings ride the same pass, and for the same reason:
1086            // neither is in the value tree `build_page` is a function of.
1087            item.annotation = annotate::exactly_at(&self.annotations, &item.path).cloned();
1088        }
1089    }
1090
1091    /// The page the selected item *would* open.
1092    ///
1093    /// A two-pane frontend showing the root's categories on the left has nothing
1094    /// to put on the right until you have drilled into something — and an empty
1095    /// half-screen is a poor advertisement for splitting the width. Previewing
1096    /// the selected category's page fills it with the thing you are about to open
1097    /// anyway, which is what a settings sidebar does. `None` for a scalar, which
1098    /// has no page.
1099    pub fn peek_page(&self) -> Option<Page> {
1100        let item = self.page_item()?;
1101        if !item.is_drill() {
1102            return None;
1103        }
1104        // The page it would *open*, which for a compressed row is the far end of
1105        // the chain — previewing the single-row page in between would put the
1106        // pane's whole purpose (showing what you are about to open) to work
1107        // showing the name you are pointing at.
1108        Some(self.page_at(&item.descend_to))
1109    }
1110
1111    /// `j` in the page view.
1112    pub fn page_move_down(&mut self) {
1113        // Page vocabulary: assert the projection this cursor belongs to.
1114        self.set_view(ViewMode::Pages);
1115        if self.page_selected + 1 < self.page.items.len() {
1116            self.page_selected += 1;
1117        }
1118    }
1119
1120    /// `k` in the page view.
1121    pub fn page_move_up(&mut self) {
1122        // Page vocabulary: assert the projection this cursor belongs to.
1123        self.set_view(ViewMode::Pages);
1124        self.page_selected = self.page_selected.saturating_sub(1);
1125    }
1126
1127    /// Stand on row `index` of the page — a click, where `j`/`k` are a walk.
1128    ///
1129    /// The page counterpart to [`select_row`](Self::select_row), and clamped
1130    /// the same way: a row past the end is the last row, and an empty page
1131    /// keeps the cursor at zero. It cannot land the cursor anywhere the walk
1132    /// could not, only faster.
1133    pub fn page_select(&mut self, index: usize) {
1134        // Page vocabulary: assert the projection this cursor belongs to.
1135        self.set_view(ViewMode::Pages);
1136        self.page_selected = index.min(self.page.items.len().saturating_sub(1));
1137    }
1138
1139    /// `l`/`Enter` in the page view: open the selected container as a page, or
1140    /// begin editing the selected scalar.
1141    ///
1142    /// A group header opens too. Its members are already on screen, so opening it
1143    /// shows nothing new — but it is the door to operating on the group as a
1144    /// container (append, insert, reorder) rather than on the members, and a
1145    /// container that is visible but cannot be entered is a worse surprise than a
1146    /// page that repeats what you could already see.
1147    pub fn page_enter(&mut self) {
1148        // Page vocabulary: assert the projection this cursor belongs to.
1149        self.set_view(ViewMode::Pages);
1150        let Some(item) = self.page_item() else {
1151            return;
1152        };
1153        if item.is_scalar() {
1154            // The picker where there is one, the text field where there is not
1155            // — see `begin_choose`, which is the fallback rather than a second
1156            // key to bind.
1157            self.begin_choose();
1158            return;
1159        }
1160        // A group header opens nothing (see `PageItem::is_drill`), so `l` on one
1161        // does the next most useful thing and steps onto its first member — the
1162        // same "into its children" this key means everywhere else.
1163        if !item.is_drill() {
1164            if let Some(first) = self.page.items[self.page_selected + 1..]
1165                .iter()
1166                .position(|i| i.inset > 0)
1167            {
1168                self.page_selected += 1 + first;
1169            }
1170            return;
1171        }
1172        // `descend_to`, not `path`: a compressed row names a chain of containers
1173        // that hold only each other, and opening it lands on the far end — the
1174        // first page with more on it than the name you just tapped. They are the
1175        // same path for every other row.
1176        let target = item.descend_to.clone();
1177        self.page_memory
1178            .insert(self.focus.clone(), self.page_selected);
1179        self.focus = target;
1180        self.page_selected = 0;
1181        self.rebuild_pages();
1182    }
1183
1184    /// `h`/`Esc` in the page view: pop back to the page that *listed* the row you
1185    /// opened, restoring the cursor to it.
1186    ///
1187    /// One level out is the wrong answer once a row can compress, for the same
1188    /// reason it is the wrong left pane
1189    /// ([`rebuild_pages`](Self::rebuild_pages)): opening `exports › journal`
1190    /// deliberately skips the `exports` page because it holds nothing but that
1191    /// one row, and handing it back on the way out makes leaving cost two steps
1192    /// where arriving cost one — on a page whose only row is the name of the
1193    /// place you just left. So this walks out past every level a row compressed
1194    /// past, and lands where the row was tapped.
1195    ///
1196    /// Nothing becomes unreachable by it. A compressed row's
1197    /// [`path`](PageItem::path) is the outermost container, so renaming,
1198    /// deleting, reordering and adding to `exports` are all still that row's ops
1199    /// on the page this lands on — the skipped page never held anything else.
1200    pub fn page_back(&mut self) {
1201        // Page vocabulary: assert the projection this cursor belongs to.
1202        self.set_view(ViewMode::Pages);
1203        if self.focus.is_empty() {
1204            self.status = "already at the top".to_string();
1205            return;
1206        }
1207        let child = std::mem::take(&mut self.focus);
1208        let mut parent = &child[..child.len() - 1];
1209        while !parent.is_empty()
1210            && page::is_compressed_past(&self.value, parent, &self.hidden, self.inline_budget)
1211        {
1212            parent = &parent[..parent.len() - 1];
1213        }
1214        self.focus = parent.to_vec();
1215        self.rebuild_pages();
1216        // Prefer re-finding the child: an index it holds is correct after edits
1217        // that shifted the page, which a remembered index would not be. The
1218        // memory answers only when the child is gone — see `page_memory`.
1219        self.page_selected = self
1220            .page
1221            .position_of(&child)
1222            .or_else(|| {
1223                self.page_memory
1224                    .get(&self.focus)
1225                    .copied()
1226                    .filter(|i| *i < self.page.items.len())
1227            })
1228            .unwrap_or(0);
1229    }
1230
1231    /// Whether the container at `path` is collapsed. Answers for a node with no
1232    /// row too (one nested inside another collapsed container), which
1233    /// [`Row::expanded`](crate::Row) cannot.
1234    pub fn is_collapsed(&self, path: &[Seg]) -> bool {
1235        self.collapsed.contains(path)
1236    }
1237
1238    /// Collapse or expand the container at `path`, leaving the selection where the
1239    /// user put it — the by-path, non-interactive counterpart to
1240    /// [`activate`](Self::activate).
1241    ///
1242    /// `activate` folds *the selected row*, so driving it from a path means moving
1243    /// the selection first and putting it back after. This doesn't: it re-anchors
1244    /// onto whatever was selected before, and only falls back to `path` itself when
1245    /// the selection was a descendant that the fold just took off screen.
1246    ///
1247    /// A path naming a scalar (or nothing) is inert — see
1248    /// [`with_collapsed`](Self::with_collapsed).
1249    pub fn set_collapsed(&mut self, path: &[Seg], collapsed: bool) {
1250        let changed = if collapsed {
1251            self.collapsed.insert(path.to_vec())
1252        } else {
1253            self.collapsed.remove(path)
1254        };
1255        if !changed {
1256            return;
1257        }
1258        let was = self.selected_row().map(|r| r.path.clone());
1259        self.rebuild_rows();
1260        if let Some(was) = was {
1261            // A row swallowed by the fold has no path to return to; its nearest
1262            // surviving ancestor is the container the user just shut.
1263            if collapsed && was.len() > path.len() && was.starts_with(path) {
1264                self.select_path(path);
1265            } else {
1266                self.select_path(&was);
1267            }
1268        }
1269    }
1270
1271    /// `Enter`/`Space`: toggle a container's expansion, or edit a scalar.
1272    pub fn activate(&mut self) {
1273        let Some(row) = self.selected_row() else {
1274            return;
1275        };
1276        if row.is_container() {
1277            let path = row.path.clone();
1278            if row.expanded {
1279                self.collapsed.insert(path.clone());
1280            } else {
1281                self.collapsed.remove(&path);
1282            }
1283            self.rebuild_rows();
1284            self.select_path(&path);
1285        } else {
1286            self.begin_choose();
1287        }
1288    }
1289
1290    // ── choosing ──────────────────────────────────────────────────────────
1291
1292    /// The values a picker at `path` should offer, or `None` when there is no
1293    /// list to offer and a value must be typed.
1294    ///
1295    /// Two sources, in order. A [`Constraint::Enum`](crate::Constraint::Enum)
1296    /// rule answers from its own terms — retired ones included, flagged in the
1297    /// choice's `detail`, because a term already written in the document has to
1298    /// stay re-choosable. Otherwise the backend is asked
1299    /// ([`Backend::candidates`]), which is where a reference field's
1300    /// candidates come from; a backend over a standalone file has none.
1301    ///
1302    /// **A list's append position answers too.** Asked about a sequence, this
1303    /// re-asks about its first item (`path.0`) — a rule written with
1304    /// [`PathPat::each_item_of`](fig_schema::PathPat::each_item_of) is
1305    /// index-agnostic, so the placeholder matches whatever governs the items,
1306    /// which is the same trick the commit funnel's validation uses. A frontend
1307    /// offering "add to this list" therefore gets the list's vocabulary without
1308    /// having to guess an index that does not exist yet.
1309    pub fn choices_at(&self, path: &[Seg]) -> Option<Vec<Choice>> {
1310        if let Some((terms, _)) = self.rule_at(path).and_then(|r| r.enum_constraint()) {
1311            let choices = schema::choices_of(terms);
1312            if !choices.is_empty() {
1313                return Some(choices);
1314            }
1315        }
1316        if let Ok(Some(candidates)) = self.backend.candidates(path)
1317            && !candidates.is_empty()
1318        {
1319            return Some(candidates);
1320        }
1321        // A sequence has no vocabulary of its own; its *items* may.
1322        if matches!(self.value_at(path), Some(Value::Seq(_))) {
1323            let mut item = path.to_vec();
1324            item.push(Seg::Index(0));
1325            return self.choices_at(&item);
1326        }
1327        None
1328    }
1329
1330    /// Open the picker on the selected node, or fall back to
1331    /// [`begin_edit`](Self::begin_edit) when there is nothing to pick from.
1332    ///
1333    /// The fallback is the point: a host binds *one* key and gets a list where
1334    /// there is a list and a text field where there is not, rather than having
1335    /// to ask first and bind two. A closed vocabulary makes the picker the only
1336    /// route to a value that validates, and free text stays reachable anyway
1337    /// ([`begin_edit`](Self::begin_edit)) — what is typed goes through the same
1338    /// validation the picker's values would.
1339    pub fn begin_choose(&mut self) {
1340        let Some(path) = self.selected_path() else {
1341            return;
1342        };
1343        let Some(choices) = self.choices_at(&path) else {
1344            self.begin_edit();
1345            return;
1346        };
1347        if self.value_at(&path).is_some_and(page::is_container) {
1348            // A container with an item vocabulary is a list to add to, not a
1349            // value to replace — an affordance that is not built yet.
1350            self.begin_edit();
1351            return;
1352        }
1353        self.mode = Mode::Choosing {
1354            path,
1355            choices,
1356            selected: 0,
1357            filter: String::new(),
1358        };
1359    }
1360
1361    /// The choices the picker is currently showing — everything offered, cut to
1362    /// what the filter matches. Empty outside [`Mode::Choosing`].
1363    pub fn visible_choices(&self) -> Vec<&Choice> {
1364        match &self.mode {
1365            Mode::Choosing {
1366                choices, filter, ..
1367            } => choices.iter().filter(|c| c.matches(filter)).collect(),
1368            _ => Vec::new(),
1369        }
1370    }
1371
1372    /// The choice under the picker's cursor, if any.
1373    pub fn choice_selected(&self) -> Option<&Choice> {
1374        match &self.mode {
1375            Mode::Choosing { selected, .. } => self.visible_choices().get(*selected).copied(),
1376            _ => None,
1377        }
1378    }
1379
1380    /// Move the picker's cursor down one filtered row.
1381    pub fn choose_next(&mut self) {
1382        let len = self.visible_choices().len();
1383        if let Mode::Choosing { selected, .. } = &mut self.mode
1384            && *selected + 1 < len
1385        {
1386            *selected += 1;
1387        }
1388    }
1389
1390    /// Move the picker's cursor up one filtered row.
1391    pub fn choose_prev(&mut self) {
1392        if let Mode::Choosing { selected, .. } = &mut self.mode {
1393            *selected = selected.saturating_sub(1);
1394        }
1395    }
1396
1397    /// Narrow the picker by one more typed character (case-insensitive
1398    /// substring of the label). The cursor returns to the top of what is left,
1399    /// so what is highlighted is always a row that is on screen.
1400    pub fn choose_push(&mut self, c: char) {
1401        if let Mode::Choosing {
1402            filter, selected, ..
1403        } = &mut self.mode
1404        {
1405            filter.push(c);
1406            *selected = 0;
1407        }
1408    }
1409
1410    /// Undo one character of the picker's filter.
1411    pub fn choose_backspace(&mut self) {
1412        if let Mode::Choosing {
1413            filter, selected, ..
1414        } = &mut self.mode
1415        {
1416            filter.pop();
1417            *selected = 0;
1418        }
1419    }
1420
1421    /// Commit the choice under the cursor, replacing the node's value.
1422    ///
1423    /// A no-op (beyond leaving the picker) when the filter has cut the list to
1424    /// nothing: there is no value to write, and inventing one from what was
1425    /// typed would be the free-text path wearing the picker's clothes.
1426    pub fn choose_commit(&mut self) {
1427        let chosen = self.choice_selected().map(|c| c.value.clone());
1428        let Mode::Choosing { path, .. } = &self.mode else {
1429            return;
1430        };
1431        let path = path.clone();
1432        self.mode = Mode::Normal;
1433        match chosen {
1434            Some(value) => self.set_value_at(&path, value),
1435            None => self.status = "nothing matches".to_string(),
1436        }
1437    }
1438
1439    /// Leave the picker, writing nothing.
1440    pub fn choose_cancel(&mut self) {
1441        self.mode = Mode::Normal;
1442        self.status = "choice cancelled".to_string();
1443    }
1444
1445    // ── editing ───────────────────────────────────────────────────────────────
1446
1447    pub fn begin_edit(&mut self) {
1448        let Some(path) = self.selected_path() else {
1449            return;
1450        };
1451        let Some(value) = self.value_at(&path) else {
1452            return;
1453        };
1454        if page::is_container(value) {
1455            self.status = "can only edit scalar values".to_string();
1456            return;
1457        }
1458        let seed = tree::edit_seed(value);
1459        self.mode = Mode::Editing {
1460            buffer: seed,
1461            path,
1462            slot: EditSlot::Value,
1463        };
1464    }
1465
1466    /// Open the selected node's leading comment block for editing, seeded with
1467    /// what is there (empty when there is none). Any node, container or scalar:
1468    /// a comment above a table is as much the table's as one above a key.
1469    pub fn begin_edit_leading_comment(&mut self) {
1470        self.begin_edit_comment(EditSlot::LeadingComment);
1471    }
1472
1473    /// Open the selected node's trailing comment for editing, seeded with what is
1474    /// there. See [`begin_edit_leading_comment`](Self::begin_edit_leading_comment).
1475    pub fn begin_edit_trailing_comment(&mut self) {
1476        self.begin_edit_comment(EditSlot::TrailingComment);
1477    }
1478
1479    fn begin_edit_comment(&mut self, slot: EditSlot) {
1480        let Some(path) = self.selected_path() else {
1481            return;
1482        };
1483        let read = match slot {
1484            EditSlot::LeadingComment => self.backend.leading_comment(&path),
1485            EditSlot::TrailingComment => self.backend.trailing_comment(&path),
1486            EditSlot::Value => unreachable!("begin_edit_comment is only called for a comment slot"),
1487        };
1488        let seed = match read {
1489            Ok(text) => text.unwrap_or_default(),
1490            Err(e) => {
1491                self.status = format!("rejected: {e}");
1492                return;
1493            }
1494        };
1495        self.mode = Mode::Editing {
1496            buffer: seed,
1497            path,
1498            slot,
1499        };
1500    }
1501
1502    pub fn edit_push(&mut self, c: char) {
1503        if let Mode::Editing { buffer, .. } = &mut self.mode {
1504            buffer.push(c);
1505        }
1506    }
1507
1508    pub fn edit_backspace(&mut self) {
1509        if let Mode::Editing { buffer, .. } = &mut self.mode {
1510            buffer.pop();
1511        }
1512    }
1513
1514    pub fn edit_cancel(&mut self) {
1515        self.mode = Mode::Normal;
1516        self.status = "edit cancelled".to_string();
1517    }
1518
1519    pub fn edit_commit(&mut self) {
1520        let Mode::Editing { buffer, path, slot } = &mut self.mode else {
1521            return;
1522        };
1523        let buffer = std::mem::take(buffer);
1524        let path = std::mem::take(path);
1525        let slot = *slot;
1526        self.mode = Mode::Normal;
1527
1528        match slot {
1529            EditSlot::Value => {
1530                let value = self.coerce_text(&path, &buffer);
1531                self.commit(
1532                    EditOp::ReplaceValue {
1533                        path: path.clone(),
1534                        value,
1535                    },
1536                    path,
1537                    "value updated",
1538                );
1539            }
1540            // An empty buffer is "no comment", not "a comment saying nothing":
1541            // the one thing a user can type to mean *remove it*.
1542            EditSlot::LeadingComment => {
1543                let text = (!buffer.is_empty()).then_some(buffer.as_str());
1544                self.set_leading_comment(&path, text)
1545            }
1546            EditSlot::TrailingComment => {
1547                let text = (!buffer.is_empty()).then_some(buffer.as_str());
1548                self.set_trailing_comment(&path, text)
1549            }
1550        }
1551    }
1552
1553    /// Set (or, with `None`, remove) the own-line comment block above the node at
1554    /// `path`, refreshing the view. The by-path counterpart of committing an
1555    /// [`EditSlot::LeadingComment`] edit, for an embedder or FFI.
1556    pub fn set_leading_comment(&mut self, path: &[Seg], text: Option<&str>) {
1557        self.commit(
1558            EditOp::SetLeadingComment {
1559                path: path.to_vec(),
1560                text: text.map(str::to_string),
1561            },
1562            path.to_vec(),
1563            if text.is_some() {
1564                "comment updated"
1565            } else {
1566                "comment removed"
1567            },
1568        );
1569    }
1570
1571    /// Set (or, with `None`, remove) the same-line comment after the value at
1572    /// `path`, refreshing the view. See
1573    /// [`set_leading_comment`](Self::set_leading_comment).
1574    pub fn set_trailing_comment(&mut self, path: &[Seg], text: Option<&str>) {
1575        self.commit(
1576            EditOp::SetTrailingComment {
1577                path: path.to_vec(),
1578                text: text.map(str::to_string),
1579            },
1580            path.to_vec(),
1581            if text.is_some() {
1582                "comment updated"
1583            } else {
1584                "comment removed"
1585            },
1586        );
1587    }
1588
1589    /// The own-line comment block above the node at `path`, if the backend
1590    /// reports one — a fresh read, not the copy on a page item.
1591    pub fn leading_comment_at(&self, path: &[Seg]) -> Option<String> {
1592        self.backend.leading_comment(path).ok().flatten()
1593    }
1594
1595    /// The same-line comment after the value at `path`, if the backend reports
1596    /// one.
1597    pub fn trailing_comment_at(&self, path: &[Seg]) -> Option<String> {
1598        self.backend.trailing_comment(path).ok().flatten()
1599    }
1600
1601    /// Programmatically replace the value at `path` (any depth), refreshing the
1602    /// view. The non-interactive counterpart to [`edit_commit`](Self::edit_commit)
1603    /// — for an embedder or FFI that edits by path rather than through the
1604    /// selection.
1605    pub fn set_value_at(&mut self, path: &[Seg], value: Value) {
1606        self.commit(
1607            EditOp::ReplaceValue {
1608                path: path.to_vec(),
1609                value,
1610            },
1611            path.to_vec(),
1612            "value updated",
1613        );
1614    }
1615
1616    /// Set the scalar at `path` from an edit-buffer `text`, coercing by the
1617    /// schema's expected type when known (a `str` field keeps `"123"` a string)
1618    /// and otherwise guessing by literal shape — the by-path, schema-aware analog
1619    /// of [`edit_commit`](Self::edit_commit). Validation (closed-vocabulary
1620    /// rejection) still happens at the commit funnel.
1621    pub fn set_scalar_text(&mut self, path: &[Seg], text: &str) {
1622        let value = self.coerce_text(path, text);
1623        self.set_value_at(path, value);
1624    }
1625
1626    /// Turn edit-buffer `text` into the value that belongs at `path`: the type the
1627    /// schema declares for that path when it declares one, and otherwise a guess
1628    /// from the literal's shape.
1629    ///
1630    /// The single rule behind [`edit_commit`](Self::edit_commit),
1631    /// [`set_scalar_text`](Self::set_scalar_text),
1632    /// [`insert_key_text`](Self::insert_key_text) and
1633    /// [`append_item_text`](Self::append_item_text). It is keyed on the path of the
1634    /// value being *written*, not of its container — that is what lets an
1635    /// each-item rule type a list's items independently of the list.
1636    fn coerce_text(&self, path: &[Seg], text: &str) -> Value {
1637        match self.rule_at(path).and_then(|r| r.ty) {
1638            Some(ty) => ty.coerce(text),
1639            None => tree::parse_scalar(text),
1640        }
1641    }
1642
1643    /// Rename the mapping entry at `path` to `new_key`, keeping its value and
1644    /// re-anchoring the selection onto the renamed entry. A no-op (with a status
1645    /// hint) when `path` doesn't end in a key — a sequence item has no key. The
1646    /// backend rejects a name that collides with an existing sibling key.
1647    pub fn rename_key(&mut self, path: &[Seg], new_key: &str) {
1648        match path.last() {
1649            Some(Seg::Key(_)) => {
1650                let mut anchor = path[..path.len() - 1].to_vec();
1651                anchor.push(Seg::Key(new_key.to_string()));
1652                self.commit(
1653                    EditOp::RenameKey {
1654                        path: path.to_vec(),
1655                        new_key: new_key.to_string(),
1656                    },
1657                    anchor,
1658                    "renamed",
1659                );
1660            }
1661            _ => self.status = "only mapping keys can be renamed".to_string(),
1662        }
1663    }
1664
1665    /// Insert `key = value` into the mapping at `map_path`, selecting the new
1666    /// entry. A frontend offers this on a map container; the backend rejects a
1667    /// duplicate key or a non-mapping target, leaving the document untouched.
1668    pub fn insert_key(&mut self, map_path: &[Seg], key: &str, value: Value) {
1669        let mut anchor = map_path.to_vec();
1670        anchor.push(Seg::Key(key.to_string()));
1671        self.commit(
1672            EditOp::InsertKey {
1673                map_path: map_path.to_vec(),
1674                key: key.to_string(),
1675                value,
1676            },
1677            anchor,
1678            "inserted",
1679        );
1680    }
1681
1682    /// Insert `key = text` into the mapping at `map_path`, coercing `text` by the
1683    /// type the schema declares for the new entry and otherwise guessing by literal
1684    /// shape — the insert-shaped analog of
1685    /// [`set_scalar_text`](Self::set_scalar_text).
1686    ///
1687    /// Prefer this to [`insert_key`](Self::insert_key) whenever the value comes
1688    /// from a user's text: a caller that shape-guesses on its own writes `2026` as
1689    /// an integer into a field the schema declares `str`, and gets no say from the
1690    /// schema it is otherwise honoring everywhere else.
1691    pub fn insert_key_text(&mut self, map_path: &[Seg], key: &str, text: &str) {
1692        let mut target = map_path.to_vec();
1693        target.push(Seg::Key(key.to_string()));
1694        let value = self.coerce_text(&target, text);
1695        self.insert_key(map_path, key, value);
1696    }
1697
1698    /// Append `value` to the sequence at `seq_path`, selecting the new item.
1699    pub fn append_item(&mut self, seq_path: &[Seg], value: Value) {
1700        let idx = self.seq_len(seq_path);
1701        let mut anchor = seq_path.to_vec();
1702        anchor.push(Seg::Index(idx));
1703        self.commit(
1704            EditOp::AppendItem {
1705                seq_path: seq_path.to_vec(),
1706                value,
1707            },
1708            anchor,
1709            "appended",
1710        );
1711    }
1712
1713    /// Append `text` to the sequence at `seq_path`, coercing it by the type the
1714    /// schema declares for the sequence's *items* and otherwise guessing by literal
1715    /// shape — the append-shaped analog of
1716    /// [`set_scalar_text`](Self::set_scalar_text).
1717    ///
1718    /// The item's type comes from the rule matching the item path (an each-item or
1719    /// subtree rule), not from the rule on the list itself: `tags` is a `seq`, its
1720    /// items are `str`.
1721    pub fn append_item_text(&mut self, seq_path: &[Seg], text: &str) {
1722        let mut target = seq_path.to_vec();
1723        target.push(Seg::Index(self.seq_len(seq_path)));
1724        let value = self.coerce_text(&target, text);
1725        self.append_item(seq_path, value);
1726    }
1727
1728    /// Move the selected row one place earlier among its siblings — a sequence
1729    /// item via fig's array-move, a mapping entry via a one-swap reorder.
1730    pub fn move_selected_up(&mut self) {
1731        self.reorder_selected(-1);
1732    }
1733
1734    /// Move the selected row one place later among its siblings.
1735    pub fn move_selected_down(&mut self) {
1736        self.reorder_selected(1);
1737    }
1738
1739    /// The shared body of [`move_selected_up`](Self::move_selected_up) /
1740    /// [`move_selected_down`](Self::move_selected_down): shift the selected row by
1741    /// `delta` positions within its parent container.
1742    fn reorder_selected(&mut self, delta: isize) {
1743        let Some(path) = self.selected_path() else {
1744            return;
1745        };
1746        let Some(last) = path.last().cloned() else {
1747            self.status = "cannot move the document root".to_string();
1748            return;
1749        };
1750        let parent = path[..path.len() - 1].to_vec();
1751        match last {
1752            Seg::Index(i) => {
1753                let len = self.seq_len(&parent);
1754                let to = i as isize + delta;
1755                if to < 0 || to as usize >= len {
1756                    self.status = "already at the edge".to_string();
1757                    return;
1758                }
1759                let to = to as usize;
1760                let mut anchor = parent.clone();
1761                anchor.push(Seg::Index(to));
1762                self.commit(
1763                    EditOp::MoveItem {
1764                        seq_path: parent,
1765                        from: i,
1766                        to,
1767                    },
1768                    anchor,
1769                    "moved",
1770                );
1771            }
1772            Seg::Key(k) => {
1773                let keys = self.map_keys(&parent);
1774                let Some(pos) = keys.iter().position(|x| *x == k) else {
1775                    return;
1776                };
1777                let target = pos as isize + delta;
1778                if target < 0 || target as usize >= keys.len() {
1779                    self.status = "already at the edge".to_string();
1780                    return;
1781                }
1782                let mut order = keys;
1783                order.swap(pos, target as usize);
1784                self.commit(
1785                    EditOp::ReorderKeys {
1786                        map_path: parent,
1787                        keys: order,
1788                    },
1789                    path,
1790                    "moved",
1791                );
1792            }
1793        }
1794    }
1795
1796    /// The value the document currently holds at `path` (the whole tree for the
1797    /// empty path), or `None` when the path doesn't resolve — for a frontend
1798    /// reading a row's value without reaching for the backend.
1799    pub fn value_at(&self, path: &[Seg]) -> Option<&Value> {
1800        tree::value_at(&self.value, path)
1801    }
1802
1803    /// The mapping keys at `path`, in document order (empty for a non-mapping).
1804    fn map_keys(&self, path: &[Seg]) -> Vec<String> {
1805        tree::map_keys(&self.value, path).unwrap_or_default()
1806    }
1807
1808    /// The length of the sequence at `path` (0 for a non-sequence) — the index an
1809    /// append will land at.
1810    pub fn seq_len(&self, path: &[Seg]) -> usize {
1811        tree::seq_len(&self.value, path).unwrap_or(0)
1812    }
1813
1814    /// `x`: delete the selected mapping entry or sequence item.
1815    pub fn delete_selected(&mut self) {
1816        let Some(path) = self.selected_path() else {
1817            return;
1818        };
1819        let (op, anchor) = match path.last() {
1820            Some(Seg::Index(i)) => {
1821                let seq_path = path[..path.len() - 1].to_vec();
1822                (
1823                    EditOp::RemoveItem {
1824                        seq_path: seq_path.clone(),
1825                        index: *i,
1826                    },
1827                    seq_path,
1828                )
1829            }
1830            Some(Seg::Key(_)) => (
1831                EditOp::DeleteKey { path: path.clone() },
1832                path[..path.len() - 1].to_vec(),
1833            ),
1834            None => {
1835                self.status = "cannot delete the document root".to_string();
1836                return;
1837            }
1838        };
1839        self.commit(op, anchor, "deleted");
1840    }
1841
1842    /// Apply one edit through the backend, then refresh the view (or report the
1843    /// rollback). The single path every mutation funnels through — and the choke
1844    /// point where the schema validates values: a closed vocabulary rejects an
1845    /// unknown value here, before it reaches the backend; an open one applies but
1846    /// surfaces a soft warning. fig's reparse stays the last-resort backstop.
1847    fn commit(&mut self, op: EditOp, anchor: Vec<Seg>, msg: &str) {
1848        // A workspace-maintained field declines every mutation, not just a value
1849        // edit: renaming or deleting one would be undone on the next write just
1850        // as surely as retyping it.
1851        if let Some(key) = op_root_key(&op)
1852            && self.derived.contains(key)
1853        {
1854            self.status = format!("rejected: `{key}` is maintained by the workspace");
1855            return;
1856        }
1857        let mut warn: Option<Issue> = None;
1858        if let Some((path, value)) = op_target(&op)
1859            && let Some(rule) = self.rule_at(&path)
1860        {
1861            match rule.validate(value) {
1862                Validation::Reject(why) => {
1863                    self.status = format!("rejected: {why}");
1864                    return;
1865                }
1866                Validation::Warn(why) => warn = Some(why),
1867                Validation::Ok => {}
1868            }
1869        }
1870        // Derived *before* the apply, from the tree as it still stands: an
1871        // inverse is a statement about the document the op is addressed
1872        // against, and after the splice that document is gone.
1873        let inverse = self.invert(&op);
1874        // Taken before the splice: an item's identity is read off the tree the
1875        // path was taken against.
1876        let ids = self.identities();
1877        match self.backend.apply(op.clone()) {
1878            Ok(()) => {
1879                match inverse {
1880                    Some(inverse) => self.undo_stack.push(Change {
1881                        forward: op,
1882                        inverse,
1883                        anchor: anchor.clone(),
1884                    }),
1885                    // An op we cannot invert (a path that stopped resolving
1886                    // between the two reads, a rename of something that is not
1887                    // a key) is a hole in the history rather than a step in it,
1888                    // and a stack with a hole in the middle undoes to a document
1889                    // nobody ever had. Dropping what is behind it is the honest
1890                    // answer, and `history_len` says so.
1891                    None => self.undo_stack.clear(),
1892                }
1893                // A fresh edit is a new branch: what was undone is no longer
1894                // ahead of us.
1895                self.redo_stack.clear();
1896                self.edit_seq += 1;
1897                self.after_edit(&anchor, msg, ids);
1898                // A soft-warn overrides the success status so the user sees it.
1899                if let Some(why) = warn {
1900                    self.status = why.to_string();
1901                }
1902            }
1903            // The backend rolled back / declined; the document is untouched.
1904            Err(e) => self.status = format!("rejected: {e}"),
1905        }
1906    }
1907
1908    // ── history ───────────────────────────────────────────────────────────
1909
1910    /// How many edits are on the undo journal — the depth
1911    /// [`undo`](Self::undo) can walk back through.
1912    ///
1913    /// A host composing flower with another editor reads it to know whether
1914    /// there is anything of flower's to undo before it dispatches the keystroke
1915    /// (provui's session, holding flower's metadata beside leaf's body).
1916    pub fn history_len(&self) -> usize {
1917        self.undo_stack.len()
1918    }
1919
1920    /// How many undone edits are available to [`redo`](Self::redo). Reset to 0
1921    /// by the next fresh commit.
1922    pub fn redo_len(&self) -> usize {
1923        self.redo_stack.len()
1924    }
1925
1926    /// A number that increases on every successful commit, undo and redo, and
1927    /// on nothing else.
1928    ///
1929    /// Not a depth — undoing advances it as surely as editing does, because
1930    /// what it counts is *how many times the document has changed*, not how far
1931    /// from the start it is. A host holding two editors keeps one ordered
1932    /// history by recording which editor's sequence number moved, so "body
1933    /// edit, metadata edit, body edit" undoes in that order without either
1934    /// editor knowing the other exists.
1935    pub fn edit_seq(&self) -> u64 {
1936        self.edit_seq
1937    }
1938
1939    /// Undo the most recent edit, putting the cursor back where it was made.
1940    ///
1941    /// The inverse goes through the same [`Backend::apply`] the edit did, so a
1942    /// backend that declines an edit declines its undo too — and a
1943    /// workspace-maintained key refuses here exactly as it refuses there. The
1944    /// schema is *not* re-consulted: the value being restored is one the
1945    /// document already held, and a vocabulary that has since tightened is not
1946    /// a reason to strand a user one edit away from where they were.
1947    ///
1948    /// Returns whether the document moved: `false` when there was nothing to
1949    /// undo, or the inverse was refused, and in either case with a status
1950    /// saying which. A host composing two editors dispatches an undo to one of
1951    /// them and needs to know whether it landed without snapshotting
1952    /// [`edit_seq`](Self::edit_seq) around the call.
1953    pub fn undo(&mut self) -> bool {
1954        let Some(change) = self.undo_stack.pop() else {
1955            self.status = "nothing to undo".to_string();
1956            return false;
1957        };
1958        if let Some(key) = self.managed_key_of(&change.inverse) {
1959            self.status = format!("rejected: `{key}` is maintained by the workspace");
1960            self.undo_stack.push(change);
1961            return false;
1962        }
1963        let ids = self.identities();
1964        match self.apply_all(&change.inverse) {
1965            Ok(()) => {
1966                self.edit_seq += 1;
1967                let anchor = change.anchor.clone();
1968                self.redo_stack.push(change);
1969                self.after_edit(&anchor, "undone", ids);
1970                self.reveal(&anchor);
1971                true
1972            }
1973            Err(e) => {
1974                self.status = format!("rejected: {e}");
1975                self.undo_stack.push(change);
1976                false
1977            }
1978        }
1979    }
1980
1981    /// Redo the most recently undone edit. Cleared — and so a no-op — once a
1982    /// fresh edit has been committed on top. Returns whether the document
1983    /// moved, as [`undo`](Self::undo) does.
1984    pub fn redo(&mut self) -> bool {
1985        let Some(change) = self.redo_stack.pop() else {
1986            self.status = "nothing to redo".to_string();
1987            return false;
1988        };
1989        if let Some(key) = self.managed_key_of(std::slice::from_ref(&change.forward)) {
1990            self.status = format!("rejected: `{key}` is maintained by the workspace");
1991            self.redo_stack.push(change);
1992            return false;
1993        }
1994        let ids = self.identities();
1995        match self.apply_all(std::slice::from_ref(&change.forward)) {
1996            Ok(()) => {
1997                self.edit_seq += 1;
1998                let anchor = change.anchor.clone();
1999                self.undo_stack.push(change);
2000                self.after_edit(&anchor, "redone", ids);
2001                self.reveal(&anchor);
2002                true
2003            }
2004            Err(e) => {
2005                self.status = format!("rejected: {e}");
2006                self.redo_stack.push(change);
2007                false
2008            }
2009        }
2010    }
2011
2012    /// The workspace-maintained top-level key one of `ops` would touch, if any
2013    /// — the same refusal [`commit`](Self::commit) makes, asked of a whole
2014    /// inverse at once so that nothing is half-applied before it fires.
2015    fn managed_key_of(&self, ops: &[EditOp]) -> Option<String> {
2016        ops.iter()
2017            .filter_map(op_root_key)
2018            .find(|k| self.derived.contains(*k))
2019            .map(str::to_string)
2020    }
2021
2022    /// Apply every op in order. Each is atomic on its own
2023    /// ([`Backend::apply`]); the *sequence* is not, so a failure partway
2024    /// through — which needs a backend refusing an op it accepted the inverse
2025    /// of — leaves what ran in place and reports.
2026    fn apply_all(&mut self, ops: &[EditOp]) -> Result<(), crate::backend::BackendError> {
2027        for op in ops {
2028            self.backend.apply(op.clone())?;
2029        }
2030        Ok(())
2031    }
2032
2033    /// Bring the node at `anchor` under the cursor, opening whatever stands
2034    /// between the cursor and it.
2035    ///
2036    /// [`after_edit`](Self::after_edit) re-anchors the selection, which is
2037    /// enough while the edit and the cursor are on the same page — they are,
2038    /// for an edit the cursor just made. An undo is the case where they are
2039    /// not: it changes a row the reader may have navigated away from several
2040    /// pages ago, and a status line saying "undone" over an unchanged screen
2041    /// is the one thing an undo must not be. So this *navigates*, in whichever
2042    /// projection is live.
2043    fn reveal(&mut self, anchor: &[Seg]) {
2044        if self.value_at(anchor).is_none() {
2045            return;
2046        }
2047        match self.view {
2048            // Already on screen, in either of the two ways it can be: the page
2049            // lists it (`after_edit` has just put the cursor on it), or it is
2050            // the container the cursor is standing *inside*. Navigating in
2051            // either case would take a reader out of the page they were on to
2052            // show them a row they can already see.
2053            ViewMode::Pages
2054                if self.focus.starts_with(anchor) || self.page.position_of(anchor).is_some() => {}
2055            ViewMode::Pages => self.focus_on(anchor),
2056            ViewMode::Tree => {
2057                for i in 0..anchor.len() {
2058                    self.collapsed.remove(&anchor[..i]);
2059                }
2060                self.rebuild_rows();
2061                self.select_path(anchor);
2062            }
2063        }
2064    }
2065
2066    /// The ops that put the document back the way it is *now*, were `op` to be
2067    /// applied to it. `None` when the current tree cannot answer — an
2068    /// unresolvable path, a rename of something that is not a key.
2069    ///
2070    /// Read against the pre-edit tree by every arm, which is what makes the
2071    /// derivation total in one place instead of scattered through the ops.
2072    fn invert(&self, op: &EditOp) -> Option<Vec<EditOp>> {
2073        Some(match op {
2074            EditOp::ReplaceValue { path, .. } => vec![EditOp::ReplaceValue {
2075                path: path.clone(),
2076                value: self.value_at(path)?.clone(),
2077            }],
2078            EditOp::DeleteKey { path } => {
2079                let Some(Seg::Key(key)) = path.last() else {
2080                    return None;
2081                };
2082                let map_path = path[..path.len() - 1].to_vec();
2083                let value = self.value_at(path)?.clone();
2084                let keys = tree::map_keys(&self.value, &map_path)?;
2085                let mut ops = vec![EditOp::InsertKey {
2086                    map_path: map_path.clone(),
2087                    key: key.clone(),
2088                    value,
2089                }];
2090                // The entry comes back at the end of the mapping, so its
2091                // comments are addressed there and the reorder carries them to
2092                // its old position with it.
2093                let mut at = map_path.clone();
2094                at.push(Seg::Key(key.clone()));
2095                ops.extend(self.comment_restores(path, &at));
2096                ops.push(EditOp::ReorderKeys { map_path, keys });
2097                ops
2098            }
2099            EditOp::RemoveItem { seq_path, index } => {
2100                let mut item_path = seq_path.clone();
2101                item_path.push(Seg::Index(*index));
2102                let value = self.value_at(&item_path)?.clone();
2103                let len = tree::seq_len(&self.value, seq_path)?;
2104                // After the removal the list is one shorter, so the append
2105                // lands at `len - 1` and the move takes it back to `index`.
2106                let landed = len.checked_sub(1)?;
2107                let mut landed_path = seq_path.clone();
2108                landed_path.push(Seg::Index(landed));
2109                let mut ops = vec![EditOp::AppendItem {
2110                    seq_path: seq_path.clone(),
2111                    value,
2112                }];
2113                ops.extend(self.comment_restores(&item_path, &landed_path));
2114                if landed != *index {
2115                    ops.push(EditOp::MoveItem {
2116                        seq_path: seq_path.clone(),
2117                        from: landed,
2118                        to: *index,
2119                    });
2120                }
2121                ops
2122            }
2123            EditOp::InsertKey { map_path, key, .. } => {
2124                let mut path = map_path.clone();
2125                path.push(Seg::Key(key.clone()));
2126                match self.value_at(&path) {
2127                    // An insert onto a key that is already there is an
2128                    // overwrite on an upserting backend (the case `EditOp`
2129                    // leaves unspecified), and inverts as one.
2130                    Some(old) => vec![EditOp::ReplaceValue {
2131                        path,
2132                        value: old.clone(),
2133                    }],
2134                    None => vec![EditOp::DeleteKey { path }],
2135                }
2136            }
2137            EditOp::AppendItem { seq_path, .. } => vec![EditOp::RemoveItem {
2138                seq_path: seq_path.clone(),
2139                index: tree::seq_len(&self.value, seq_path)?,
2140            }],
2141            EditOp::MoveItem { seq_path, from, to } => vec![EditOp::MoveItem {
2142                seq_path: seq_path.clone(),
2143                from: *to,
2144                to: *from,
2145            }],
2146            EditOp::ReorderKeys { map_path, .. } => vec![EditOp::ReorderKeys {
2147                map_path: map_path.clone(),
2148                keys: tree::map_keys(&self.value, map_path)?,
2149            }],
2150            EditOp::RenameKey { path, new_key } => {
2151                let Some(Seg::Key(old)) = path.last() else {
2152                    return None;
2153                };
2154                let mut renamed = path[..path.len() - 1].to_vec();
2155                renamed.push(Seg::Key(new_key.clone()));
2156                vec![EditOp::RenameKey {
2157                    path: renamed,
2158                    new_key: old.clone(),
2159                }]
2160            }
2161            EditOp::SetLeadingComment { path, .. } => vec![EditOp::SetLeadingComment {
2162                path: path.clone(),
2163                text: self.backend.leading_comment(path).ok().flatten(),
2164            }],
2165            EditOp::SetTrailingComment { path, .. } => vec![EditOp::SetTrailingComment {
2166                path: path.clone(),
2167                text: self.backend.trailing_comment(path).ok().flatten(),
2168            }],
2169        })
2170    }
2171
2172    /// The comment ops that put `from`'s comments onto the node at `to` — how
2173    /// a deleted entry comes back with what was written above it.
2174    ///
2175    /// Only for comments that are actually there: a backend that reads none
2176    /// (or a format with no comment syntax) contributes nothing, rather than a
2177    /// pair of removals the undo would have to survive.
2178    fn comment_restores(&self, from: &[Seg], to: &[Seg]) -> Vec<EditOp> {
2179        let mut ops = Vec::new();
2180        if let Ok(Some(text)) = self.backend.leading_comment(from) {
2181            ops.push(EditOp::SetLeadingComment {
2182                path: to.to_vec(),
2183                text: Some(text),
2184            });
2185        }
2186        if let Ok(Some(text)) = self.backend.trailing_comment(from) {
2187            ops.push(EditOp::SetTrailingComment {
2188                path: to.to_vec(),
2189                text: Some(text),
2190            });
2191        }
2192        ops
2193    }
2194
2195    /// Shared tail of a successful mutation: refresh the view, re-anchor
2196    /// selection, mark dirty, set the status line.
2197    fn after_edit(&mut self, anchor: &[Seg], msg: &str, ids: Identities) {
2198        if let Err(e) = self.reload_keeping(Some(ids)) {
2199            self.status = format!("view refresh failed: {e}");
2200            return;
2201        }
2202        self.select_path(anchor);
2203        // Derived from the bytes, not set: undoing back to what was saved is
2204        // a clean document, and no journal depth can say that for itself.
2205        self.dirty = self.source_snapshot() != self.saved_source;
2206        self.status = msg.to_string();
2207    }
2208}
2209
2210/// The (target path, value) a value-bearing [`EditOp`] writes — what schema
2211/// validation checks. An append's item index isn't known here, so a placeholder
2212/// `Index(0)` stands in; it only serves to match an `EachItem` rule pattern, which
2213/// is index-agnostic. Structural ops (delete, move, reorder, rename) carry no new
2214/// value and return `None`.
2215/// The top-level mapping key an op would change, if any — the unit at which a
2216/// document's managed fields are declared, so an edit anywhere beneath one
2217/// (an item of a managed list, a nested key) is caught along with the field
2218/// itself.
2219fn op_root_key(op: &EditOp) -> Option<&str> {
2220    fn first_key(path: &[Seg]) -> Option<&str> {
2221        match path.first() {
2222            Some(Seg::Key(k)) => Some(k.as_str()),
2223            _ => None,
2224        }
2225    }
2226    match op {
2227        EditOp::ReplaceValue { path, .. }
2228        | EditOp::DeleteKey { path }
2229        | EditOp::RenameKey { path, .. }
2230        | EditOp::SetLeadingComment { path, .. }
2231        | EditOp::SetTrailingComment { path, .. } => first_key(path),
2232        EditOp::RemoveItem { seq_path, .. }
2233        | EditOp::AppendItem { seq_path, .. }
2234        | EditOp::MoveItem { seq_path, .. } => first_key(seq_path),
2235        // An insert *at the root* names the new top-level key itself; deeper, the
2236        // container it lands in is what matters.
2237        EditOp::InsertKey { map_path, key, .. } => match map_path.first() {
2238            None => Some(key.as_str()),
2239            _ => first_key(map_path),
2240        },
2241        // Reordering the root's own keys moves no field's value.
2242        EditOp::ReorderKeys { map_path, .. } => first_key(map_path),
2243    }
2244}
2245
2246fn op_target(op: &EditOp) -> Option<(Vec<Seg>, &Value)> {
2247    match op {
2248        EditOp::ReplaceValue { path, value } => Some((path.clone(), value)),
2249        EditOp::InsertKey {
2250            map_path,
2251            key,
2252            value,
2253        } => {
2254            let mut p = map_path.clone();
2255            p.push(Seg::Key(key.clone()));
2256            Some((p, value))
2257        }
2258        EditOp::AppendItem { seq_path, value } => {
2259            let mut p = seq_path.clone();
2260            p.push(Seg::Index(0));
2261            Some((p, value))
2262        }
2263        _ => None,
2264    }
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269    use super::*;
2270    use crate::backend::FigBackend;
2271    use crate::schema::Constraint;
2272    use fig::Format;
2273
2274    const SAMPLE: &str = "\
2275# flower sample config — comments and formatting below should survive edits
2276title = \"flower\"
2277version = 1
2278enabled = true
2279
2280# the server block
2281[server]
2282host = \"localhost\"
2283port = 8080
2284tags = [\"alpha\", \"beta\"]
2285
2286[server.limits]
2287max_connections = 100
2288timeout = 30.5
2289";
2290
2291    fn sample_model() -> Model<FigBackend> {
2292        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open backend");
2293        Model::new(backend).expect("build model")
2294    }
2295
2296    fn select(model: &mut Model<FigBackend>, path: &[Seg]) {
2297        model.selected = model
2298            .rows
2299            .iter()
2300            .position(|r| r.path == path)
2301            .unwrap_or_else(|| panic!("no row for {path:?}"));
2302    }
2303
2304    fn type_value(model: &mut Model<FigBackend>, text: &str) {
2305        if let Mode::Editing { buffer, .. } = &mut model.mode {
2306            buffer.clear();
2307        }
2308        for c in text.chars() {
2309            model.edit_push(c);
2310        }
2311        model.edit_commit();
2312    }
2313
2314    #[test]
2315    fn a_fresh_model_has_nothing_to_report() {
2316        // The status line carries refusals. A model that has refused nothing
2317        // has nothing for it, and a frontend reads the empty string as "draw no
2318        // bar" rather than having to know which openings words are noise.
2319        assert!(
2320            sample_model().status.is_empty(),
2321            "status: {}",
2322            sample_model().status
2323        );
2324    }
2325
2326    #[test]
2327    fn page_items_carry_the_comments_written_on_them() {
2328        let model = sample_model();
2329        let page = model.root_page();
2330        let item = |k: &str| {
2331            page.items
2332                .iter()
2333                .find(|i| i.path == [Seg::Key(k.into())])
2334                .unwrap_or_else(|| panic!("no item {k}"))
2335        };
2336        assert_eq!(
2337            item("title").leading_comment.as_deref(),
2338            Some("flower sample config — comments and formatting below should survive edits")
2339        );
2340        assert_eq!(
2341            item("server").leading_comment.as_deref(),
2342            Some("the server block")
2343        );
2344        assert_eq!(item("version").leading_comment, None);
2345        assert_eq!(item("version").trailing_comment, None);
2346        // The projection over a bare `Value` knows nothing of them: it is the
2347        // model's pass that fills them, so a page built any other way has none.
2348        let bare = page::build_page(
2349            &model.value,
2350            &[],
2351            &HashSet::new(),
2352            &HashSet::new(),
2353            InlineBudget::default(),
2354        );
2355        assert!(bare.items.iter().all(|i| i.leading_comment.is_none()));
2356    }
2357
2358    #[test]
2359    fn a_comment_is_edited_through_the_same_footer_as_a_value() {
2360        let mut model = sample_model();
2361        select(
2362            &mut model,
2363            &[Seg::Key("server".into()), Seg::Key("port".into())],
2364        );
2365
2366        model.begin_edit_trailing_comment();
2367        assert!(matches!(
2368            model.mode,
2369            Mode::Editing {
2370                slot: EditSlot::TrailingComment,
2371                ..
2372            }
2373        ));
2374        type_value(&mut model, "dev only");
2375        let src = model.source_snapshot();
2376        assert!(src.contains("port = 8080 # dev only\n"), "{src}");
2377        assert!(model.dirty);
2378        assert_eq!(model.status, "comment updated");
2379        // …and the page shows it without a second read.
2380        let server = model.page_at(&[Seg::Key("server".into())]);
2381        let item = server
2382            .items
2383            .iter()
2384            .find(|i| i.path.last() == Some(&Seg::Key("port".into())))
2385            .expect("port is on server's page");
2386        assert_eq!(item.trailing_comment.as_deref(), Some("dev only"));
2387
2388        // Reopening seeds the footer with what is there.
2389        model.begin_edit_trailing_comment();
2390        if let Mode::Editing { buffer, .. } = &model.mode {
2391            assert_eq!(buffer, "dev only");
2392        } else {
2393            panic!("not editing");
2394        }
2395        // An empty commit removes it.
2396        type_value(&mut model, "");
2397        assert!(model.source_snapshot().contains("port = 8080\n"));
2398        assert_eq!(model.status, "comment removed");
2399
2400        // The block above, replaced whole — on a container as readily as a key.
2401        select(&mut model, &[Seg::Key("server".into())]);
2402        model.begin_edit_leading_comment();
2403        if let Mode::Editing { buffer, slot, .. } = &model.mode {
2404            assert_eq!(buffer, "the server block");
2405            assert_eq!(*slot, EditSlot::LeadingComment);
2406        } else {
2407            panic!("not editing");
2408        }
2409        type_value(&mut model, "where it listens");
2410        let src = model.source_snapshot();
2411        assert!(src.contains("# where it listens\n[server]"), "{src}");
2412        assert!(!src.contains("the server block"), "{src}");
2413        assert!(src.contains("port = 8080"), "value untouched");
2414    }
2415
2416    #[test]
2417    fn a_flow_item_owns_no_leading_comment_and_the_parents_block_is_never_edited_through_it() {
2418        // An item of a one-line array sits on its parent's line and owns no
2419        // line to comment. fig (since core 2.9) reports none for it, deletes
2420        // nothing through it, and refuses to add one — so the page shows the
2421        // block once, on the container, and no write through an item can take
2422        // it. Before that fix the model guarded this itself, by comparing the
2423        // item's reported comment with its parent's; the guard is gone.
2424        let src = "\
2425# the members
2426members = [\"a\", \"b\"]
2427";
2428        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2429        let mut model = Model::new(backend).expect("model");
2430        model.fit_to_room(20);
2431        let page = model.root_page();
2432        let members = [Seg::Key("members".into())];
2433        let item0 = [Seg::Key("members".into()), Seg::Index(0)];
2434        let of = |p: &[Seg]| page.items.iter().find(|i| i.path == p).expect("item");
2435        assert_eq!(of(&members).leading_comment.as_deref(), Some("the members"));
2436        assert_eq!(of(&item0).leading_comment, None, "not repeated per item");
2437        assert_eq!(model.leading_comment_at(&item0), None);
2438
2439        // Adding through the item is refused, and the source is untouched.
2440        model.set_leading_comment(&item0, Some("mine"));
2441        assert!(model.status.starts_with("rejected"), "{}", model.status);
2442        assert!(!model.dirty);
2443        assert_eq!(model.source_snapshot(), src, "parent's block kept");
2444
2445        // Removing through the item removes nothing — there is nothing there.
2446        model.set_leading_comment(&item0, None);
2447        assert_eq!(model.source_snapshot(), src, "parent's block kept");
2448
2449        // The container's own comment is still editable as its own.
2450        model.set_leading_comment(&members, Some("renamed"));
2451        assert!(model.source_snapshot().contains("# renamed\nmembers"));
2452    }
2453
2454    #[test]
2455    fn comment_ops_by_path_refresh_the_page() {
2456        let mut model = sample_model();
2457        let host = [Seg::Key("server".into()), Seg::Key("host".into())];
2458        model.set_leading_comment(&host, Some("first\nsecond"));
2459        assert_eq!(
2460            model.leading_comment_at(&host).as_deref(),
2461            Some("first\nsecond")
2462        );
2463        let page = model.page_at(&[Seg::Key("server".into())]);
2464        let item = page.items.iter().find(|i| i.path == host).unwrap();
2465        assert_eq!(item.leading_comment.as_deref(), Some("first\nsecond"));
2466        model.set_leading_comment(&host, None);
2467        assert_eq!(model.leading_comment_at(&host), None);
2468        assert!(!model.source_snapshot().contains("first"));
2469    }
2470
2471    #[test]
2472    fn edits_a_scalar_losslessly() {
2473        let mut model = sample_model();
2474
2475        select(&mut model, &[Seg::Key("version".into())]);
2476        model.begin_edit();
2477        type_value(&mut model, "2");
2478
2479        let src = model.source_snapshot();
2480        assert!(src.contains("version = 2"), "value changed:\n{src}");
2481        assert!(
2482            src.contains("# the server block"),
2483            "comment preserved:\n{src}"
2484        );
2485        assert!(
2486            src.contains("# flower sample config"),
2487            "header preserved:\n{src}"
2488        );
2489        assert!(model.dirty);
2490    }
2491
2492    #[test]
2493    fn edits_a_nested_string() {
2494        let mut model = sample_model();
2495
2496        select(
2497            &mut model,
2498            &[Seg::Key("server".into()), Seg::Key("host".into())],
2499        );
2500        model.begin_edit();
2501        type_value(&mut model, "example.com");
2502
2503        let src = model.source_snapshot();
2504        assert!(
2505            src.contains("host = \"example.com\""),
2506            "nested edit:\n{src}"
2507        );
2508        assert!(src.contains("port = 8080"), "sibling untouched:\n{src}");
2509    }
2510
2511    #[test]
2512    fn deletes_a_key() {
2513        let mut model = sample_model();
2514
2515        select(&mut model, &[Seg::Key("enabled".into())]);
2516        model.delete_selected();
2517
2518        let src = model.source_snapshot();
2519        assert!(!src.contains("enabled = true"), "key removed:\n{src}");
2520        assert!(src.contains("title = \"flower\""), "siblings kept:\n{src}");
2521    }
2522
2523    #[test]
2524    fn appends_a_sequence_item() {
2525        let mut model = sample_model();
2526        let tags = vec![Seg::Key("server".into()), Seg::Key("tags".into())];
2527        model.append_item(&tags, Value::Str("gamma".into()));
2528
2529        let src = model.source_snapshot();
2530        assert!(src.contains("gamma"), "item appended:\n{src}");
2531        assert!(
2532            src.contains("alpha") && src.contains("beta"),
2533            "siblings kept"
2534        );
2535        assert!(model.dirty);
2536    }
2537
2538    #[test]
2539    fn inserts_a_mapping_key() {
2540        let mut model = sample_model();
2541        let server = vec![Seg::Key("server".into())];
2542        model.insert_key(&server, "scheme", Value::Str("https".into()));
2543
2544        let src = model.source_snapshot();
2545        // fig may quote the inserted key (`"scheme" = …`); both are valid TOML.
2546        assert!(
2547            src.contains("scheme") && src.contains("= \"https\""),
2548            "key inserted:\n{src}"
2549        );
2550        assert!(src.contains("host = \"localhost\""), "siblings kept");
2551    }
2552
2553    #[test]
2554    fn moves_a_sequence_item_and_reorders_keys() {
2555        let mut model = sample_model();
2556
2557        // Move the second tag ("beta", index 1) up to index 0.
2558        select(
2559            &mut model,
2560            &[
2561                Seg::Key("server".into()),
2562                Seg::Key("tags".into()),
2563                Seg::Index(1),
2564            ],
2565        );
2566        model.move_selected_up();
2567        let src = model.source_snapshot();
2568        let a = src.find("alpha").unwrap();
2569        let b = src.find("beta").unwrap();
2570        assert!(b < a, "beta now precedes alpha:\n{src}");
2571
2572        // Move a top-level mapping entry down: title should follow version.
2573        select(&mut model, &[Seg::Key("title".into())]);
2574        model.move_selected_down();
2575        let src = model.source_snapshot();
2576        assert!(
2577            src.find("version").unwrap() < src.find("title").unwrap(),
2578            "version now precedes title:\n{src}"
2579        );
2580    }
2581
2582    #[test]
2583    fn hidden_top_level_keys_are_projected_out_but_kept_lossless() {
2584        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
2585        let mut model =
2586            Model::with_hidden(backend, vec!["title".into(), "enabled".into()]).expect("model");
2587
2588        // Hidden keys produce no rows…
2589        assert!(
2590            !model
2591                .rows
2592                .iter()
2593                .any(|r| r.path == [Seg::Key("title".into())])
2594        );
2595        assert!(
2596            !model
2597                .rows
2598                .iter()
2599                .any(|r| r.path == [Seg::Key("enabled".into())])
2600        );
2601        // …but a visible sibling is still there,
2602        assert!(
2603            model
2604                .rows
2605                .iter()
2606                .any(|r| r.path == [Seg::Key("version".into())])
2607        );
2608        // …and the hidden keys remain in the document bytes.
2609        assert!(model.source_snapshot().contains("title = \"flower\""));
2610        assert!(model.source_snapshot().contains("enabled = true"));
2611
2612        // Editing a visible key doesn't disturb the hidden ones.
2613        select(&mut model, &[Seg::Key("version".into())]);
2614        model.begin_edit();
2615        type_value(&mut model, "9");
2616        let src = model.source_snapshot();
2617        assert!(src.contains("version = 9"));
2618        assert!(src.contains("title = \"flower\"") && src.contains("enabled = true"));
2619    }
2620
2621    #[test]
2622    fn reorder_leaves_hidden_keys_in_place() {
2623        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
2624        let mut model = Model::with_hidden(backend, vec!["title".into()]).expect("model");
2625
2626        // Move a visible top-level key; the hidden `title` must keep its position.
2627        select(&mut model, &[Seg::Key("enabled".into())]);
2628        model.move_selected_up(); // enabled moves above version
2629        let src = model.source_snapshot();
2630        // title stays first (it was declared before version/enabled).
2631        let title = src.find("title").unwrap();
2632        let version = src.find("version").unwrap();
2633        let enabled = src.find("enabled").unwrap();
2634        assert!(
2635            title < version && title < enabled,
2636            "title stayed put:\n{src}"
2637        );
2638        assert!(enabled < version, "enabled moved above version:\n{src}");
2639    }
2640
2641    #[test]
2642    fn inserts_a_root_level_key() {
2643        let mut model = sample_model();
2644        model.insert_key(&[], "root_flag", Value::Bool(true));
2645        let src = model.source_snapshot();
2646        assert!(src.contains("root_flag"), "root key inserted:\n{src}");
2647        assert!(src.contains("title = \"flower\""), "existing kept");
2648    }
2649
2650    #[test]
2651    fn renames_a_key_losslessly() {
2652        let mut model = sample_model();
2653        select(&mut model, &[Seg::Key("version".into())]);
2654        model.rename_key(&[Seg::Key("version".into())], "revision");
2655        let src = model.source_snapshot();
2656        // fig may quote the new key (`"revision" = 1`); both are valid TOML.
2657        assert!(
2658            src.contains("revision") && src.contains("= 1"),
2659            "renamed with value kept:\n{src}"
2660        );
2661        assert!(!src.contains("version = 1"), "old key gone");
2662        // Selection re-anchored onto the renamed entry.
2663        assert_eq!(
2664            model.rows[model.selected].path,
2665            [Seg::Key("revision".into())]
2666        );
2667    }
2668
2669    #[test]
2670    fn rename_rejects_a_sequence_item() {
2671        let mut model = sample_model();
2672        model.rename_key(
2673            &[
2674                Seg::Key("server".into()),
2675                Seg::Key("tags".into()),
2676                Seg::Index(0),
2677            ],
2678            "nope",
2679        );
2680        assert!(model.status.contains("mapping keys"));
2681    }
2682
2683    #[test]
2684    fn schema_closed_vocabulary_rejects_an_unknown_edit() {
2685        use crate::schema::{Constraint, FieldRule};
2686        use fig_schema::{FieldType, PathPat, Term};
2687        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
2688        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2689        let mut model = Model::new(backend).expect("model");
2690        model.set_schema(crate::schema::Schema::new(vec![
2691            FieldRule::new(PathPat::each_item_of("audience"))
2692                .ty(FieldType::Str)
2693                .constraint(Constraint::Enum {
2694                    values: vec![Term::value("public"), Term::value("private")],
2695                    closed: true,
2696                }),
2697        ]));
2698
2699        // An unknown value is rejected at the commit funnel; the document is
2700        // untouched (fig never sees the edit).
2701        select(&mut model, &[Seg::Key("audience".into()), Seg::Index(0)]);
2702        model.begin_edit();
2703        type_value(&mut model, "familly");
2704        assert!(
2705            model.status.contains("rejected"),
2706            "status: {}",
2707            model.status
2708        );
2709        assert!(
2710            model.source_snapshot().contains("public"),
2711            "document unchanged:\n{}",
2712            model.source_snapshot()
2713        );
2714
2715        // A known value commits normally.
2716        model.begin_edit();
2717        type_value(&mut model, "private");
2718        let out = model.source_snapshot();
2719        assert!(out.contains("private"), "known value applied:\n{out}");
2720        assert!(!out.contains("public"), "old value replaced:\n{out}");
2721    }
2722
2723    /// A declared field the document omits is otherwise unreachable — it has no
2724    /// row, because rows come from the document. This is what lets a frontend
2725    /// offer it.
2726    #[test]
2727    fn addable_fields_are_the_declared_keys_the_document_lacks() {
2728        use crate::schema::{Constraint, FieldRule};
2729        use fig_schema::{FieldType, PathPat, Term};
2730        let src = "audience = [\"public\"]\ntitle = \"note\"\n";
2731        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2732        let mut model =
2733            Model::with_hidden(backend, vec!["title".into(), "updated".into()]).expect("model");
2734        model.set_schema(crate::schema::Schema::new(vec![
2735            // Present in the document — already reachable, so never offered.
2736            FieldRule::new(PathPat::key("audience")).ty(FieldType::Str),
2737            // An each-item rule governs *within* a field; it names none.
2738            FieldRule::new(PathPat::each_item_of("audience"))
2739                .ty(FieldType::Str)
2740                .constraint(Constraint::Enum {
2741                    values: vec![Term::value("public")],
2742                    closed: true,
2743                }),
2744            // Declared, absent, not managed — the one to offer.
2745            FieldRule::new(PathPat::key("created")).ty(FieldType::Str),
2746            // Declared and absent, but the embedder manages it.
2747            FieldRule::new(PathPat::key("updated")).ty(FieldType::Str),
2748        ]));
2749
2750        let offered: Vec<_> = model
2751            .addable_fields()
2752            .iter()
2753            .map(|r| match r.at.0.as_slice() {
2754                [SegPat::Key(k)] => k.clone(),
2755                _ => unreachable!("only single-key rules are offered"),
2756            })
2757            .collect();
2758        assert_eq!(offered, vec!["created".to_string()]);
2759
2760        // Once added it is a real row, so it stops being offered.
2761        model.insert_key(&[], "created", Value::Str("2026-07-24".into()));
2762        assert!(model.addable_fields().is_empty());
2763    }
2764
2765    /// A derived field keeps its row — unlike a hidden one — but declines every
2766    /// mutation, because the workspace rewrites it on the next save regardless.
2767    #[test]
2768    fn a_derived_field_is_visible_but_declines_edits() {
2769        let src = "title = \"note\"\nupdated = \"2026-07-01\"\ncreated = \"2026-06-01\"\n";
2770        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2771        let mut model = Model::with_managed(backend, vec!["title".into()], vec!["updated".into()])
2772            .expect("model");
2773
2774        // Hidden means no row; derived means a row that is marked.
2775        let labels: Vec<&str> = model.rows.iter().map(|r| r.label.as_str()).collect();
2776        assert_eq!(labels, vec!["updated", "created"]);
2777        assert!(model.is_derived(&[Seg::Key("updated".into())]));
2778        assert!(!model.is_derived(&[Seg::Key("created".into())]));
2779
2780        // Every shape of mutation is declined, and the document is untouched.
2781        model.set_scalar_text(&[Seg::Key("updated".into())], "2026-01-01");
2782        assert!(model.status.contains("maintained by the workspace"));
2783        model.rename_key(&[Seg::Key("updated".into())], "modified");
2784        assert!(model.status.contains("maintained by the workspace"));
2785        model.selected = 0;
2786        model.delete_selected();
2787        assert!(model.status.contains("maintained by the workspace"));
2788        let out = model.source_snapshot();
2789        assert!(
2790            out.contains("updated = \"2026-07-01\""),
2791            "unchanged:\n{out}"
2792        );
2793
2794        // A neighbouring ordinary field still edits normally.
2795        model.set_scalar_text(&[Seg::Key("created".into())], "2026-06-15");
2796        assert!(model.source_snapshot().contains("2026-06-15"));
2797    }
2798
2799    /// Without a schema there is nothing to declare, so nothing is offered —
2800    /// a standalone config keeps the free-text add path.
2801    #[test]
2802    fn addable_fields_are_empty_without_a_schema() {
2803        let src = "title = \"note\"\n";
2804        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2805        let model = Model::new(backend).expect("model");
2806        assert!(model.addable_fields().is_empty());
2807    }
2808
2809    #[test]
2810    fn schema_typed_field_keeps_a_numeric_string_as_text() {
2811        use crate::schema::FieldRule;
2812        use fig_schema::{FieldType, PathPat};
2813        let src = "code = \"x\"\n";
2814        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2815        let mut model = Model::new(backend).expect("model");
2816        model.set_schema(crate::schema::Schema::new(vec![
2817            FieldRule::new(PathPat::key("code")).ty(FieldType::Str),
2818        ]));
2819
2820        select(&mut model, &[Seg::Key("code".into())]);
2821        model.begin_edit();
2822        type_value(&mut model, "123");
2823        // Schema says `str`, so the buffer stays a quoted string rather than being
2824        // coerced to an integer the way the shape-guessing heuristic would.
2825        let out = model.source_snapshot();
2826        assert!(out.contains("code = \"123\""), "kept as string:\n{out}");
2827    }
2828
2829    /// The point of a default-collapsed set: the *opening* frame is already
2830    /// folded, without a toggle pass that walks the selection across the document.
2831    #[test]
2832    fn containers_can_arrive_collapsed() {
2833        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open");
2834        let model = Model::with_collapsed(
2835            backend,
2836            Vec::new(),
2837            Vec::new(),
2838            vec![
2839                vec![Seg::Key("server".into())],
2840                // Naming a scalar is inert, not an error — a caller collapses the
2841                // keys it means to without first sorting containers from scalars.
2842                vec![Seg::Key("title".into())],
2843            ],
2844        )
2845        .expect("model");
2846
2847        let server = model
2848            .rows
2849            .iter()
2850            .find(|r| r.path == [Seg::Key("server".into())])
2851            .expect("server row");
2852        assert!(!server.expanded, "collapsed before the first frame");
2853        assert!(
2854            !model.rows.iter().any(|r| r.path.len() > 1),
2855            "no descendant rows: {:?}",
2856            model.rows.iter().map(|r| &r.label).collect::<Vec<_>>()
2857        );
2858        // The inert scalar path didn't cost `title` its row.
2859        assert!(
2860            model
2861                .rows
2862                .iter()
2863                .any(|r| r.path == [Seg::Key("title".into())])
2864        );
2865        assert_eq!(model.selected, 0, "selection untouched");
2866    }
2867
2868    /// Unlike `activate`, folding by path is not a selection move — that is the
2869    /// whole reason a caller reaches for it.
2870    #[test]
2871    fn set_collapsed_folds_by_path_without_moving_the_selection() {
2872        let mut model = sample_model();
2873        select(&mut model, &[Seg::Key("title".into())]);
2874
2875        model.set_collapsed(&[Seg::Key("server".into())], true);
2876        assert!(model.is_collapsed(&[Seg::Key("server".into())]));
2877        assert!(
2878            !model.rows.iter().any(|r| r.path.len() > 1),
2879            "children hidden"
2880        );
2881        assert_eq!(
2882            model.rows[model.selected].path,
2883            [Seg::Key("title".into())],
2884            "selection stayed on title"
2885        );
2886
2887        model.set_collapsed(&[Seg::Key("server".into())], false);
2888        assert!(!model.is_collapsed(&[Seg::Key("server".into())]));
2889        assert!(
2890            model
2891                .rows
2892                .iter()
2893                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())])
2894        );
2895        assert_eq!(model.rows[model.selected].path, [Seg::Key("title".into())]);
2896    }
2897
2898    /// The one case where the selection *must* move: it was inside the fold.
2899    #[test]
2900    fn set_collapsed_reanchors_a_selection_it_swallowed() {
2901        let mut model = sample_model();
2902        select(
2903            &mut model,
2904            &[Seg::Key("server".into()), Seg::Key("host".into())],
2905        );
2906        model.set_collapsed(&[Seg::Key("server".into())], true);
2907        assert_eq!(
2908            model.rows[model.selected].path,
2909            [Seg::Key("server".into())],
2910            "landed on the container that swallowed it"
2911        );
2912    }
2913
2914    /// The insert/append counterparts of the type-directed scalar edit: without
2915    /// them a caller shape-guesses, and `2026` lands in a `str` list as an integer.
2916    #[test]
2917    fn insert_and_append_are_type_directed_by_the_schema() {
2918        use crate::schema::FieldRule;
2919        use fig_schema::{FieldType, PathPat};
2920        let src = "tags = [\"alpha\"]\n\n[meta]\nk = \"v\"\n";
2921        let backend = FigBackend::open(src.as_bytes(), Format::Toml).expect("open");
2922        let mut model = Model::new(backend).expect("model");
2923        model.set_schema(crate::schema::Schema::new(vec![
2924            // The *items* of `tags` are strings — the list itself is a seq.
2925            FieldRule::new(PathPat::each_item_of("tags")).ty(FieldType::Str),
2926            FieldRule::new(PathPat::key("year")).ty(FieldType::Str),
2927            FieldRule::new(PathPat(vec![
2928                fig_schema::SegPat::Key("meta".into()),
2929                fig_schema::SegPat::Key("code".into()),
2930            ]))
2931            .ty(FieldType::Str),
2932        ]));
2933
2934        model.append_item_text(&[Seg::Key("tags".into())], "2026");
2935        model.insert_key_text(&[], "year", "2026");
2936        // The nested case flower-ffi and Diaryx both shape-guessed.
2937        model.insert_key_text(&[Seg::Key("meta".into())], "code", "2026");
2938
2939        let out = model.source_snapshot();
2940        assert!(
2941            !out.contains("2026,") && !out.contains("[2026]") && !out.contains("= 2026"),
2942            "no bare integers survived the schema:\n{out}"
2943        );
2944        assert_eq!(
2945            model.value_at(&[Seg::Key("tags".into()), Seg::Index(1)]),
2946            Some(&Value::Str("2026".into())),
2947            "list item took the each-item type:\n{out}"
2948        );
2949        assert_eq!(
2950            model.value_at(&[Seg::Key("year".into())]),
2951            Some(&Value::Str("2026".into()))
2952        );
2953        assert_eq!(
2954            model.value_at(&[Seg::Key("meta".into()), Seg::Key("code".into())]),
2955            Some(&Value::Str("2026".into()))
2956        );
2957    }
2958
2959    /// With no rule to consult they fall back to the same shape-guessing the raw
2960    /// `insert_key`/`append_item` callers do today, so a standalone config is
2961    /// unaffected.
2962    #[test]
2963    fn insert_and_append_text_shape_guess_without_a_schema() {
2964        let mut model = sample_model();
2965        model.append_item_text(&[Seg::Key("server".into()), Seg::Key("tags".into())], "42");
2966        model.insert_key_text(&[], "count", "7");
2967        assert_eq!(
2968            model.value_at(&[
2969                Seg::Key("server".into()),
2970                Seg::Key("tags".into()),
2971                Seg::Index(2)
2972            ]),
2973            Some(&Value::Int(42))
2974        );
2975        assert_eq!(
2976            model.value_at(&[Seg::Key("count".into())]),
2977            Some(&Value::Int(7))
2978        );
2979    }
2980
2981    /// The walkers a backend needs, over a plain `Value` — no `Model` in reach.
2982    #[test]
2983    fn tree_walkers_resolve_paths_and_reject_mismatches() {
2984        let model = sample_model();
2985        let root = model.value_at(&[]).expect("root");
2986
2987        assert_eq!(
2988            tree::value_at(root, &[Seg::Key("server".into()), Seg::Key("port".into())]),
2989            Some(&Value::Int(8080))
2990        );
2991        assert_eq!(
2992            tree::seq_len(root, &[Seg::Key("server".into()), Seg::Key("tags".into())]),
2993            Some(2)
2994        );
2995        // Not a sequence, versus not there at all — both `None`, and neither is a
2996        // length of zero a caller could mistake for an empty list.
2997        assert_eq!(tree::seq_len(root, &[Seg::Key("title".into())]), None);
2998        assert_eq!(tree::seq_len(root, &[Seg::Key("absent".into())]), None);
2999        assert_eq!(
3000            tree::map_keys(root, &[Seg::Key("server".into())]),
3001            Some(vec![
3002                "host".to_string(),
3003                "port".to_string(),
3004                "tags".to_string(),
3005                "limits".to_string()
3006            ])
3007        );
3008        assert_eq!(tree::map_keys(root, &[Seg::Key("title".into())]), None);
3009        // A key step into a sequence resolves to nothing rather than guessing.
3010        assert_eq!(
3011            tree::value_at(
3012                root,
3013                &[
3014                    Seg::Key("server".into()),
3015                    Seg::Key("tags".into()),
3016                    Seg::Key("0".into())
3017                ]
3018            ),
3019            None
3020        );
3021    }
3022
3023    #[test]
3024    fn navigation_folds_and_reanchors() {
3025        let mut model = sample_model();
3026
3027        select(&mut model, &[Seg::Key("server".into())]);
3028        model.collapse_or_leave();
3029        assert!(
3030            !model
3031                .rows
3032                .iter()
3033                .any(|r| r.path == [Seg::Key("server".into()), Seg::Key("host".into())]),
3034            "collapsed children hidden"
3035        );
3036        assert_eq!(model.rows[model.selected].path, [Seg::Key("server".into())]);
3037    }
3038
3039    // ── the page projection ───────────────────────────────────────────────
3040
3041    fn key(k: &str) -> Seg {
3042        Seg::Key(k.to_string())
3043    }
3044
3045    /// A model in the page view, cursor on the root page.
3046    fn paged_model() -> Model<FigBackend> {
3047        let mut model = sample_model();
3048        model.set_view(ViewMode::Pages);
3049        model
3050    }
3051
3052    fn page_labels(model: &Model<FigBackend>) -> Vec<String> {
3053        model.page().items.iter().map(|i| i.label.clone()).collect()
3054    }
3055
3056    fn selected_label(model: &Model<FigBackend>) -> String {
3057        model.page_item().expect("a selected item").label.clone()
3058    }
3059
3060    #[test]
3061    fn drilling_opens_a_page_and_backing_out_returns_the_cursor_to_it() {
3062        let mut model = paged_model();
3063        assert!(model.focus().is_empty());
3064
3065        // Down to `server`, then in.
3066        for _ in 0..3 {
3067            model.page_move_down();
3068        }
3069        assert_eq!(selected_label(&model), "server");
3070        model.page_enter();
3071
3072        assert_eq!(model.focus(), &[key("server")]);
3073        assert_eq!(selected_label(&model), "host");
3074
3075        model.page_back();
3076        assert!(model.focus().is_empty());
3077        assert_eq!(selected_label(&model), "server");
3078    }
3079
3080    #[test]
3081    fn depth_costs_a_page_not_a_column() {
3082        let mut model = paged_model();
3083        // Two levels down, and the page is still four items of one rank plus the
3084        // members of the groups inlined into it — never an indentation ladder.
3085        model.focus_on(&[key("server"), key("limits")]);
3086        assert_eq!(model.focus(), &[key("server")]);
3087        assert!(model.page().items.iter().all(|i| i.inset <= 1));
3088        assert_eq!(selected_label(&model), "limits");
3089
3090        // A group header opens nothing — its members are already here — so `l`
3091        // steps onto the first of them instead.
3092        model.page_enter();
3093        assert_eq!(model.focus(), &[key("server")]);
3094        assert_eq!(selected_label(&model), "max_connections");
3095    }
3096
3097    #[test]
3098    fn raising_the_inline_budget_turns_the_root_page_into_the_document() {
3099        let mut model = paged_model();
3100        model.set_inline_budget(InlineBudget::new(99, 8));
3101
3102        // Everything inlines, so the cursor can stand on the deepest member
3103        // without ever leaving the root page…
3104        model.focus_on(&[key("server"), key("limits"), key("timeout")]);
3105        assert!(model.focus().is_empty());
3106        assert_eq!(selected_label(&model), "timeout");
3107        assert!(model.page().items.iter().any(|i| i.inset == 2));
3108
3109        // …and with nothing left to drill into, a second pane has no job.
3110        assert!(model.pages_would_degenerate());
3111
3112        // Back to the default, the same node is reached through its page again.
3113        model.set_inline_budget(InlineBudget::default());
3114        model.focus_on(&[key("server"), key("limits"), key("timeout")]);
3115        assert_eq!(model.focus(), &[key("server")]);
3116    }
3117
3118    #[test]
3119    fn a_group_header_never_opens_a_page_that_repeats_it() {
3120        let mut model = paged_model();
3121        model.focus_on(&[key("server")]);
3122        model.page_enter();
3123        for header in ["tags", "limits"] {
3124            let at = model
3125                .page()
3126                .items
3127                .iter()
3128                .position(|i| i.label == header)
3129                .expect("the group header");
3130            assert!(!model.page().items[at].is_drill());
3131            // Whatever the cursor does, the focused page never becomes the group's.
3132            model.page_enter();
3133            assert_eq!(model.focus(), &[key("server")]);
3134        }
3135    }
3136
3137    #[test]
3138    fn an_edit_made_from_a_page_is_lossless() {
3139        let mut model = paged_model();
3140        // An inlined member, two ranks below the page's focus — the case where the
3141        // page's layout and the document's shape disagree most.
3142        model.focus_on(&[key("server"), key("limits"), key("timeout")]);
3143        assert_eq!(model.focus(), &[key("server")]);
3144        assert_eq!(selected_label(&model), "timeout");
3145
3146        model.begin_edit();
3147        type_value(&mut model, "45.5");
3148
3149        let src = model.source_snapshot();
3150        assert_eq!(src, SAMPLE.replace("timeout = 30.5", "timeout = 45.5"));
3151        assert!(model.dirty);
3152        // The cursor stayed on the field that was edited, in both projections.
3153        assert_eq!(selected_label(&model), "timeout");
3154        assert_eq!(
3155            model.rows[model.selected].path,
3156            vec![key("server"), key("limits"), key("timeout")]
3157        );
3158    }
3159
3160    #[test]
3161    fn losing_the_container_you_are_standing_in_pops_you_out() {
3162        // `b` nests a container, so it is a real drill rather than an inlined
3163        // group — the only kind of row a page can be opened from.
3164        let backend =
3165            FigBackend::open(br#"{"a": {"b": {"c": {"d": 1}}}}"#, Format::Json).expect("open");
3166        let mut model = Model::new(backend).expect("model");
3167        model.set_view(ViewMode::Pages);
3168        model.focus_on(&[key("a"), key("b")]);
3169        model.page_enter();
3170        assert_eq!(model.focus(), &[key("a"), key("b")]);
3171
3172        // Replace the container the page is listing with a scalar: the focus now
3173        // names something that cannot be listed at all.
3174        model.set_value_at(&[key("a"), key("b")], Value::Int(1));
3175
3176        assert_eq!(model.focus(), &[key("a")]);
3177        assert_eq!(page_labels(&model), vec!["b"]);
3178    }
3179
3180    #[test]
3181    fn switching_views_carries_the_selection_both_ways() {
3182        let mut model = sample_model();
3183        select(&mut model, &[key("server"), key("limits"), key("timeout")]);
3184
3185        model.set_view(ViewMode::Pages);
3186        // The page that *lists* an inlined member is its grandparent's.
3187        assert_eq!(model.focus(), &[key("server")]);
3188        assert_eq!(selected_label(&model), "timeout");
3189
3190        // Move within the page, and the tree lands where the page left off.
3191        model.page_move_up();
3192        assert_eq!(selected_label(&model), "max_connections");
3193        model.set_view(ViewMode::Tree);
3194        assert_eq!(
3195            model.rows[model.selected].path,
3196            vec![key("server"), key("limits"), key("max_connections")]
3197        );
3198    }
3199
3200    #[test]
3201    fn switching_to_the_tree_opens_the_lineage_of_a_folded_selection() {
3202        let mut model = sample_model();
3203        model.set_collapsed(&[key("server")], true);
3204        model.set_view(ViewMode::Pages);
3205        model.focus_on(&[key("server"), key("host")]);
3206
3207        model.set_view(ViewMode::Tree);
3208        // `server` was shut, so `host` had no row to land on until it was opened.
3209        assert!(!model.is_collapsed(&[key("server")]));
3210        assert_eq!(
3211            model.rows[model.selected].path,
3212            vec![key("server"), key("host")]
3213        );
3214    }
3215
3216    /// The `repos.figl` shape: one key, holding a list too long to inline.
3217    fn list_model() -> Model<FigBackend> {
3218        let items: Vec<String> = (0..22)
3219            .map(|i| format!(r#"{{"name": "r{i}", "lang": "rust"}}"#))
3220            .collect();
3221        let src = format!(r#"{{"repo": [{}]}}"#, items.join(", "));
3222        let backend = FigBackend::open(src.as_bytes(), Format::Json).expect("open");
3223        let mut model = Model::new(backend).expect("model");
3224        model.set_view(ViewMode::Pages);
3225        model
3226    }
3227
3228    #[test]
3229    fn a_document_that_is_one_list_opens_on_the_list() {
3230        let mut model = list_model();
3231        // Before: a root page whose one row names the file you just opened.
3232        assert_eq!(page_labels(&model), ["repo"]);
3233
3234        model.enter_document();
3235        assert_eq!(model.focus(), &[Seg::Key("repo".into())]);
3236        assert_eq!(model.page().items.len(), 22);
3237        assert_eq!(model.page_selected(), 0);
3238        // The page it skipped is one step out, not gone: `repo` still renames,
3239        // deletes and takes an append there.
3240        model.page_back();
3241        assert_eq!(page_labels(&model), ["repo"]);
3242    }
3243
3244    #[test]
3245    fn a_root_page_with_something_to_say_is_opened_where_it_is() {
3246        let mut model = paged_model();
3247        model.enter_document();
3248        assert!(model.focus().is_empty());
3249        assert_eq!(selected_label(&model), "title");
3250    }
3251
3252    #[test]
3253    fn the_page_leads_the_split_when_the_one_behind_it_holds_a_single_row() {
3254        let mut model = list_model();
3255        model.enter_document();
3256        // The root page holds one row, so drawing it beside this one would
3257        // spend half the width on something nobody can choose between. This
3258        // page leads instead, and the other pane previews what it opens.
3259        assert!(model.page_leads_the_split());
3260        assert!(!model.pages_would_degenerate());
3261        assert_eq!(model.peek_page().expect("the first repo").items.len(), 2);
3262
3263        // A root page with four rows on it is worth a pane, so it keeps one.
3264        let mut model = paged_model();
3265        model.focus_on(&[Seg::Key("server".into())]);
3266        model.page_enter();
3267        assert!(!model.page_leads_the_split());
3268    }
3269
3270    #[test]
3271    fn fitting_the_room_puts_a_document_that_fits_on_one_page() {
3272        let mut model = paged_model();
3273        assert!(model.page().has_drills());
3274
3275        // Twelve rows of document, and room for them.
3276        model.fit_to_room(12);
3277        assert!(!model.page().has_drills());
3278        assert!(model.pages_would_degenerate());
3279        assert_eq!(page_labels(&model).len(), 12);
3280
3281        // One row short and the founding rule is back.
3282        model.fit_to_room(11);
3283        assert!(model.page().has_drills());
3284        assert_eq!(
3285            page_labels(&model),
3286            ["title", "version", "enabled", "server"]
3287        );
3288    }
3289
3290    #[test]
3291    fn fitting_the_room_leaves_the_cursor_and_the_focus_where_they_were() {
3292        // A resize is not a navigation. It changes how much of the document a
3293        // page shows, and nothing about where the reader is in it.
3294        let mut model = list_model();
3295        model.enter_document();
3296        model.page_move_down();
3297        model.page_move_down();
3298        let (focus, at) = (model.focus().to_vec(), selected_label(&model));
3299        model.fit_to_room(80);
3300        assert_eq!(model.focus(), focus.as_slice());
3301        assert_eq!(selected_label(&model), at);
3302    }
3303
3304    #[test]
3305    fn a_document_poured_onto_one_page_wastes_a_second_pane_wherever_you_are() {
3306        // Nothing to navigate to from the root, so there is no lineage to put
3307        // two panes on — even standing one level in, where a parent page and a
3308        // page would otherwise be two halves that repeat each other.
3309        let mut model = list_model();
3310        model.enter_document();
3311        model.set_inline_budget(InlineBudget::new(99, 8));
3312        assert!(!model.focus().is_empty());
3313        assert!(model.pages_would_degenerate());
3314    }
3315
3316    #[test]
3317    fn a_flat_document_would_waste_a_second_pane() {
3318        let flat = FigBackend::open(
3319            b"a = 1
3320b = 2
3321",
3322            Format::Toml,
3323        )
3324        .expect("open");
3325        let flat = Model::new(flat).expect("model");
3326        assert!(flat.pages_would_degenerate());
3327        assert!(!sample_model().pages_would_degenerate());
3328    }
3329
3330    #[test]
3331    fn the_root_page_previews_what_the_cursor_would_open() {
3332        let mut model = paged_model();
3333        assert_eq!(selected_label(&model), "title");
3334        assert!(model.peek_page().is_none(), "a scalar has no page");
3335
3336        for _ in 0..3 {
3337            model.page_move_down();
3338        }
3339        let peek = model.peek_page().expect("server's page");
3340        assert_eq!(peek.focus, vec![key("server")]);
3341        assert_eq!(peek.breadcrumb("‹document›"), "server");
3342    }
3343
3344    #[test]
3345    fn opening_a_compressed_row_lands_past_the_pages_that_say_nothing() {
3346        let backend = FigBackend::open(
3347            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
3348            Format::Json,
3349        )
3350        .expect("open");
3351        let mut model = Model::new(backend).expect("model");
3352        model.set_view(ViewMode::Pages);
3353
3354        model.page_enter();
3355        // One step, two levels: the `exports` page held nothing but `journal`.
3356        assert_eq!(model.focus(), &[key("exports"), key("journal")]);
3357        assert_eq!(
3358            model.page().breadcrumb("‹document›"),
3359            "exports › journal",
3360            "the trail still shows what was skipped"
3361        );
3362
3363        // Backing out retraces the step: one tap in was two levels, so one tap
3364        // out is two levels, and it lands on the page that listed the row rather
3365        // than on the page the compression existed to skip.
3366        model.page_back();
3367        assert!(model.focus().is_empty());
3368        // The cursor is on the row that was opened, which still addresses
3369        // `exports` and still renames it.
3370        assert_eq!(
3371            model.page_item().map(|i| i.label.clone()),
3372            Some("exports".into())
3373        );
3374        assert!(model.page_item().unwrap().can_rename());
3375    }
3376
3377    #[test]
3378    fn the_left_pane_is_the_page_that_listed_the_row_not_the_level_above() {
3379        let backend = FigBackend::open(
3380            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
3381            Format::Json,
3382        )
3383        .expect("open");
3384        let mut model = Model::new(backend).expect("model");
3385        model.set_view(ViewMode::Pages);
3386        model.page_enter();
3387        assert_eq!(model.focus(), &[key("exports"), key("journal")]);
3388
3389        // One level out is `exports`, whose page holds nothing but the row that
3390        // was tapped — the page the compression exists to skip. The left pane
3391        // walks past it to the page that actually listed the row.
3392        assert!(
3393            model.parent_page().focus.is_empty(),
3394            "the root, not `exports`"
3395        );
3396        // And it can still mark what was opened: the compressed row answers for
3397        // its whole chain.
3398        let marked = model
3399            .parent_page()
3400            .position_of(model.focus())
3401            .expect("marked");
3402        assert_eq!(model.parent_page().items[marked].label, "exports");
3403
3404        // And backing out agrees with the pane: `exports` is skipped both ways,
3405        // so the page on the left is the page you land on.
3406        model.page_back();
3407        assert!(model.focus().is_empty());
3408        assert_eq!(model.focus(), model.parent_page().focus);
3409    }
3410
3411    #[test]
3412    fn a_compressed_row_still_answers_ops_as_its_outermost_node() {
3413        let backend = FigBackend::open(
3414            br#"{"exports": {"journal": {"label": "x", "gate": {"f": 1}}}, "z": 1}"#,
3415            Format::Json,
3416        )
3417        .expect("open");
3418        let mut model = Model::new(backend).expect("model");
3419        model.set_view(ViewMode::Pages);
3420
3421        // Deleting a row reading `exports › journal` takes the whole chain, so
3422        // no empty `exports: {}` is left behind to delete separately.
3423        model.delete_selected();
3424        assert!(!model.source_snapshot().contains("exports"));
3425        assert!(!model.source_snapshot().contains("journal"));
3426        assert!(model.source_snapshot().contains('z'));
3427    }
3428
3429    /// A host driving both surfaces — a metadata pane beside a settings page —
3430    /// hands a *row* index to a model left standing in the page projection. The
3431    /// index means nothing there, and before `select_row` asserted the tree the
3432    /// delete that followed read the page cursor and removed a different node.
3433    #[test]
3434    fn a_row_index_deletes_the_row_it_names_even_from_the_page_projection() {
3435        let backend = FigBackend::open(
3436            br#"{"alpha": 1, "beta": 2, "gamma": {"inner": 3}}"#,
3437            Format::Json,
3438        )
3439        .expect("open");
3440        let mut model = Model::new(backend).expect("model");
3441
3442        // Go and stand somewhere in the page projection, with its cursor on a
3443        // different node than the row index below names.
3444        model.set_view(ViewMode::Pages);
3445        model.page_move_down();
3446        assert_eq!(
3447            model.page_item().map(|i| i.label.clone()),
3448            Some("beta".into())
3449        );
3450
3451        // Now the other surface speaks, in its own coordinates, without first
3452        // announcing a switch.
3453        model.select_row(0);
3454        model.delete_selected();
3455
3456        assert!(
3457            !model.source_snapshot().contains("alpha"),
3458            "row 0 was `alpha`"
3459        );
3460        assert!(
3461            model.source_snapshot().contains("beta"),
3462            "the page cursor was not the target"
3463        );
3464    }
3465
3466    /// The mirror: page vocabulary asserts pages, so a page op after tree work
3467    /// acts on the page cursor rather than on whatever row was last selected.
3468    #[test]
3469    fn a_page_op_acts_on_the_page_cursor_even_from_the_tree_projection() {
3470        // `gamma` holds a container *and* a scalar, so it neither inlines into
3471        // the root page nor compresses into a chain — it is a plain drill row.
3472        let backend = FigBackend::open(
3473            br#"{"alpha": 1, "beta": 2, "gamma": {"inner": {"deep": 3}, "flag": true}}"#,
3474            Format::Json,
3475        )
3476        .expect("open");
3477        let mut model = Model::new(backend).expect("model");
3478
3479        model.select_row(0);
3480        assert_eq!(model.view(), ViewMode::Tree);
3481
3482        // `page_enter` is page vocabulary; it must not be read against the tree.
3483        model.page_move_down();
3484        model.page_move_down();
3485        model.page_enter();
3486        assert_eq!(model.view(), ViewMode::Pages);
3487        assert_eq!(model.focus(), &[key("gamma")]);
3488    }
3489
3490    #[test]
3491    fn backing_out_past_the_root_is_inert() {
3492        let mut model = paged_model();
3493        model.page_back();
3494        assert!(model.focus().is_empty());
3495        assert_eq!(model.page_selected(), 0);
3496    }
3497
3498    /// Arriving and leaving cost the same number of steps.
3499    ///
3500    /// `views` holds only `date`, so its row compresses and `page_enter` lands
3501    /// straight on `views.date`. Popping one raw segment would put you on the
3502    /// `views` page — one row, named `date`, which is the page compression
3503    /// exists to skip — and make the way out twice as long as the way in.
3504    #[test]
3505    fn backing_out_retraces_what_entering_skipped() {
3506        let backend = FigBackend::open(
3507            br#"{"views": {"date": {"icon": "calendar", "group": ["created"], "by": "year"}}, "fixity": "all"}"#,
3508            Format::Json,
3509        )
3510        .expect("open");
3511        let mut model = Model::new(backend).expect("model");
3512        model.set_view(ViewMode::Pages);
3513
3514        // One step in, past `views`, to the first page with more on it than the
3515        // name that was tapped.
3516        model.page_enter();
3517        assert_eq!(model.focus(), &[key("views"), key("date")]);
3518
3519        // One step out, to the page that listed the row — not to `views`.
3520        model.page_back();
3521        assert!(model.focus().is_empty());
3522        // And the cursor is back on the row that was opened: a compressed row
3523        // answers for its whole chain, so the child path finds it.
3524        assert_eq!(model.page_selected(), 0);
3525    }
3526
3527    /// The skipped page held nothing but the chain, so skipping it takes no
3528    /// operation away: the row on the page we land on still addresses `views`.
3529    #[test]
3530    fn the_skipped_level_is_still_operable_from_the_row() {
3531        let backend = FigBackend::open(
3532            br#"{"views": {"date": {"icon": "calendar", "group": ["created"], "by": "year"}}, "fixity": "all"}"#,
3533            Format::Json,
3534        )
3535        .expect("open");
3536        let mut model = Model::new(backend).expect("model");
3537        model.set_view(ViewMode::Pages);
3538
3539        model.page_enter();
3540        model.page_back();
3541        let row = model.page_item().expect("a row under the cursor");
3542        assert_eq!(row.path, vec![key("views")]);
3543        assert_eq!(row.descend_to, vec![key("views"), key("date")]);
3544    }
3545
3546    #[test]
3547    fn the_two_panes_are_consecutive_levels_of_one_lineage() {
3548        // Every level here holds two things, so no row compresses and each
3549        // `page_enter` moves exactly one level — which is what this is about.
3550        let backend = FigBackend::open(
3551            br#"{"jobs": {"plan": {"steps": {"a": 1, "b": {"c": 2}}, "id": 3}, "name": "x"}}"#,
3552            Format::Json,
3553        )
3554        .expect("open");
3555        let mut model = Model::new(backend).expect("model");
3556        model.set_view(ViewMode::Pages);
3557
3558        // At the root there is no parent to show on the left.
3559        assert!(model.parent_page().is_empty());
3560
3561        model.page_enter(); // jobs
3562        assert_eq!(model.parent_page().focus, Vec::<Seg>::new());
3563        model.page_enter(); // jobs.plan
3564        assert_eq!(model.parent_page().focus, vec![key("jobs")]);
3565        model.page_enter(); // jobs.plan.steps
3566        assert_eq!(model.parent_page().focus, vec![key("jobs"), key("plan")]);
3567
3568        // The left pane can always mark the row the right one was opened from.
3569        assert!(model.parent_page().position_of(model.focus()).is_some());
3570    }
3571
3572    // ── stable identity for a sequence item ───────────────────────────────
3573
3574    /// Five items, each named by a field that tells it from the others — the
3575    /// shape a page of a list actually has.
3576    fn steps_model() -> Model<FigBackend> {
3577        let src = concat!(
3578            r#"{"steps": ["#,
3579            r#"{"name": "alpha", "run": "a"},"#,
3580            r#"{"name": "bravo", "run": "b"},"#,
3581            r#"{"name": "charlie", "run": "c"},"#,
3582            r#"{"name": "delta", "run": "d"},"#,
3583            r#"{"name": "echo", "run": "e"}"#,
3584            r#"], "tags": ["x", "y", "z"]}"#,
3585        );
3586        let backend = FigBackend::open(src.as_bytes(), Format::Json).expect("open");
3587        let mut model = Model::new(backend).expect("model");
3588        model.set_view(ViewMode::Pages);
3589        // Nothing inlines, so each step is a page you can stand *in* — which is
3590        // the case a reorder re-points and the one this is about.
3591        model.set_inline_budget(InlineBudget::new(0, 0));
3592        model
3593    }
3594
3595    /// The name of the step the page is standing on, read out of the document.
3596    fn focused_name(model: &Model<FigBackend>) -> Option<String> {
3597        let mut path = model.focus().to_vec();
3598        path.push(Seg::Key("name".into()));
3599        match model.value_at(&path) {
3600            Some(Value::Str(s)) => Some(s.clone()),
3601            _ => None,
3602        }
3603    }
3604
3605    #[test]
3606    fn a_reorder_does_not_re_point_the_page_you_have_open() {
3607        let mut model = steps_model();
3608        let steps = vec![Seg::Key("steps".into())];
3609        let mut third = steps.clone();
3610        third.push(Seg::Index(2));
3611        model.focus_on(&third);
3612        model.page_enter();
3613        assert_eq!(focused_name(&model).as_deref(), Some("charlie"));
3614
3615        // `alpha` goes to the end, so everything above it shifts down one.
3616        model.commit(
3617            EditOp::MoveItem {
3618                seq_path: steps.clone(),
3619                from: 0,
3620                to: 4,
3621            },
3622            steps.clone(),
3623            "moved",
3624        );
3625        assert_eq!(model.focus(), [Seg::Key("steps".into()), Seg::Index(1)]);
3626        assert_eq!(
3627            focused_name(&model).as_deref(),
3628            Some("charlie"),
3629            "the page is still the step it was opened on"
3630        );
3631
3632        // …and the undo puts it back, by the same rule in reverse.
3633        model.undo();
3634        assert_eq!(model.focus(), [Seg::Key("steps".into()), Seg::Index(2)]);
3635        assert_eq!(focused_name(&model).as_deref(), Some("charlie"));
3636    }
3637
3638    #[test]
3639    fn deleting_an_earlier_sibling_does_not_re_point_it_either() {
3640        let mut model = steps_model();
3641        let steps = vec![Seg::Key("steps".into())];
3642        let mut third = steps.clone();
3643        third.push(Seg::Index(2));
3644        model.focus_on(&third);
3645        model.page_enter();
3646
3647        model.commit(
3648            EditOp::RemoveItem {
3649                seq_path: steps.clone(),
3650                index: 0,
3651            },
3652            steps.clone(),
3653            "deleted",
3654        );
3655        assert_eq!(model.focus(), [Seg::Key("steps".into()), Seg::Index(1)]);
3656        assert_eq!(focused_name(&model).as_deref(), Some("charlie"));
3657
3658        // An append after it changes nothing, which is the third case and the
3659        // one that must not move.
3660        model.commit(
3661            EditOp::AppendItem {
3662                seq_path: steps.clone(),
3663                value: Value::Map(vec![(
3664                    Value::Str("name".into()),
3665                    Value::Str("foxtrot".into()),
3666                )]),
3667            },
3668            steps,
3669            "appended",
3670        );
3671        assert_eq!(model.focus(), [Seg::Key("steps".into()), Seg::Index(1)]);
3672        assert_eq!(focused_name(&model).as_deref(), Some("charlie"));
3673    }
3674
3675    #[test]
3676    fn a_page_the_edit_removed_falls_back_to_the_clamping_it_always_had() {
3677        let mut model = steps_model();
3678        let steps = vec![Seg::Key("steps".into())];
3679        let mut third = steps.clone();
3680        third.push(Seg::Index(2));
3681        model.focus_on(&third);
3682        model.page_enter();
3683
3684        model.commit(
3685            EditOp::RemoveItem {
3686                seq_path: steps.clone(),
3687                index: 2,
3688            },
3689            steps,
3690            "deleted",
3691        );
3692        // Nothing to re-find: the key `charlie` named is gone, so the index is
3693        // kept and the page is whatever is at that index now — the clamping
3694        // that was there before identity was. Identity buys back the cases
3695        // where the item still exists, and claims nothing about the one where
3696        // it does not.
3697        assert_eq!(model.focus(), [Seg::Key("steps".into()), Seg::Index(2)]);
3698        assert_eq!(focused_name(&model).as_deref(), Some("delta"));
3699    }
3700
3701    #[test]
3702    fn a_scalar_sequence_is_identified_by_its_text() {
3703        let mut model = steps_model();
3704        let tags = vec![Seg::Key("tags".into())];
3705        assert_eq!(model.item_key(&tags, 0).as_deref(), Some("x"));
3706        assert_eq!(model.item_key(&tags, 2).as_deref(), Some("z"));
3707
3708        model.commit(
3709            EditOp::MoveItem {
3710                seq_path: tags.clone(),
3711                from: 0,
3712                to: 2,
3713            },
3714            tags.clone(),
3715            "moved",
3716        );
3717        // The text went with the item, so the key follows it to its new index.
3718        assert_eq!(model.item_key(&tags, 2).as_deref(), Some("x"));
3719        assert_eq!(model.item_key(&tags, 0).as_deref(), Some("y"));
3720
3721        // A mapping item takes the name the row already shows it by, and an
3722        // item nothing can name has no key at all.
3723        assert_eq!(
3724            model.item_key(&[Seg::Key("steps".into())], 3).as_deref(),
3725            Some("delta")
3726        );
3727        assert!(model.item_key(&[Seg::Key("nope".into())], 0).is_none());
3728    }
3729
3730    #[test]
3731    fn a_backend_key_wins_over_the_inferred_one() {
3732        /// A backend that names an item by its link target, as one over a list
3733        /// of references would.
3734        struct WithKeys(FigBackend);
3735        impl Backend for WithKeys {
3736            fn apply(&mut self, op: EditOp) -> Result<(), crate::backend::BackendError> {
3737                self.0.apply(op)
3738            }
3739            fn to_value(&self) -> Result<Value, crate::backend::BackendError> {
3740                self.0.to_value()
3741            }
3742            fn source(&self) -> Result<String, crate::backend::BackendError> {
3743                self.0.source()
3744            }
3745            fn item_key(
3746                &self,
3747                seq_path: &[Seg],
3748                index: usize,
3749            ) -> Result<Option<String>, crate::backend::BackendError> {
3750                let mut path = seq_path.to_vec();
3751                path.push(Seg::Index(index));
3752                path.push(Seg::Key("id".into()));
3753                Ok(match tree::value_at(&self.to_value()?, &path) {
3754                    Some(Value::Str(s)) => Some(s.clone()),
3755                    _ => None,
3756                })
3757            }
3758        }
3759
3760        let src = r#"{"links": [{"id": "a", "name": "one"}, {"id": "b", "name": "one"}]}"#;
3761        let backend = WithKeys(FigBackend::open(src.as_bytes(), Format::Json).expect("open"));
3762        let mut model = Model::new(backend).expect("model");
3763        model.set_view(ViewMode::Pages);
3764        model.set_inline_budget(InlineBudget::new(0, 0));
3765        let links = vec![Seg::Key("links".into())];
3766
3767        // Both items would infer the *same* title; the backend tells them apart.
3768        assert_eq!(model.item_key(&links, 0).as_deref(), Some("a"));
3769        assert_eq!(model.item_key(&links, 1).as_deref(), Some("b"));
3770
3771        let mut second = links.clone();
3772        second.push(Seg::Index(1));
3773        model.focus_on(&second);
3774        model.page_enter();
3775        model.commit(
3776            EditOp::RemoveItem {
3777                seq_path: links,
3778                index: 0,
3779            },
3780            Vec::new(),
3781            "deleted",
3782        );
3783        assert_eq!(model.focus(), [Seg::Key("links".into()), Seg::Index(0)]);
3784        let mut id = model.focus().to_vec();
3785        id.push(Seg::Key("id".into()));
3786        assert_eq!(model.value_at(&id), Some(&Value::Str("b".into())));
3787    }
3788
3789    // ── the picker ────────────────────────────────────────────────────────
3790
3791    /// A backend that answers for a link field, the way a workspace-aware one
3792    /// would — the injection point `Backend::candidates` exists to be.
3793    struct WithCandidates(FigBackend);
3794
3795    impl Backend for WithCandidates {
3796        fn apply(&mut self, op: EditOp) -> Result<(), crate::backend::BackendError> {
3797            self.0.apply(op)
3798        }
3799        fn to_value(&self) -> Result<Value, crate::backend::BackendError> {
3800            self.0.to_value()
3801        }
3802        fn source(&self) -> Result<String, crate::backend::BackendError> {
3803            self.0.source()
3804        }
3805        fn candidates(
3806            &self,
3807            path: &[Seg],
3808        ) -> Result<Option<Vec<Choice>>, crate::backend::BackendError> {
3809            // Keyed on the relation, not on the index, so the append position
3810            // is answered by the same arm the third item is.
3811            Ok(match path.first() {
3812                Some(Seg::Key(k)) if k == "contents" => Some(vec![
3813                    Choice::plain("id:prov/1ch2991").detail("prov"),
3814                    Choice::plain("id:fig/9qk2s1z").detail("fig"),
3815                ]),
3816                _ => None,
3817            })
3818        }
3819    }
3820
3821    fn status_schema() -> Schema {
3822        use fig_schema::{PathPat, Term};
3823        Schema::new(vec![
3824            FieldRule::new(PathPat::key("status")).constraint(Constraint::Enum {
3825                values: vec![
3826                    Term::value("active").description("being worked on"),
3827                    Term::value("archived").retired(true),
3828                ],
3829                closed: true,
3830            }),
3831            FieldRule::new(PathPat::each_item_of("audience")).constraint(Constraint::Enum {
3832                values: vec![Term::value("public"), Term::value("private")],
3833                closed: true,
3834            }),
3835        ])
3836    }
3837
3838    #[test]
3839    fn an_enum_field_offers_its_terms_and_a_retired_one_says_so() {
3840        let backend = FigBackend::open(
3841            b"status = \"active\"\naudience = [\"public\"]\n",
3842            Format::Toml,
3843        )
3844        .expect("open");
3845        let mut model = Model::new(backend).expect("model");
3846        model.set_schema(status_schema());
3847
3848        let choices = model
3849            .choices_at(&[Seg::Key("status".into())])
3850            .expect("a vocabulary");
3851        assert_eq!(
3852            choices.iter().map(|c| c.label.as_str()).collect::<Vec<_>>(),
3853            ["active", "archived"]
3854        );
3855        assert_eq!(choices[0].detail.as_deref(), Some("being worked on"));
3856        // Retired, and still offered: a document already holding one has to be
3857        // able to re-choose it without retyping.
3858        assert_eq!(choices[1].detail.as_deref(), Some("retired"));
3859
3860        // An each-item rule answers for an item…
3861        let item = [Seg::Key("audience".into()), Seg::Index(0)];
3862        assert_eq!(model.choices_at(&item).map(|c| c.len()), Some(2));
3863        // …for the append position, which resolves to nothing…
3864        let append = [Seg::Key("audience".into()), Seg::Index(1)];
3865        assert_eq!(model.choices_at(&append).map(|c| c.len()), Some(2));
3866        // …and for the list itself, through the same placeholder.
3867        assert_eq!(
3868            model
3869                .choices_at(&[Seg::Key("audience".into())])
3870                .map(|c| c.len()),
3871            Some(2)
3872        );
3873        // A field nothing governs has nothing to offer.
3874        assert!(model.choices_at(&[Seg::Key("nope".into())]).is_none());
3875    }
3876
3877    #[test]
3878    fn the_picker_filters_commits_and_falls_back_to_free_text() {
3879        let backend = FigBackend::open(b"status = \"active\"\ntitle = \"a note\"\n", Format::Toml)
3880            .expect("open");
3881        let mut model = Model::new(backend).expect("model");
3882        model.set_schema(status_schema());
3883        model.focus_on(&[Seg::Key("status".into())]);
3884
3885        model.begin_choose();
3886        assert!(matches!(model.mode, Mode::Choosing { .. }));
3887        assert_eq!(model.visible_choices().len(), 2);
3888        assert_eq!(
3889            model.choice_selected().map(|c| c.label.as_str()),
3890            Some("active")
3891        );
3892        model.choose_next();
3893        assert_eq!(
3894            model.choice_selected().map(|c| c.label.as_str()),
3895            Some("archived")
3896        );
3897        model.choose_prev();
3898
3899        // Narrowing is a case-insensitive substring of the label, and puts the
3900        // cursor back on a row that is still there.
3901        for c in "ARCH".chars() {
3902            model.choose_push(c);
3903        }
3904        assert_eq!(model.visible_choices().len(), 1);
3905        assert_eq!(
3906            model.choice_selected().map(|c| c.label.as_str()),
3907            Some("archived")
3908        );
3909        model.choose_backspace();
3910        assert_eq!(model.visible_choices().len(), 1);
3911
3912        model.choose_commit();
3913        assert!(matches!(model.mode, Mode::Normal));
3914        assert!(model.source_snapshot().contains("status = \"archived\""));
3915        // A retired term is a member, so it applies with a warning rather than
3916        // being refused.
3917        assert!(model.status.contains("retired"), "{}", model.status);
3918
3919        // Cancelling writes nothing.
3920        let before = model.source_snapshot();
3921        model.begin_choose();
3922        model.choose_cancel();
3923        assert_eq!(model.source_snapshot(), before);
3924
3925        // And a field with no vocabulary falls through to the text field, so a
3926        // host binds one key for both.
3927        model.focus_on(&[Seg::Key("title".into())]);
3928        model.begin_choose();
3929        assert!(matches!(
3930            model.mode,
3931            Mode::Editing {
3932                slot: EditSlot::Value,
3933                ..
3934            }
3935        ));
3936    }
3937
3938    #[test]
3939    fn a_backend_answers_for_a_link_field_the_schema_cannot() {
3940        let backend = WithCandidates(
3941            FigBackend::open(b"contents = [\"id:prov/1ch2991\"]\n", Format::Toml).expect("open"),
3942        );
3943        let mut model = Model::new(backend).expect("model");
3944        let item = [Seg::Key("contents".into()), Seg::Index(0)];
3945
3946        let choices = model.choices_at(&item).expect("the workspace answered");
3947        assert_eq!(choices.len(), 2);
3948        assert_eq!(choices[1].detail.as_deref(), Some("fig"));
3949        // The append position is the same question with a different index.
3950        assert!(
3951            model
3952                .choices_at(&[Seg::Key("contents".into()), Seg::Index(1)])
3953                .is_some()
3954        );
3955        // A path the host does not answer for has no picker.
3956        assert!(model.choices_at(&[Seg::Key("title".into())]).is_none());
3957
3958        model.focus_on(&item);
3959        model.begin_choose();
3960        for c in "fig".chars() {
3961            model.choose_push(c);
3962        }
3963        model.choose_commit();
3964        assert!(model.source_snapshot().contains("id:fig/9qk2s1z"));
3965    }
3966
3967    #[test]
3968    fn a_filter_that_matches_nothing_commits_nothing() {
3969        let backend = FigBackend::open(b"status = \"active\"\n", Format::Toml).expect("open");
3970        let mut model = Model::new(backend).expect("model");
3971        model.set_schema(status_schema());
3972        model.focus_on(&[Seg::Key("status".into())]);
3973        model.begin_choose();
3974        for c in "zzz".chars() {
3975            model.choose_push(c);
3976        }
3977        assert!(model.visible_choices().is_empty());
3978        model.choose_commit();
3979        assert_eq!(model.status, "nothing matches");
3980        assert!(model.source_snapshot().contains("status = \"active\""));
3981        assert!(!model.dirty);
3982    }
3983
3984    // ── annotations ───────────────────────────────────────────────────────
3985
3986    #[test]
3987    fn a_finding_marks_the_row_it_names_and_survives_an_edit() {
3988        use crate::annotate::Severity;
3989        let mut model = sample_model();
3990        let port = vec![Seg::Key("server".into()), Seg::Key("port".into())];
3991        let tags = vec![Seg::Key("server".into()), Seg::Key("tags".into())];
3992        model.set_annotations(vec![
3993            Annotation::error(port.clone(), "already in use"),
3994            Annotation::warning(tags.clone(), "two of these are retired"),
3995        ]);
3996
3997        let page = model.page_at(&[Seg::Key("server".into())]);
3998        let at = |path: &[Seg]| {
3999            page.items
4000                .iter()
4001                .find(|i| i.path == path)
4002                .unwrap_or_else(|| panic!("no item for {path:?}"))
4003                .annotation
4004                .clone()
4005        };
4006        assert_eq!(at(&port).map(|a| a.severity), Some(Severity::Error));
4007        assert_eq!(
4008            at(&tags).map(|a| a.message),
4009            Some("two of these are retired".to_string())
4010        );
4011        // A finding on the list marks the list, and not each of its items.
4012        let mut first = tags.clone();
4013        first.push(Seg::Index(0));
4014        assert_eq!(at(&first), None);
4015        // …though a caller asking about the item is told what governs it.
4016        assert_eq!(
4017            model.annotation_at(&first).map(|a| a.severity),
4018            Some(Severity::Warning)
4019        );
4020
4021        // They are the host's state, not the document's: an edit re-attaches
4022        // them rather than clearing them.
4023        model.set_scalar_text(&port, "9090");
4024        let page = model.page_at(&[Seg::Key("server".into())]);
4025        assert!(
4026            page.items
4027                .iter()
4028                .any(|i| i.path == port && i.annotation.is_some())
4029        );
4030        assert_eq!(model.annotations().len(), 2);
4031
4032        // The tree projection is marked the same way.
4033        model.select_row(0);
4034        assert!(
4035            model
4036                .rows
4037                .iter()
4038                .any(|r| r.path == port && r.annotation.is_some())
4039        );
4040
4041        model.set_annotations(Vec::new());
4042        assert!(model.rows.iter().all(|r| r.annotation.is_none()));
4043    }
4044
4045    // ── undo and redo ─────────────────────────────────────────────────────
4046
4047    /// The sequence every round-trip test below drives: one op of each kind
4048    /// that does not delete a node, so the source is expected back byte for
4049    /// byte. Returns nothing — the assertions are the caller's.
4050    fn edit_everything(model: &mut Model<FigBackend>) {
4051        let server = |k: &str| vec![Seg::Key("server".into()), Seg::Key(k.into())];
4052        model.set_scalar_text(&server("host"), "example.com");
4053        model.set_value_at(&[Seg::Key("version".into())], Value::Int(2));
4054        model.append_item_text(&server("tags"), "gamma");
4055        model.insert_key_text(&server("limits"), "burst", "5");
4056        model.set_trailing_comment(&server("port"), Some("dev only"));
4057        model.set_leading_comment(&[Seg::Key("title".into())], Some("renamed"));
4058        let mut tags = server("tags");
4059        model.select_row(0);
4060        model.focus_on(&tags);
4061        tags.push(Seg::Index(0));
4062        model.focus_on(&tags);
4063        model.move_selected_down();
4064    }
4065
4066    #[test]
4067    fn undoing_every_edit_leaves_the_document_that_was_opened() {
4068        let mut model = sample_model();
4069        let opened = model.value.clone();
4070        edit_everything(&mut model);
4071        assert_ne!(model.value, opened, "the edits did something");
4072        assert_eq!(model.history_len(), 7);
4073
4074        while model.history_len() > 0 {
4075            model.undo();
4076        }
4077        assert_eq!(model.value, opened, "back to the value tree it opened with");
4078        // Nothing was deleted, so the bytes come back too — the comments, the
4079        // key order, and the blank lines included.
4080        assert_eq!(model.source_snapshot(), SAMPLE);
4081        // And undoing back to what was saved reads as clean, however deep the
4082        // journal got on the way.
4083        assert!(!model.dirty);
4084    }
4085
4086    /// A rename undoes by *value* and not always by bytes: fig's editor writes
4087    /// the key back through its own quoting rules, so a bare `enabled` renamed
4088    /// away and back can return as `"enabled"`. The task that asked for this
4089    /// journal says so — a byte-exact undo is a splice log in fig, not an
4090    /// inverse op here.
4091    #[test]
4092    fn a_rename_undoes_to_the_same_key_if_not_always_the_same_spelling() {
4093        let mut model = sample_model();
4094        let opened = model.value.clone();
4095        model.rename_key(&[Seg::Key("enabled".into())], "on");
4096        assert!(model.value_at(&[Seg::Key("on".into())]).is_some());
4097        model.undo();
4098        assert_eq!(model.value, opened);
4099        assert!(model.source_snapshot().contains("enabled"));
4100    }
4101
4102    #[test]
4103    fn redo_replays_to_the_bytes_the_edits_produced() {
4104        let mut model = sample_model();
4105        edit_everything(&mut model);
4106        let edited = model.source_snapshot();
4107        let depth = model.history_len();
4108
4109        for _ in 0..depth {
4110            model.undo();
4111        }
4112        assert_eq!(model.redo_len(), depth);
4113        for _ in 0..depth {
4114            model.redo();
4115        }
4116        assert_eq!(model.source_snapshot(), edited);
4117        assert_eq!(model.history_len(), depth);
4118        assert_eq!(model.redo_len(), 0);
4119    }
4120
4121    #[test]
4122    fn a_fresh_edit_clears_what_was_undone() {
4123        let mut model = sample_model();
4124        model.set_value_at(&[Seg::Key("version".into())], Value::Int(2));
4125        model.undo();
4126        assert_eq!(model.redo_len(), 1);
4127        model.set_value_at(&[Seg::Key("version".into())], Value::Int(3));
4128        assert_eq!(model.redo_len(), 0);
4129        model.redo();
4130        assert_eq!(model.status, "nothing to redo");
4131        assert_eq!(
4132            model.value_at(&[Seg::Key("version".into())]),
4133            Some(&Value::Int(3))
4134        );
4135    }
4136
4137    #[test]
4138    fn a_deleted_entry_comes_back_with_its_comment_and_its_position() {
4139        let mut model = sample_model();
4140        let opened = model.value.clone();
4141        // A mapping entry, whose leading comment is the document's own note on
4142        // it, and a sequence item, which comes back by index.
4143        model.select_row(0);
4144        model.focus_on(&[Seg::Key("title".into())]);
4145        model.delete_selected();
4146        let tags = vec![Seg::Key("server".into()), Seg::Key("tags".into())];
4147        let mut first = tags.clone();
4148        first.push(Seg::Index(0));
4149        model.focus_on(&first);
4150        model.delete_selected();
4151        assert_eq!(model.seq_len(&tags), 1);
4152
4153        model.undo();
4154        model.undo();
4155        assert_eq!(model.value, opened, "both nodes back, in their old places");
4156        assert_eq!(
4157            model
4158                .leading_comment_at(&[Seg::Key("title".into())])
4159                .as_deref(),
4160            Some("flower sample config — comments and formatting below should survive edits"),
4161        );
4162    }
4163
4164    #[test]
4165    fn a_derived_key_declines_the_undo_as_it_declined_the_edit() {
4166        let backend = FigBackend::open(SAMPLE.as_bytes(), Format::Toml).expect("open backend");
4167        let mut model = Model::with_managed(backend, Vec::new(), vec!["version".to_string()])
4168            .expect("build model");
4169        let before = model.source_snapshot();
4170
4171        model.set_value_at(&[Seg::Key("version".into())], Value::Int(2));
4172        assert!(model.status.starts_with("rejected:"), "{}", model.status);
4173        // Declined edits are not edits: there is nothing to undo, and undoing
4174        // does nothing.
4175        assert_eq!(model.history_len(), 0);
4176        assert_eq!(model.edit_seq(), 0);
4177        model.undo();
4178        assert_eq!(model.status, "nothing to undo");
4179        model.redo();
4180        assert_eq!(model.status, "nothing to redo");
4181        assert_eq!(model.source_snapshot(), before);
4182        assert!(!model.dirty);
4183    }
4184
4185    #[test]
4186    fn the_sequence_number_advances_on_every_change_in_either_direction() {
4187        let mut model = sample_model();
4188        assert_eq!(model.edit_seq(), 0);
4189        model.set_value_at(&[Seg::Key("version".into())], Value::Int(2));
4190        assert_eq!(model.edit_seq(), 1);
4191        model.undo();
4192        assert_eq!(model.edit_seq(), 2, "an undo is a change, not a rewind");
4193        model.redo();
4194        assert_eq!(model.edit_seq(), 3);
4195        // A refusal is not a change.
4196        model.undo();
4197        model.undo();
4198        assert_eq!(model.edit_seq(), 4);
4199    }
4200
4201    #[test]
4202    fn a_save_is_not_a_history_boundary() {
4203        let mut model = sample_model();
4204        model.set_value_at(&[Seg::Key("version".into())], Value::Int(2));
4205        model.mark_saved();
4206        assert!(!model.dirty);
4207        model.set_value_at(&[Seg::Key("version".into())], Value::Int(3));
4208        assert!(model.dirty);
4209
4210        // Undo runs back through the save — and past it, into edits made before
4211        // the document was written.
4212        model.undo();
4213        assert!(!model.dirty, "back at the saved bytes");
4214        model.undo();
4215        assert!(model.dirty, "and before them, which is a change again");
4216        assert_eq!(model.source_snapshot(), SAMPLE);
4217    }
4218
4219    #[test]
4220    fn undo_and_redo_say_whether_the_document_moved() {
4221        let mut model = Model::new(FigBackend::open(b"a = 1\n", Format::Toml).unwrap()).unwrap();
4222        assert!(!model.undo(), "nothing to undo yet");
4223        assert!(!model.redo(), "nothing to redo yet");
4224        model.set_value_at(&[Seg::Key("a".into())], Value::Int(2));
4225        assert!(model.undo(), "the edit was there to undo");
4226        assert!(!model.undo(), "and only once");
4227        assert!(model.redo(), "the undone edit was there to redo");
4228        assert!(!model.redo(), "and only once");
4229    }
4230
4231    #[test]
4232    fn undo_puts_the_cursor_back_where_the_edit_was_made() {
4233        let mut model = sample_model();
4234        let port = vec![Seg::Key("server".into()), Seg::Key("port".into())];
4235        model.focus_on(&port);
4236        model.set_scalar_text(&port, "9090");
4237        // Somewhere else entirely, the way a user would be by the time they
4238        // reach for undo.
4239        model.focus_on(&[Seg::Key("title".into())]);
4240        model.undo();
4241        assert_eq!(model.selected_path().as_deref(), Some(&port[..]));
4242    }
4243}