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}