Skip to main content

lattice_host/
buffer_registry.rs

1//! Unified buffer registry (DESIGN.md §5.9).
2//!
3//! Every concrete buffer the App can route input through -- a code
4//! [`Document`] today, a [`FileTreeBuffer`] tomorrow -- lives in
5//! [`BufferRegistry`] keyed by [`BufferId`]. A single registry
6//! gives `:bn` / `:bp` / `:ls` / `:bd` a consistent surface across
7//! buffer kinds; multiple file trees coexist with multiple
8//! documents under the same shape.
9//!
10//! ## Threading model (B'.1b)
11//!
12//! `BufferRegistry` uses interior mutability via
13//! `Arc<Mutex<BufferRegistryInner>>`. Every method takes `&self`
14//! and locks briefly. The registry is `Clone` (cheap atomic
15//! bump) so the App's `BufferStore` service impl can hold the
16//! same state as the App's `buffers` field — modes call into
17//! the shared store from any thread; the App accesses the same
18//! data through its direct field.
19//!
20//! Methods that previously returned `Option<&BufferEntry>` (and
21//! kind-specific equivalents like `document(id)`) are replaced
22//! with two flavours:
23//!
24//! - **Owned-return convenience methods** for common patterns
25//!   (`document_handle`, `name_of`, `kind_of`, `flags_of`,
26//!   `document_dirty`, `document_path`, `entry_summary`).
27//! - **Callback methods** (`with_entry`, `with_document`, etc.)
28//!   for one-off access. The callback runs while the lock is
29//!   held; callers MUST NOT re-enter the registry from inside
30//!   (it would deadlock on the same `Mutex`).
31//!
32//! Help buffers stay overlay-rendered for v1 (transient popup),
33//! so they're not in the registry yet -- moving them in is a
34//! follow-up that doesn't require structural change.
35//!
36//! Hot-path access: the *active* document's actor handle, syntax
37//! state, and last-parsed-version live on [`crate::app::App`]
38//! directly so motion / dispatch code stays unchanged. Switching
39//! the active document snapshots those fields back into the
40//! matching registry entry and loads from the destination's.
41//!
42//! [`Document`]: lattice_core::Document
43//! [`FileTreeBuffer`]: crate::file_tree::FileTreeBuffer
44
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex, MutexGuard};
47
48use crate::buffers::{BufferFlags, BufferId, BufferKind};
49use crate::help::HelpBuffer;
50use lattice_terminal::buffer::TerminalBuffer;
51
52/// Per-document registry payload. Each entry carries the actor
53/// handle plus per-document tree-sitter `Syntax` state, fold
54/// list, and any other "lives with this buffer until it
55/// closes" state.
56///
57/// **Active vs inactive split.** The currently-active buffer's
58/// `syntax` / `folds` slots are conventionally `None` / empty
59/// because the live state lives on `App.syntax` / `App.folds`
60/// for hot-path access. Switching buffers via
61/// `App::activate_document` snapshots the old buffer's live
62/// state into its entry, then loads the destination's state
63/// from its entry into the App's hot-path fields. The
64/// `App::activate_buffer_state` hook then refreshes anything
65/// that needs recomputing for the newly-active buffer (e.g.
66/// fold recompute when switching into a buffer for the first
67/// time).
68#[derive(Debug)]
69pub struct DocumentEntry {
70    pub id: BufferId,
71    /// M.0 (2026-05-31): typed as `Arc<dyn Document>` so the
72    /// registry can hold either a regular `RopeDocumentHandle`
73    /// (today) or a `MultibufferDocumentHandle` (M.1) without
74    /// kind-branching at retrieval. Consumers read through
75    /// the `Document` trait directly; concrete-type access
76    /// (e.g., `RopeDocumentHandle::replace`-equivalent operations
77    /// that don't apply to multibuffer) is gone — slot
78    /// replacement / membership APIs handle those cases.
79    pub handle: std::sync::Arc<dyn lattice_runtime::Document>,
80    // M.3.2.c.5: `syntax`, `last_parsed_text_version`,
81    // `last_synced_syntax_version`, and `folds` retired off the
82    // entry. They live in `App.buffer_locals[id]` as
83    // [`crate::modes::DocumentSyntax`] /
84    // [`crate::modes::DocumentLastParsedTextVersion`] /
85    // [`crate::modes::DocumentLastSyncedSyntaxVersion`] /
86    // [`crate::modes::DocumentFolds`]. Reads route through
87    // `App::document_syntax_for` and friends; writes go through
88    // `App::seed_document_entry_locals` /
89    // `App::seed_active_document_locals` at the activation
90    // boundary helpers in `app/lifecycle.rs`.
91}
92
93/// One slot in the registry. The kind-specific data lives in
94/// [`BufferData`]; flags + id + name apply uniformly.
95///
96/// `name` is the buffer's synthetic display label when there is
97/// no physical file backing it. For path-backed Documents it
98/// stays `None` and the status line / picker fall back to the
99/// path. For synthetic buffers like `*lsp*`, `*messages*`, or
100/// `*lsp:rust-analyzer:lattice*`, the owning subsystem sets the
101/// name at construction time; the status line + `:ls` + buffer
102/// picker all surface that label uniformly. Help buffers carry
103/// their own `title` field (used by help-mode link / anchor
104/// machinery) and don't use `name`.
105#[derive(Debug)]
106pub struct BufferEntry {
107    pub id: BufferId,
108    pub flags: BufferFlags,
109    pub data: BufferData,
110    pub name: Option<String>,
111}
112
113impl BufferEntry {
114    pub fn kind(&self) -> BufferKind {
115        match &self.data {
116            BufferData::Document(_) => BufferKind::Document,
117            BufferData::FileTree(_) => BufferKind::FileTree,
118            BufferData::Help(_) => BufferKind::Help,
119            BufferData::Oil(_) => BufferKind::Oil,
120            BufferData::Terminal(_) => BufferKind::Terminal,
121            BufferData::Messages(_) => BufferKind::Messages,
122            BufferData::Multibuffer(_) => BufferKind::Multibuffer,
123            BufferData::Dashboard(_) => BufferKind::Dashboard,
124        }
125    }
126
127    /// The Document storage for any document-shaped kind.
128    /// Returns `Some` for [`BufferData::Document`],
129    /// [`BufferData::Messages`], [`BufferData::Multibuffer`], and
130    /// (PU.1a) [`BufferData::Help`] — their storage is identical
131    /// (all carry `Arc<dyn Document>`); `None` for other kinds.
132    /// Code that needs to distinguish branches on [`Self::kind`].
133    pub fn document(&self) -> Option<&DocumentEntry> {
134        match &self.data {
135            BufferData::Document(d)
136            | BufferData::Messages(d)
137            | BufferData::Multibuffer(d)
138            | BufferData::Help(d)
139            | BufferData::FileTree(d)
140            | BufferData::Oil(d)
141            | BufferData::Dashboard(d) => Some(d),
142            _ => None,
143        }
144    }
145
146    pub fn document_mut(&mut self) -> Option<&mut DocumentEntry> {
147        match &mut self.data {
148            BufferData::Document(d)
149            | BufferData::Messages(d)
150            | BufferData::Multibuffer(d)
151            | BufferData::Help(d)
152            | BufferData::FileTree(d)
153            | BufferData::Oil(d)
154            | BufferData::Dashboard(d) => Some(d),
155            _ => None,
156        }
157    }
158
159    pub fn terminal(&self) -> Option<&TerminalBuffer> {
160        match &self.data {
161            BufferData::Terminal(t) => Some(t),
162            _ => None,
163        }
164    }
165
166    pub fn terminal_mut(&mut self) -> Option<&mut TerminalBuffer> {
167        match &mut self.data {
168            BufferData::Terminal(t) => Some(t),
169            _ => None,
170        }
171    }
172}
173
174/// DL.4/DL.5: which listing kind a new listing Document is filed
175/// under. Both are `DocumentEntry`-backed; only the discriminator
176/// differs, which is exactly the PU.1a shape.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum ListingKind {
179    FileTree,
180    Oil,
181}
182
183#[derive(Debug)]
184pub enum BufferData {
185    Document(DocumentEntry),
186    /// Hierarchical filesystem tree.
187    ///
188    /// DL.4: storage is now identical to [`BufferData::Document`] — an
189    /// actor-backed synthetic Document — exactly as `Help` converged at
190    /// PU.1a. The discriminator stays so `:ls`, mode lookup and
191    /// `BufferKind::is_read_only` can still tell a tree from a file.
192    ///
193    /// It was its own struct with its own rope, which is why
194    /// `document_handle()` returned `None` for it and the generic pane
195    /// path could not render it at all — the reason four bespoke paint
196    /// functions existed (CV.5, CV.6).
197    FileTree(DocumentEntry),
198    /// Help / log / picker-listing buffers placed into a pane
199    /// (DESIGN.md §5.9, §5.11). The transient overlay path
200    /// (`App.popup_buffer`) remains for popup-style displays
201    /// (hover, doc lookups, error toasts); persistent help views
202    /// (`:lsp-log`, `:lsp-server-log`, `:lsp-trace-log`,
203    /// `:describe-*`, `:diagnostics`) route here so they live in
204    /// a real pane, can be split, switched, listed via `:ls`,
205    /// and updated live when their backing source emits events.
206    ///
207    /// PU.1a (2026-06-27): storage is now identical to
208    /// [`BufferData::Document`] (a [`DocumentEntry`] carrying
209    /// `Arc<dyn Document>`) — help content is an actor-backed
210    /// synthetic Document, exactly like [`BufferData::Messages`].
211    /// The discriminator stays so help-mode behaviours (links,
212    /// anchors, dismiss-on-Esc, the markdown major) and `:ls` /
213    /// introspection can tell a help view apart from a file. The
214    /// help title lives in [`BufferEntry::name`]; per-buffer
215    /// metadata (links / anchors / highlights) lives in
216    /// `buffer_locals`.
217    Help(DocumentEntry),
218    /// Flat editable directory listing (oil.nvim-style).
219    ///
220    /// DL.5: storage is a `DocumentEntry` like every other kind — the
221    /// last one to converge. Its rope is an actor-backed Document, and
222    /// the directory snapshot `:w` diffs against lives in the
223    /// `OilSnapshotLocal` buffer-local. Oil is **writable**, so it
224    /// needs no owner-write bypass: edits go through the ordinary
225    /// document path.
226    Oil(DocumentEntry),
227    Terminal(TerminalBuffer),
228    /// The editor's `*messages*` audit transcript. Storage is
229    /// identical to [`BufferData::Document`] (same
230    /// [`DocumentEntry`]); the discriminator exists so `:ls`,
231    /// modeline, and introspection paths can tell the transcript
232    /// apart from a user-edited file. The subsystem owns content;
233    /// `messages-mode` contributes `ReadOnly` + `NoFile`.
234    Messages(DocumentEntry),
235    /// **H.1 (2026-05-31):** composed multibuffer view buffer
236    /// (`lattice-multibuffer`). Storage is identical to
237    /// [`BufferData::Document`] (the `DocumentEntry`'s
238    /// `handle: Arc<dyn Document>` is a
239    /// `MultibufferDocumentHandle` impl). The discriminator
240    /// exists so `:ls` / picker display / mode lookup can
241    /// distinguish a composed view from a regular Document.
242    /// `MultibufferMode` (M.2.b.2) is the major mode; H.3's
243    /// `Event::BufferOpened` dispatch activates it.
244    Multibuffer(DocumentEntry),
245    /// The `*dashboard*` launch page (DB.2). Storage is identical to
246    /// [`BufferData::Document`] (same [`DocumentEntry`]); the discriminator
247    /// exists so `:ls` / introspection tell the dashboard apart from a file,
248    /// and so the follow / dismiss gates group it with help-style buffers.
249    /// `dashboard-mode` is the major mode; it contributes `ReadOnly` +
250    /// `NoFile`. See `docs/dev/architecture/dashboard.md` §9.2.
251    Dashboard(DocumentEntry),
252}
253
254#[derive(Debug, Default)]
255struct BufferRegistryInner {
256    by_id: HashMap<BufferId, BufferEntry>,
257}
258
259/// The App's buffer registry. Methods take `&self` and lock
260/// internally; the registry is `Clone` so the App's
261/// `BufferStore` service impl can hold a clone for cross-thread
262/// access.
263///
264/// Perf plan B.4.b: carries an `AtomicU64` version counter
265/// alongside the inner. Every mutating method (`insert`, `remove`,
266/// `set_flags`, `set_name`, and every `with_*_mut` closure
267/// accessor) bumps it. `Versioned<T>`'s `DerefMut` discipline can't
268/// fire here because the registry uses interior mutability —
269/// callers take `&self` rather than `&mut self`, so autoref through
270/// `DerefMut` is impossible. The atomic is shared via the same
271/// `Arc<...>` clone as `inner` so every registry handle sees the
272/// same counter; `Clone` is one Arc bump for the pair.
273///
274/// `version()` is the read API. The `BuffersRenderState` /
275/// `TabsRenderState` caches on `Editor::publish_cache` compare
276/// against the prior captured value to decide cache reuse vs.
277/// rebuild.
278#[derive(Clone, Debug, Default)]
279pub struct BufferRegistry {
280    inner: Arc<Mutex<BufferRegistryInner>>,
281    /// Perf plan B.4.b: monotonic version. Bumped by every
282    /// mutating method on `BufferRegistry`. `AtomicU64` is
283    /// `Default` (=0) so this slot composes with the existing
284    /// `#[derive(Default)]` without an explicit `Default` impl.
285    version: Arc<std::sync::atomic::AtomicU64>,
286}
287
288impl BufferRegistry {
289    /// Perf plan B.4.b: monotonic mutation counter. Read by the
290    /// publish cache (see [`crate::render_state::PublishCache`])
291    /// to decide whether the cached `buffers` / `tabs` Arcs can be
292    /// reused across publishes. `Relaxed` ordering is fine because
293    /// the publish path runs on the actor thread; we only need the
294    /// counter to advance after a mutation, not to synchronise
295    /// memory order with other writers.
296    pub fn version(&self) -> u64 {
297        self.version.load(std::sync::atomic::Ordering::Relaxed)
298    }
299
300    #[inline]
301    fn bump_version(&self) {
302        self.version
303            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
304    }
305}
306
307fn lock_inner(inner: &Arc<Mutex<BufferRegistryInner>>) -> MutexGuard<'_, BufferRegistryInner> {
308    inner.lock().expect("BufferRegistry mutex poisoned")
309}
310
311impl BufferRegistry {
312    pub fn new() -> Self {
313        Self::default()
314    }
315
316    // ---- Mutation ----------------------------------------
317
318    pub fn insert(&self, entry: BufferEntry) {
319        lock_inner(&self.inner).by_id.insert(entry.id, entry);
320        // Perf plan B.4.b: bump after the mutation so the publish
321        // cache sees the new state on its next sub-state check.
322        self.bump_version();
323    }
324
325    pub fn remove(&self, id: BufferId) -> Option<BufferEntry> {
326        let removed = lock_inner(&self.inner).by_id.remove(&id);
327        // Only bump on an actual removal so a no-op `remove` of a
328        // non-existent id doesn't invalidate the cache.
329        if removed.is_some() {
330            self.bump_version();
331        }
332        removed
333    }
334
335    // ---- Owned-return reads ------------------------------
336
337    pub fn contains(&self, id: BufferId) -> bool {
338        lock_inner(&self.inner).by_id.contains_key(&id)
339    }
340
341    pub fn len(&self) -> usize {
342        lock_inner(&self.inner).by_id.len()
343    }
344
345    pub fn is_empty(&self) -> bool {
346        lock_inner(&self.inner).by_id.is_empty()
347    }
348
349    /// Kind of the entry at `id`, or `None` if absent.
350    pub fn kind_of(&self, id: BufferId) -> Option<BufferKind> {
351        lock_inner(&self.inner).by_id.get(&id).map(|e| e.kind())
352    }
353
354    /// Synthetic name of the entry at `id`, or `None` if absent
355    /// or if the entry has no name set.
356    pub fn name_of(&self, id: BufferId) -> Option<String> {
357        lock_inner(&self.inner)
358            .by_id
359            .get(&id)
360            .and_then(|e| e.name.clone())
361    }
362
363    /// Per-buffer flags. `BufferFlags` is `Copy` so this returns
364    /// owned without locking issues.
365    pub fn flags_of(&self, id: BufferId) -> Option<BufferFlags> {
366        lock_inner(&self.inner).by_id.get(&id).map(|e| e.flags)
367    }
368
369    /// Mutate the per-buffer flags via callback (e.g. flip
370    /// `listed` on `:setlocal nobuflisted` once that lands).
371    pub fn set_flags(&self, id: BufferId, flags: BufferFlags) -> bool {
372        let mut inner = lock_inner(&self.inner);
373        let updated = match inner.by_id.get_mut(&id) {
374            Some(e) => {
375                e.flags = flags;
376                true
377            }
378            None => false,
379        };
380        // Drop the lock before bumping the atomic — the version is
381        // a separate Arc and doesn't need the inner lock.
382        drop(inner);
383        if updated {
384            self.bump_version();
385        }
386        updated
387    }
388
389    /// Rename the entry's synthetic `name` slot. Used by the
390    /// supervisor when an actor exits and the per-instance buffer
391    /// gets renamed `*lsp:rust:/path*` → `*lsp:rust:/path (exited)*`.
392    pub fn set_name(&self, id: BufferId, name: Option<String>) -> bool {
393        let mut inner = lock_inner(&self.inner);
394        let updated = match inner.by_id.get_mut(&id) {
395            Some(e) => {
396                e.name = name;
397                true
398            }
399            None => false,
400        };
401        drop(inner);
402        if updated {
403            self.bump_version();
404        }
405        updated
406    }
407
408    /// Kind-specific convenience: clone the `Arc<dyn Document>`
409    /// for `id`. The handle is `Send + Sync` and can be held
410    /// across thread boundaries. M.0: returns the polymorphic
411    /// shape (was `RopeDocumentHandle` pre-M.0) so the registry
412    /// serves multibuffer handles (M.1) through the same path.
413    pub fn document_handle(
414        &self,
415        id: BufferId,
416    ) -> Option<std::sync::Arc<dyn lattice_runtime::Document>> {
417        lock_inner(&self.inner)
418            .by_id
419            .get(&id)
420            .and_then(|e| e.document().map(|d| d.handle.clone()))
421    }
422
423    /// D.3.a.1 (2026-05-29): reverse lookup —
424    /// `DocumentId` → `BufferId`. Used by the
425    /// diff subsystem's `DocumentBufferResolver` impl: bus
426    /// events carry `DocumentId`, host-side state is keyed by
427    /// `BufferId`. Scans the registry's document entries —
428    /// O(N_documents) per call, acceptable at v1 buffer counts
429    /// (~tens; the LSP fan-in and the keymap registry do
430    /// similar walks). Future inverse index lives behind the
431    /// same method signature.
432    pub fn buffer_id_for_document(
433        &self,
434        document_id: lattice_protocol::ids::DocumentId,
435    ) -> Option<BufferId> {
436        let inner = lock_inner(&self.inner);
437        for (buffer_id, entry) in inner.by_id.iter() {
438            if let Some(d) = entry.document()
439                && d.handle.id() == document_id
440            {
441                return Some(*buffer_id);
442            }
443        }
444        None
445    }
446
447    /// Kind-specific convenience: path of the document at `id`.
448    pub fn document_path(&self, id: BufferId) -> Option<std::path::PathBuf> {
449        lock_inner(&self.inner)
450            .by_id
451            .get(&id)
452            .and_then(|e| e.document().and_then(|d| d.handle.path()))
453    }
454
455    /// Kind-specific convenience: dirty flag for the document
456    /// at `id`. Returns `false` for absent / non-document.
457    pub fn document_dirty(&self, id: BufferId) -> bool {
458        lock_inner(&self.inner)
459            .by_id
460            .get(&id)
461            .and_then(|e| e.document().map(|d| d.handle.dirty()))
462            .unwrap_or(false)
463    }
464
465    pub fn contains_document(&self, id: BufferId) -> bool {
466        // H.1 / M.6.X follow-up (2026-06-01): Multibuffer
467        // entries are document-backed (`document()` at line 137
468        // already returns `Some` for them), so the activation
469        // pipeline that gates on this predicate must accept
470        // them. Without this, `:b N` on a multibuffer view
471        // bounces with "buffer #N not a document" even though
472        // the registry can hand back a usable document handle.
473        // Aligned with `feedback_buffers_no_special_case` —
474        // Multibuffer is uniform with Document/Messages at the
475        // activation seam.
476        lock_inner(&self.inner)
477            .by_id
478            .get(&id)
479            // DL.4: DERIVED from `document()` rather than re-listing the
480            // variants. The hand-maintained list had already drifted —
481            // it predated `Multibuffer` being added to `document()` and
482            // then silently excluded `FileTree` when that converged, so
483            // `activate_document` refused a buffer whose handle the
484            // registry would happily hand out. A tree opened, painted
485            // the buffer behind it, and put its caret on row 0.
486            //
487            // Help is the one deliberate exception: it is
488            // document-backed (PU.1a) but activates through the popup /
489            // in-pane help path, not this one. Named here rather than
490            // omitted, so the exclusion is a decision and not a gap.
491            .map(|e| e.document().is_some() && !matches!(e.data, BufferData::Help(_)))
492            .unwrap_or(false)
493    }
494
495    /// True iff the entry at `id` is the `*messages*` transcript.
496    /// Symmetric with [`Self::contains_help`] /
497    /// [`Self::contains_oil`] / [`Self::contains_file_tree`] —
498    /// callers that need to differentiate the Messages identity
499    /// from a regular Document branch on this.
500    pub fn contains_messages(&self, id: BufferId) -> bool {
501        lock_inner(&self.inner)
502            .by_id
503            .get(&id)
504            .map(|e| matches!(e.data, BufferData::Messages(_)))
505            .unwrap_or(false)
506    }
507
508    pub fn contains_file_tree(&self, id: BufferId) -> bool {
509        lock_inner(&self.inner)
510            .by_id
511            .get(&id)
512            .map(|e| matches!(e.data, BufferData::FileTree(_)))
513            .unwrap_or(false)
514    }
515
516    pub fn contains_help(&self, id: BufferId) -> bool {
517        lock_inner(&self.inner)
518            .by_id
519            .get(&id)
520            .map(|e| matches!(e.data, BufferData::Help(_)))
521            .unwrap_or(false)
522    }
523
524    pub fn contains_oil(&self, id: BufferId) -> bool {
525        lock_inner(&self.inner)
526            .by_id
527            .get(&id)
528            .map(|e| matches!(e.data, BufferData::Oil(_)))
529            .unwrap_or(false)
530    }
531
532    /// Compact snapshot of an entry: `(id, kind, flags, name,
533    /// is_document_path_set)`. Used by `:ls` and the buffer picker
534    /// to render rows without holding the lock through complex
535    /// display logic. Returns `None` if the entry is absent.
536    pub fn entry_summary(
537        &self,
538        id: BufferId,
539    ) -> Option<(BufferId, BufferKind, BufferFlags, Option<String>)> {
540        let inner = lock_inner(&self.inner);
541        inner
542            .by_id
543            .get(&id)
544            .map(|e| (e.id, e.kind(), e.flags, e.name.clone()))
545    }
546
547    /// All ids in ascending order. Used by `:bn` / `:bp` for
548    /// deterministic cycling order independent of HashMap
549    /// hash-randomization.
550    pub fn sorted_ids(&self) -> Vec<BufferId> {
551        let inner = lock_inner(&self.inner);
552        let mut ids: Vec<BufferId> = inner.by_id.keys().copied().collect();
553        ids.sort();
554        ids
555    }
556
557    /// All listed ids in ascending order. `:bn` / `:bp` skip
558    /// unlisted buffers (vim semantics); `:ls` shows them under a
559    /// separate header (post-v1 polish).
560    pub fn listed_ids_sorted(&self) -> Vec<BufferId> {
561        let inner = lock_inner(&self.inner);
562        // PU.5: ephemeral popup-backing buffers are never listed (they set
563        // `listed: false`, but gate on `ephemeral` too so the invariant
564        // holds even if a future caller sets `listed: true` by mistake).
565        let mut ids: Vec<BufferId> = inner
566            .by_id
567            .iter()
568            .filter(|(_, e)| e.flags.listed && !e.flags.ephemeral)
569            .map(|(id, _)| *id)
570            .collect();
571        ids.sort();
572        ids
573    }
574
575    /// Document buffers only, sorted by id. The `*messages*`
576    /// transcript stores as [`BufferData::Messages`] (see
577    /// [`Self::messages_ids_sorted`]) so it is **excluded** from
578    /// this list even though storage is identical; callers that
579    /// want every rope-backed-doc kind should walk both.
580    pub fn document_ids_sorted(&self) -> Vec<BufferId> {
581        let inner = lock_inner(&self.inner);
582        let mut ids: Vec<BufferId> = inner
583            .by_id
584            .iter()
585            .filter(|(_, e)| matches!(e.data, BufferData::Document(_)))
586            .map(|(id, _)| *id)
587            .collect();
588        ids.sort();
589        ids
590    }
591
592    /// `*messages*` (and any future Messages-kind) buffers,
593    /// sorted by id. Symmetric with [`Self::document_ids_sorted`].
594    pub fn messages_ids_sorted(&self) -> Vec<BufferId> {
595        let inner = lock_inner(&self.inner);
596        let mut ids: Vec<BufferId> = inner
597            .by_id
598            .iter()
599            .filter(|(_, e)| matches!(e.data, BufferData::Messages(_)))
600            .map(|(id, _)| *id)
601            .collect();
602        ids.sort();
603        ids
604    }
605
606    /// K.4.8 (2026-06-02): Multibuffer-kind buffers, sorted by id.
607    /// Symmetric with [`Self::messages_ids_sorted`] /
608    /// [`Self::document_ids_sorted`]. Used by `:ls` to count the
609    /// Multibuffer entries separately from Messages now that
610    /// Multibuffer has its own listing row.
611    pub fn multibuffer_ids_sorted(&self) -> Vec<BufferId> {
612        let inner = lock_inner(&self.inner);
613        let mut ids: Vec<BufferId> = inner
614            .by_id
615            .iter()
616            .filter(|(_, e)| matches!(e.data, BufferData::Multibuffer(_)))
617            .map(|(id, _)| *id)
618            .collect();
619        ids.sort();
620        ids
621    }
622
623    /// File-tree buffers only, sorted by id.
624    pub fn file_tree_ids_sorted(&self) -> Vec<BufferId> {
625        let inner = lock_inner(&self.inner);
626        let mut ids: Vec<BufferId> = inner
627            .by_id
628            .iter()
629            .filter(|(_, e)| matches!(e.data, BufferData::FileTree(_)))
630            .map(|(id, _)| *id)
631            .collect();
632        ids.sort();
633        ids
634    }
635
636    /// Help buffers only, sorted by id.
637    pub fn help_ids_sorted(&self) -> Vec<BufferId> {
638        let inner = lock_inner(&self.inner);
639        let mut ids: Vec<BufferId> = inner
640            .by_id
641            .iter()
642            .filter(|(_, e)| matches!(e.data, BufferData::Help(_)))
643            .map(|(id, _)| *id)
644            .collect();
645        ids.sort();
646        ids
647    }
648
649    pub fn oil_ids_sorted(&self) -> Vec<BufferId> {
650        let inner = lock_inner(&self.inner);
651        let mut ids: Vec<BufferId> = inner
652            .by_id
653            .iter()
654            .filter(|(_, e)| matches!(e.data, BufferData::Oil(_)))
655            .map(|(id, _)| *id)
656            .collect();
657        ids.sort();
658        ids
659    }
660
661    /// IDs of every registered file-tree buffer, in arbitrary
662    /// order. The App-side `file_tree_with_root` walks these +
663    /// probes each one's `FileTreeRoot` buffer-local for the
664    /// dedup lookup; the registry can't do that walk because it
665    /// doesn't own buffer-locals.
666    pub fn file_tree_ids(&self) -> Vec<BufferId> {
667        lock_inner(&self.inner)
668            .by_id
669            .values()
670            .filter_map(|entry| match &entry.data {
671                BufferData::FileTree(_) => Some(entry.id),
672                _ => None,
673            })
674            .collect()
675    }
676
677    /// IDs of every registered oil buffer, in arbitrary order.
678    pub fn oil_ids(&self) -> Vec<BufferId> {
679        lock_inner(&self.inner)
680            .by_id
681            .values()
682            .filter_map(|entry| match &entry.data {
683                BufferData::Oil(_) => Some(entry.id),
684                _ => None,
685            })
686            .collect()
687    }
688
689    /// First buffer whose `name` matches exactly. Used by
690    /// subsystem-owned synthetic buffers (`*lsp*`, `*messages*`,
691    /// per-instance LSP log buffers) so re-running the
692    /// owner's create-or-activate path surfaces the existing
693    /// entry rather than allocating a duplicate.
694    pub fn by_name(&self, name: &str) -> Option<BufferId> {
695        let inner = lock_inner(&self.inner);
696        for entry in inner.by_id.values() {
697            if entry.name.as_deref() == Some(name) {
698                return Some(entry.id);
699            }
700        }
701        None
702    }
703
704    /// First help buffer with the given title, if any. Used by the
705    /// `:lsp-log` / `:lsp-trace-log` openers so re-running the
706    /// command surfaces the existing buffer rather than allocating
707    /// a duplicate.
708    pub fn help_with_title(&self, title: &str) -> Option<BufferId> {
709        let inner = lock_inner(&self.inner);
710        for entry in inner.by_id.values() {
711            // PU.1a: the help title now lives in `entry.name` (the
712            // synthetic-Document name slot), not on the storage.
713            if matches!(entry.data, BufferData::Help(_)) && entry.name.as_deref() == Some(title) {
714                return Some(entry.id);
715            }
716        }
717        None
718    }
719
720    /// First document buffer with the given path, if any. Used by
721    /// `:e FILE` to detect "already open".
722    pub fn document_with_path(&self, path: &std::path::Path) -> Option<BufferId> {
723        let inner = lock_inner(&self.inner);
724        for entry in inner.by_id.values() {
725            if let BufferData::Document(d) = &entry.data
726                && d.handle.path() == Some(path.to_path_buf())
727            {
728                return Some(entry.id);
729            }
730        }
731        None
732    }
733
734    // ---- Callback access ---------------------------------
735    //
736    // Each `with_*` method locks, looks up the entry, runs the
737    // callback while the lock is held, releases. Callers MUST
738    // NOT re-enter the registry from inside the callback (it
739    // would deadlock on the same `Mutex`). Use the owned-return
740    // helpers above to extract data when re-entry would
741    // otherwise be needed.
742
743    /// Run `f` against the `BufferEntry` at `id` while holding
744    /// the registry lock. Returns `None` if the entry is absent.
745    pub fn with_entry<R>(&self, id: BufferId, f: impl FnOnce(&BufferEntry) -> R) -> Option<R> {
746        let inner = lock_inner(&self.inner);
747        inner.by_id.get(&id).map(f)
748    }
749
750    /// Mutable variant of [`Self::with_entry`].
751    ///
752    /// Perf plan B.4.b: conservatively bumps the version after the
753    /// closure runs, even if the closure didn't actually mutate.
754    /// Over-bumping causes a one-time cache miss on the next
755    /// publish — safe and bounded — versus the alternative of
756    /// missing a real mutation, which would leave stale Arcs
757    /// visible to renderers indefinitely.
758    pub fn with_entry_mut<R>(
759        &self,
760        id: BufferId,
761        f: impl FnOnce(&mut BufferEntry) -> R,
762    ) -> Option<R> {
763        let result = {
764            let mut inner = lock_inner(&self.inner);
765            inner.by_id.get_mut(&id).map(f)
766        };
767        if result.is_some() {
768            self.bump_version();
769        }
770        result
771    }
772
773    pub fn with_document<R>(&self, id: BufferId, f: impl FnOnce(&DocumentEntry) -> R) -> Option<R> {
774        let inner = lock_inner(&self.inner);
775        inner.by_id.get(&id).and_then(|e| e.document()).map(f)
776    }
777
778    pub fn with_document_mut<R>(
779        &self,
780        id: BufferId,
781        f: impl FnOnce(&mut DocumentEntry) -> R,
782    ) -> Option<R> {
783        // Perf plan B.4.b: document-entry mutations don't affect
784        // the published `BuffersRenderState` fields directly (kind /
785        // name / flags). They do invalidate the registry-derived
786        // tabs label only if a name changes — which goes through
787        // the dedicated `set_name`, not through `with_document_mut`.
788        // We still bump because plugin code could conceivably edit
789        // the wrong field through a closure; conservatism is cheaper
790        // than a hard-to-find staleness bug.
791        let result = {
792            let mut inner = lock_inner(&self.inner);
793            inner
794                .by_id
795                .get_mut(&id)
796                .and_then(|e| e.document_mut())
797                .map(f)
798        };
799        if result.is_some() {
800            self.bump_version();
801        }
802        result
803    }
804
805    /// PU.1a: reconstruct a transient [`HelpBuffer`] *view* from the
806    /// registry Document at `id`. Help content is now an actor-backed
807    /// synthetic Document; this builds the slim view value the bespoke
808    /// help renderers still consume (PU.1b deletes both). The view's
809    /// `scroll` / `cursor` default to 0 — callers overlay the live
810    /// popup (`popup_scroll`/`popup_cursor`) or pane scroll+cursor.
811    /// `None` when `id` is not a help buffer.
812    pub fn help_content_view(&self, id: BufferId) -> Option<HelpBuffer> {
813        let inner = lock_inner(&self.inner);
814        let entry = inner.by_id.get(&id)?;
815        if !matches!(entry.data, BufferData::Help(_)) {
816            return None;
817        }
818        let snap = entry.document()?.handle.snapshot();
819        Some(HelpBuffer {
820            id,
821            title: entry.name.clone().unwrap_or_default(),
822            content: snap.buffer.clone(),
823            scroll: 0,
824            cursor: lattice_protocol::position::Position::ZERO,
825        })
826    }
827
828    pub fn with_terminal<R>(
829        &self,
830        id: BufferId,
831        f: impl FnOnce(&TerminalBuffer) -> R,
832    ) -> Option<R> {
833        let inner = lock_inner(&self.inner);
834        inner.by_id.get(&id).and_then(|e| e.terminal()).map(f)
835    }
836
837    pub fn with_terminal_mut<R>(
838        &self,
839        id: BufferId,
840        f: impl FnOnce(&mut TerminalBuffer) -> R,
841    ) -> Option<R> {
842        let result = {
843            let mut inner = lock_inner(&self.inner);
844            inner
845                .by_id
846                .get_mut(&id)
847                .and_then(|e| e.terminal_mut())
848                .map(f)
849        };
850        if result.is_some() {
851            self.bump_version();
852        }
853        result
854    }
855
856    /// Run `f` against every entry under the registry lock.
857    /// Callers must not re-enter the registry from inside.
858    pub fn for_each<F: FnMut(&BufferEntry)>(&self, mut f: F) {
859        let inner = lock_inner(&self.inner);
860        for entry in inner.by_id.values() {
861            f(entry);
862        }
863    }
864}
865
866// ---------------------------------------------------------------
867// BufferStore impl (B'.3)
868// ---------------------------------------------------------------
869//
870// Wraps a clone of `BufferRegistry` so modes can find synthetic
871// buffers by name and pull `RopeDocumentHandle`s from any tokio task.
872// Registered into the App's `ServiceRegistry` at boot; modes pull
873// it via `ctx.service::<lattice_mode::BufferStoreHandle>()`.
874//
875// This handle is read/find + generic document insertion only — buffer
876// *creation* (find-or-create + activate a major) is NOT here, because
877// activating a mode needs `&mut Editor`. Modes provision their own
878// buffers through `ModeActivator::ensure_named_document` (the
879// `&mut`-backed create seam), whose `Editor` impl calls
880// `ensure_named_synthetic_document`.
881
882impl lattice_mode::BufferStore for BufferRegistry {
883    fn find_by_name(&self, name: &str) -> Option<lattice_core::BufferId> {
884        self.by_name(name)
885    }
886
887    fn handle_for(
888        &self,
889        id: lattice_core::BufferId,
890    ) -> Option<std::sync::Arc<dyn lattice_runtime::Document>> {
891        self.document_handle(id)
892    }
893
894    fn name_for(&self, id: lattice_core::BufferId) -> Option<String> {
895        self.name_of(id)
896    }
897
898    /// Answered exactly rather than through the trait's best-effort default:
899    /// this registry holds the map, so it can see a registered buffer that has
900    /// no name, no path and no document handle — which the default reports as
901    /// absent.
902    fn contains_buffer(&self, id: lattice_core::BufferId) -> bool {
903        self.contains(id)
904    }
905
906    fn path_for(&self, id: lattice_core::BufferId) -> Option<std::path::PathBuf> {
907        self.document_path(id)
908    }
909
910    /// H.1 (2026-05-31): generic Document-shaped buffer
911    /// insertion for extension crates (`lattice-multibuffer`
912    /// today; future plugin-defined Document-shaped kinds).
913    /// Maps `kind` to the appropriate `BufferData` variant;
914    /// other kinds (`FileTree`, `Oil`, `Terminal`, `Help`)
915    /// log + skip — their payload is not a Document.
916    fn insert_document_buffer(
917        &self,
918        id: lattice_core::BufferId,
919        kind: lattice_core::BufferKind,
920        handle: std::sync::Arc<dyn lattice_runtime::Document>,
921        flags: lattice_core::BufferFlags,
922        name: Option<String>,
923    ) {
924        // Idempotent: if already registered, return.
925        if self.contains(id) {
926            return;
927        }
928        let entry = DocumentEntry { id, handle };
929        let data = match kind {
930            lattice_core::BufferKind::Document => BufferData::Document(entry),
931            lattice_core::BufferKind::Messages => BufferData::Messages(entry),
932            lattice_core::BufferKind::Multibuffer => BufferData::Multibuffer(entry),
933            other => {
934                // Non-Document-shaped kinds (FileTree, Oil,
935                // Terminal, Help) carry kind-specific payloads;
936                // they're not reachable through this generic
937                // surface. Caller is using the wrong API.
938                tracing::warn!(
939                    target: "lattice_host::buffer_registry",
940                    ?kind,
941                    ?id,
942                    "BufferStore::insert_document_buffer called for non-Document kind {:?}; ignoring",
943                    other,
944                );
945                return;
946            }
947        };
948        self.insert(BufferEntry {
949            id,
950            flags,
951            data,
952            name,
953        });
954    }
955}
956
957// ---------------------------------------------------------------
958// TerminalStore impl (T-mode-1)
959// ---------------------------------------------------------------
960//
961// Wraps `BufferRegistry` so `TerminalNormalMode`'s on-activate /
962// Guard-Drop can install / clear the SyntheticDoc on a
963// `TerminalBuffer` from inside the mode lifecycle. Registered at
964// boot via `editor_boot.rs` and pulled by the mode via
965// `ctx.service::<lattice_terminal::TerminalStoreHandle>()`.
966
967impl lattice_terminal::TerminalStore for BufferRegistry {
968    fn install_synthetic(&self, id: BufferId) -> bool {
969        self.with_terminal_mut(id, |t| {
970            let doc = t.term.build_normal_snapshot();
971            t.synthetic = Some(std::sync::Arc::new(doc));
972        })
973        .is_some()
974    }
975
976    fn clear_synthetic(&self, id: BufferId) -> bool {
977        self.with_terminal_mut(id, |t| {
978            t.synthetic = None;
979        })
980        .is_some()
981    }
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987
988    fn ft_entry(id: BufferId, listed: bool, name: Option<String>) -> BufferEntry {
989        BufferEntry {
990            id,
991            flags: BufferFlags {
992                listed,
993                hidden: false,
994                ephemeral: false,
995            },
996            // DL.4: a file tree is a `DocumentEntry` now, like Help.
997            data: BufferData::FileTree(DocumentEntry {
998                id,
999                handle: std::sync::Arc::new(lattice_runtime::spawn_document(
1000                    id,
1001                    lattice_core::Document::empty(),
1002                    std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
1003                        lattice_grammar::registry::CommandRegistry::default(),
1004                    )),
1005                )),
1006            }),
1007            name,
1008        }
1009    }
1010
1011    #[test]
1012    fn fresh_registry_is_empty() {
1013        let r = BufferRegistry::new();
1014        assert!(r.is_empty());
1015        assert_eq!(r.len(), 0);
1016    }
1017
1018    #[test]
1019    fn sorted_ids_returns_ascending_order() {
1020        let r = BufferRegistry::new();
1021        let id_a = BufferId::next();
1022        let id_b = BufferId::next();
1023        let id_c = BufferId::next();
1024        r.insert(ft_entry(id_c, true, None));
1025        r.insert(ft_entry(id_a, true, None));
1026        r.insert(ft_entry(id_b, true, None));
1027        assert_eq!(r.sorted_ids(), vec![id_a, id_b, id_c]);
1028    }
1029
1030    #[test]
1031    fn unlisted_buffers_skip_listed_ids() {
1032        let r = BufferRegistry::new();
1033        let id_a = BufferId::next();
1034        let id_b = BufferId::next();
1035        r.insert(ft_entry(id_a, true, None));
1036        r.insert(ft_entry(id_b, false, None));
1037        assert_eq!(r.listed_ids_sorted(), vec![id_a]);
1038        assert_eq!(r.sorted_ids(), vec![id_a, id_b]);
1039    }
1040
1041    #[test]
1042    fn ephemeral_buffers_excluded_from_listed_ids() {
1043        // PU.5: an ephemeral popup-backing buffer never appears in the
1044        // `:bn` / `:bp` walk (`listed_ids_sorted`), even though it remains
1045        // in the full registry (`sorted_ids`) until its owning popup GCs it.
1046        let r = BufferRegistry::new();
1047        let id_a = BufferId::next();
1048        let id_eph = BufferId::next();
1049        r.insert(ft_entry(id_a, true, None));
1050        let mut eph = ft_entry(id_eph, false, None);
1051        eph.flags.ephemeral = true;
1052        r.insert(eph);
1053        assert_eq!(r.listed_ids_sorted(), vec![id_a]);
1054        assert_eq!(r.sorted_ids(), vec![id_a, id_eph]);
1055    }
1056
1057    #[test]
1058    fn by_name_finds_entry_with_matching_synthetic_name() {
1059        let r = BufferRegistry::new();
1060        let id_lsp = BufferId::next();
1061        let id_other = BufferId::next();
1062        r.insert(ft_entry(id_lsp, true, Some("*lsp*".to_string())));
1063        r.insert(ft_entry(id_other, true, None));
1064        assert_eq!(r.by_name("*lsp*"), Some(id_lsp));
1065        assert_eq!(r.by_name("nope"), None);
1066    }
1067
1068    #[test]
1069    fn file_tree_ids_lists_registered_trees() {
1070        let r = BufferRegistry::new();
1071        let id = BufferId::next();
1072        r.insert(ft_entry(id, true, None));
1073        assert_eq!(r.file_tree_ids(), vec![id]);
1074    }
1075
1076    #[test]
1077    fn clone_shares_state() {
1078        let r1 = BufferRegistry::new();
1079        let r2 = r1.clone();
1080        let id = BufferId::next();
1081        r1.insert(ft_entry(id, true, None));
1082        assert!(r2.contains(id));
1083        assert_eq!(r2.len(), 1);
1084    }
1085
1086    #[test]
1087    fn with_entry_runs_callback_under_lock() {
1088        let r = BufferRegistry::new();
1089        let id = BufferId::next();
1090        r.insert(ft_entry(id, true, Some("name".to_string())));
1091        let kind = r.with_entry(id, |e| e.kind());
1092        assert_eq!(kind, Some(BufferKind::FileTree));
1093    }
1094
1095    #[test]
1096    fn set_name_updates_entry() {
1097        let r = BufferRegistry::new();
1098        let id = BufferId::next();
1099        r.insert(ft_entry(id, true, Some("old".to_string())));
1100        assert!(r.set_name(id, Some("new".to_string())));
1101        assert_eq!(r.name_of(id), Some("new".to_string()));
1102        assert_eq!(r.by_name("new"), Some(id));
1103        assert_eq!(r.by_name("old"), None);
1104    }
1105
1106    // ─────────────────────────────────────────────────────────────
1107    // M.1.b (2026-05-31): `MultibufferDocumentHandle` registers
1108    // through the same `BufferData::Document` slot a regular
1109    // `RopeDocumentHandle` uses. The slot's handle field is
1110    // `Arc<dyn Document>` (M.0 Phase D); multibuffer wraps in via
1111    // the same Arc and is observable through `document_handle(id)`
1112    // / `with_document(id, ...)` / the `BufferStore` trait — no
1113    // kind-branching at the registry boundary.
1114    // ─────────────────────────────────────────────────────────────
1115
1116    #[tokio::test(flavor = "multi_thread")]
1117    async fn multibuffer_registers_through_document_slot() {
1118        use lattice_multibuffer::{Excerpt, MultibufferDocumentHandle};
1119        use lattice_runtime::spawn_document;
1120        use std::collections::HashMap;
1121
1122        // Two source documents.
1123        let registry = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
1124            lattice_grammar::CommandRegistry::new(),
1125        ));
1126        let s1 = spawn_document(
1127            lattice_core::BufferId(0),
1128            lattice_core::Document::from_text("alpha\nbeta\ngamma\n"),
1129            registry.clone(),
1130        );
1131        let s2 = spawn_document(
1132            lattice_core::BufferId(0),
1133            lattice_core::Document::from_text("delta\nepsilon\nzeta\n"),
1134            registry.clone(),
1135        );
1136
1137        // Reserve BufferIds for the sources; register them so the
1138        // registry round-trips correctly.
1139        let s1_id = BufferId::next();
1140        let s2_id = BufferId::next();
1141        let r = BufferRegistry::new();
1142        let s1_handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(s1);
1143        let s2_handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(s2);
1144        r.insert(BufferEntry {
1145            id: s1_id,
1146            flags: BufferFlags::default(),
1147            data: BufferData::Document(DocumentEntry {
1148                id: s1_id,
1149                handle: s1_handle.clone(),
1150            }),
1151            name: None,
1152        });
1153        r.insert(BufferEntry {
1154            id: s2_id,
1155            flags: BufferFlags::default(),
1156            data: BufferData::Document(DocumentEntry {
1157                id: s2_id,
1158                handle: s2_handle.clone(),
1159            }),
1160            name: None,
1161        });
1162
1163        // Build a multibuffer composing the two sources.
1164        let mut sources: HashMap<BufferId, std::sync::Arc<dyn lattice_runtime::Document>> =
1165            HashMap::new();
1166        sources.insert(s1_id, s1_handle);
1167        sources.insert(s2_id, s2_handle);
1168        let excerpts = vec![Excerpt::new(s1_id, 0, 1), Excerpt::new(s2_id, 1, 2)];
1169        let mb = MultibufferDocumentHandle::new(
1170            sources,
1171            excerpts,
1172            std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
1173                lattice_grammar::CommandRegistry::new(),
1174            )),
1175        )
1176        .expect("mb constructs");
1177        let mb_buffer_id = mb.buffer_id();
1178        let mb_handle: std::sync::Arc<dyn lattice_runtime::Document> = std::sync::Arc::new(mb);
1179
1180        // H.1 (2026-05-31): register through the generic
1181        // `BufferStore::insert_document_buffer` trait method —
1182        // extension crates' canonical path. Maps
1183        // `BufferKind::Multibuffer` to `BufferData::Multibuffer`
1184        // internally.
1185        use lattice_mode::BufferStore;
1186        r.insert_document_buffer(
1187            mb_buffer_id,
1188            BufferKind::Multibuffer,
1189            mb_handle.clone(),
1190            BufferFlags::default(),
1191            Some("*search-results*".to_string()),
1192        );
1193
1194        // The registry exposes the multibuffer through the same
1195        // public API as any other document. Kind now correctly
1196        // returns Multibuffer (was Document before H.1 because
1197        // M.2.b.1 had no `BufferData::Multibuffer` variant).
1198        assert!(r.contains(mb_buffer_id));
1199        assert_eq!(r.kind_of(mb_buffer_id), Some(BufferKind::Multibuffer));
1200        assert_eq!(
1201            r.name_of(mb_buffer_id),
1202            Some("*search-results*".to_string())
1203        );
1204
1205        // `document_handle` returns the trait-object handle —
1206        // reading through it yields the composed snapshot.
1207        let handle_from_registry = r
1208            .document_handle(mb_buffer_id)
1209            .expect("multibuffer reachable via document_handle");
1210        let composed = handle_from_registry.text();
1211        assert_eq!(composed, "alpha\nbeta\nepsilon\nzeta\n");
1212
1213        // `BufferStore::handle_for` returns the same handle —
1214        // the polymorphic surface that mode-owned tasks
1215        // (lattice-lsp, future plugin modes) consume.
1216        let store_handle = r
1217            .handle_for(mb_buffer_id)
1218            .expect("multibuffer reachable via BufferStore");
1219        assert_eq!(store_handle.text(), composed);
1220    }
1221}