Skip to main content

lattice_multibuffer/
lib.rs

1//! Multibuffers: one buffer composed of excerpts from other buffers and
2//! files, and every concern that comes with them.
3//!
4//! A dedicated crate since M.2.b.1 (2026-05-31). Lives outside
5//! `lattice-runtime` so that:
6//!
7//! - The runtime crate stays focused on the actor + handle +
8//!   Document-trait substrate; multibuffer is one specific kind
9//!   of document built on top of that substrate, not part of it.
10//! - Plugins (post-v1) can depend on `lattice-multibuffer`
11//!   directly without pulling in the full actor machinery.
12//! - The crate boundary makes the design self-documenting —
13//!   every multibuffer concern is in one tree; nothing else
14//!   knows multibuffer exists except `lattice-host`'s tiny
15//!   boot-wiring registration.
16//!
17//! See `docs/dev/architecture/multibuffer-views.md` §3.6 for the
18//! crate-layout decision.
19//!
20//! ## What this crate ships (M.2.b.1)
21//!
22//! * **Data model**: `Excerpt`, `ExcerptId`, `ExcerptHeader`,
23//!   `ExcerptHeaderStyle`, `RowEntry`, `RowTranslation`.
24//! * **Handle**: `MultibufferDocumentHandle` — read-only impl of
25//!   `lattice_runtime::Document` composing N source handles into
26//!   one view. M.3 lifts the read-only restriction.
27//! * **Header provider**: `MultibufferExcerptHeaderProvider` (impl
28//!   `lattice_cells::VirtualRowProvider`) emitting one virtual
29//!   row per excerpt header.
30//!
31//! ## What lands later
32//!
33//! * **M.2.b.2** — `MultibufferMode` as the major mode for
34//!   `BufferKind::Multibuffer`. Activation owns the header
35//!   provider registration + per-buffer typed context Guard.
36//! * **M.2.b.3** — `]e` / `[e` / `]E` / `[E` motions registered
37//!   through the grammar; bound in `MultibufferMode` keymap.
38//! * **M.3** — edit propagation (writes flow back to source
39//!   handles via the row translation).
40//! * **M.4** — live updates from sources (auto-recompose on
41//!   `EventKind::DocumentChanged`; anchor sliding; source-close
42//!   auto-remove).
43//! * **M.5–M.8** — expand-context, provider trait + first
44//!   consumer, fold providers.
45
46// PV.1 (2026-08-12): crate-level events, moved out of the
47// feature-gated `providers::search` so the off-keystroke wake exists in
48// every build and every provider crate can publish it.
49pub mod events;
50pub mod install;
51pub mod mode;
52pub mod motions;
53pub mod providers;
54pub mod registry;
55pub mod view;
56
57pub use crate::events::MultibufferExcerptsReady;
58pub use crate::install::install;
59pub use crate::mode::{
60    MultibufferMode, register_multibuffer_ex_commands, register_multibuffer_modes,
61};
62pub use crate::motions::{MultibufferMotionIds, register_multibuffer_motions};
63pub use crate::registry::{
64    InMemoryMultibufferRegistry, MultibufferRegistry, MultibufferRegistryHandle,
65};
66pub use crate::view::create_multibuffer_view;
67
68use std::collections::HashMap;
69use std::path::PathBuf;
70use std::sync::Arc;
71use std::sync::atomic::{AtomicU64, Ordering};
72
73use arc_swap::ArcSwap;
74use lattice_cells::cell::Cell;
75use lattice_cells::headerline::{Headerline, HeaderlineProvider, HeaderlineRow};
76use lattice_cells::virtual_rows::{
77    AnchorPosition, ProviderId, VirtualRow, VirtualRowKind, VirtualRowProvider,
78};
79use lattice_core::buffer::AppliedEdit;
80use lattice_core::{Buffer, BufferId};
81use lattice_grammar::{
82    CancellationToken, CommandInvocation, CommandKind, CommandRegistryHandle, Effect,
83    execute_with_env,
84};
85use lattice_protocol::edit::Edit;
86use lattice_protocol::ids::DocumentId;
87use lattice_protocol::position::Position;
88use lattice_protocol::selection::SelectionSet;
89use lattice_runtime::{
90    Document, DocumentSnapshot, Pending, PublishedSnapshot, RuntimeError, SnapshotCache,
91};
92// K.4.7 (2026-06-07): per-excerpt syntax highlighting.
93use lattice_syntax::{Lang, LangRegistry, Syntax, SyntaxHandle, SyntaxSnapshot};
94// T.7 (2026-06-18): mode-owned theme elements for the excerpt header.
95use lattice_theme::{
96    Color, ColorRef, ElementId, ElementName, ElementOwner, StyleSpec, ThemeRegistryHandle,
97};
98
99// ─────────────────────────────────────────────────────────────────
100// Excerpt + identity + header
101// ─────────────────────────────────────────────────────────────────
102
103/// Unique identity for an excerpt within a multibuffer. Stable
104/// for the excerpt's lifetime; survives reorders / source-edit
105/// rebuilds.
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
107pub struct ExcerptId(pub u64);
108
109impl ExcerptId {
110    pub fn next() -> Self {
111        static SEQ: AtomicU64 = AtomicU64::new(1);
112        Self(SEQ.fetch_add(1, Ordering::Relaxed))
113    }
114}
115
116/// Header presentation for an excerpt — title + style + the
117/// mode-owned semantic data the rich header renderer reads
118/// (MH.A2, see multibuffer-views.md §3.8).
119#[derive(Debug, Clone, Default)]
120pub struct ExcerptHeader {
121    /// Human-readable label. Conventionally
122    /// `"<path> : <start_line+1>-<end_line+1>"` for a regular
123    /// file excerpt (1-indexed for display). Empty string = no
124    /// header rendered. Fallback basename when `path` is `None`.
125    pub title: String,
126    pub style: ExcerptHeaderStyle,
127    /// MH.A2: the source file path. Drives the leading file-type
128    /// icon and the basename-bright / dir-dim split in
129    /// [`header_cells`]. `None` ⇒ fall back to `title`. Set by the
130    /// producing mode (e.g. the search provider) at excerpt
131    /// creation, NOT baked into cells here — the glyph + colours
132    /// are resolved live in `collect()` so `ui.nerd_fonts` /
133    /// `:colorscheme` toggles re-render correctly.
134    pub path: Option<std::path::PathBuf>,
135    /// MH.A2: hit count for the `· N matches` badge. Mode-consumed
136    /// datum set at production (the search mode counts hits per
137    /// source). `None` ⇒ no badge rendered.
138    pub match_count: Option<u32>,
139}
140
141impl ExcerptHeader {
142    pub fn new(title: impl Into<String>) -> Self {
143        Self {
144            title: title.into(),
145            style: ExcerptHeaderStyle::default(),
146            path: None,
147            match_count: None,
148        }
149    }
150}
151
152/// Style discriminator for excerpt headers. M.2 shipped a single
153/// `Default` variant and said future ones would distinguish header
154/// presentation; MH.A6 adds the first.
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum ExcerptHeaderStyle {
157    #[default]
158    Default,
159    /// MH.A6: **this header is the one to look at.** Rendered from its
160    /// own theme elements (`multibuffer.excerpt_header.emphasis[.*]`)
161    /// rather than the shared backdrop / path pair, so a colourscheme
162    /// decides what "prominent" means instead of this enum.
163    ///
164    /// The agenda's today block is the first consumer: a view whose
165    /// whole purpose is "what do I do now" reads badly when the day
166    /// you are actually in paints identically to a Thursday three
167    /// weeks out. Deliberately NOT named `Today` — the mechanism is
168    /// "one header outranks its peers", and a diagnostics view
169    /// wanting to lift its errors block, or a diff view its conflicted
170    /// file, is the same want.
171    ///
172    /// **At most one per view is a convention, not a rule.** Nothing
173    /// here enforces it, because nothing here could: excerpts arrive
174    /// from a sort the provider does not control. A provider that
175    /// emphasises everything has emphasised nothing, and that is its
176    /// bug to avoid.
177    Emphasis,
178}
179
180/// One excerpt of a source document, identified by its source
181/// `BufferId` and an inclusive line range
182/// `[start_line, end_line]`.
183///
184/// M.1 keeps the range as integer line numbers; M.4 swaps to
185/// `Anchor`-based positions that slide on source edits.
186#[derive(Debug, Clone)]
187pub struct Excerpt {
188    pub id: ExcerptId,
189    pub source: BufferId,
190    pub start_line: u32,
191    pub end_line: u32,
192    pub header: ExcerptHeader,
193}
194
195impl Excerpt {
196    pub fn new(source: BufferId, start_line: u32, end_line: u32) -> Self {
197        Self {
198            id: ExcerptId::next(),
199            source,
200            start_line,
201            end_line,
202            header: ExcerptHeader::default(),
203        }
204    }
205
206    pub fn with_header(mut self, header: ExcerptHeader) -> Self {
207        self.header = header;
208        self
209    }
210
211    /// Number of source rows this excerpt covers. Always `>= 1`
212    /// for a well-formed excerpt.
213    pub fn line_count(&self) -> u32 {
214        self.end_line.saturating_sub(self.start_line) + 1
215    }
216}
217
218// ─────────────────────────────────────────────────────────────────
219// Row translation
220// ─────────────────────────────────────────────────────────────────
221
222/// One row in the composed multibuffer view, mapped back to its
223/// source.
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225pub enum RowEntry {
226    Excerpt {
227        excerpt_id: ExcerptId,
228        source_row: u32,
229    },
230}
231
232/// Composed-row → source-row mapping. One entry per composed
233/// row, in display order. Rebuilt on every recompose.
234#[derive(Debug, Clone, Default)]
235pub struct RowTranslation {
236    pub entries: Vec<RowEntry>,
237}
238
239impl RowTranslation {
240    pub fn build(excerpts: &[Excerpt]) -> Self {
241        let mut this = Self::default();
242        this.append(excerpts);
243        this
244    }
245
246    /// MH.B1 (2026-06-19): extend the translation in place with a
247    /// batch of newly-appended excerpts. Because `build` is pure
248    /// concatenation (one `RowEntry::Excerpt` per source row, per
249    /// excerpt, in order — no cross-excerpt state), appending a
250    /// batch's entries to an existing translation yields exactly
251    /// the same `Vec<RowEntry>` as rebuilding from the full excerpt
252    /// list. This is the row-translation half of the O(batch)
253    /// incremental `append_excerpts`; the equivalence is pinned by
254    /// `incremental_append_matches_full_build`.
255    pub fn append(&mut self, excerpts: &[Excerpt]) {
256        for excerpt in excerpts {
257            for row in excerpt.start_line..=excerpt.end_line {
258                self.entries.push(RowEntry::Excerpt {
259                    excerpt_id: excerpt.id,
260                    source_row: row,
261                });
262            }
263        }
264    }
265}
266
267// ─────────────────────────────────────────────────────────────────
268// MultibufferDocumentHandle
269// ─────────────────────────────────────────────────────────────────
270
271struct MultibufferInner {
272    id: DocumentId,
273    buffer_id: BufferId,
274    // M.2.b.2 (2026-06-01): sources + excerpts move behind a
275    // Mutex so providers can stream updates asynchronously via
276    // `append_excerpts` / `replace_excerpts` / `add_source` /
277    // `remove_source`. Hot reads (`snapshot`, `row_translation`,
278    // `excerpts`) go through the lock-free `PublishedSnapshot`
279    // cell and the `ArcSwap<RowTranslation>` — the Mutex is
280    // only acquired on mutation + on the recompose seam.
281    state: std::sync::Mutex<MultibufferState>,
282    /// M.11 (2026-06-02): the composed rope, owned LOCALLY.
283    /// Edits apply here synchronously (byte-identical to
284    /// `RopeDocumentHandle`'s apply_edit) before being forwarded
285    /// to the relevant source actor async. The multibuffer is no
286    /// longer a proxy — it IS a buffer.
287    ///
288    /// Wrapped in `Mutex` because `apply_edit` runs on the
289    /// caller's thread (the host's block_on bridge) while the
290    /// source forwarder task and excerpt-mutation paths
291    /// (append_excerpts / replace_excerpts) read it from other
292    /// contexts. Lock-free reads go through `snapshot_cell`.
293    composed_doc: std::sync::Mutex<lattice_core::Document>,
294    /// M.11 (2026-06-02): mpsc sender into the source-forwarder
295    /// task. Each `apply_edit` queues a (composed_edit,
296    /// row_translation_snapshot) pair; the forwarder task
297    /// translates to source coords and ships to the source
298    /// actor. Fire-and-forget — caller doesn't wait.
299    source_forward_tx: tokio::sync::mpsc::UnboundedSender<SourceForwardMsg>,
300    snapshot_cell: Arc<PublishedSnapshot>,
301    row_translation: ArcSwap<RowTranslation>,
302    // M.4 (2026-06-01): view-level headerline rendered above the
303    // first excerpt. Async providers update this to surface
304    // progress + completion status (see
305    // `multibuffer-views.md` §3.7 "headerline status convention").
306    // Lock-free read via `ArcSwap`; writes go through
307    // `set_headerline` which also publishes
308    // `MultibufferHeaderlineChanged`.
309    headerline: ArcSwap<HeaderlineStatus>,
310    // M.6.5 (2026-06-08): monotonic version for the view-status headerline.
311    // Bumped in `set_headerline` so `MultibufferStatusProvider::version()`
312    // advances and the cells worker rebuilds the sticky row.
313    headerline_version: AtomicU64,
314    // M.4 (2026-06-01): event-bus subscription bookkeeping for
315    // the auto-recompose forwarder. `SubscriptionId`s registered
316    // by `attach_event_subscriptions` are unsubscribed on Drop.
317    subscriptions: std::sync::Mutex<SubscriptionBookkeeping>,
318    // K.4.11 (2026-06-02): CommandRegistry the multibuffer runs
319    // grammar against in `dispatch_with_cancel`. Passed at
320    // construction so the multibuffer is a self-sufficient
321    // Document — same shape `spawn_document(id, doc, registry)`
322    // takes for regular Document handles. Replaces the
323    // host-side kind-branch in `Editor::dispatch_blocking` (the
324    // multibuffer's own `Document::dispatch_with_cancel` impl
325    // now does the work uniformly).
326    //
327    // B3b: the `ArcSwap` handle (was `Arc<CommandRegistry>`) so a plugin
328    // grammar contribution registered at runtime is live for this view's
329    // next dispatch; `dispatch_with_cancel` `.load_full()`s an owned
330    // snapshot for each keystroke.
331    registry: CommandRegistryHandle,
332    // K.4.7 (2026-06-07): language registry for per-source
333    // SyntaxHandle creation. Set once after construction via
334    // `set_lang_registry`; `None` until the host wires it.
335    lang_registry: std::sync::OnceLock<Arc<LangRegistry>>,
336    /// AF.1: how this view's rows GROUP for folding, declared by the provider
337    /// that built it. See [`FoldGrouping`].
338    fold_grouping: std::sync::atomic::AtomicU8,
339    // K.4.7 (2026-06-08): monotonic generation counter. Incremented
340    // every time `source_syntax` gains a new handle (`add_source` /
341    // `set_lang_registry`). Folded into `MatrixVersion::syntax` in
342    // `publish_render_state` so the cells worker invalidates its cache
343    // when per-source handles are first populated. XOR of individual
344    // handle text_versions is unreliable: N handles all at version=1
345    // XOR to 0 for even N, colliding with the initial-zero and
346    // producing a false cache hit that freezes highlighting.
347    excerpt_syntax_gen: std::sync::atomic::AtomicU64,
348    // K.4.7 (2026-06-08): monotonic publish counter. Stamped as the
349    // `text_version` on every DocumentSnapshot emitted by
350    // `append_excerpts` / `replace_excerpts`. Document::from_text()
351    // always returns text_version=0, so without this counter the
352    // MatrixVersion.text axis is permanently 0 and the cells worker
353    // always returns CacheHit — the "empty until keypress" bug.
354    publish_seq: std::sync::atomic::AtomicU64,
355}
356
357#[derive(Default)]
358struct SubscriptionBookkeeping {
359    /// Subscription ids returned by `EventBus::subscribe`; cleared
360    /// on Inner Drop via `unsubscribe`.
361    ids: Vec<lattice_runtime::SubscriptionId>,
362    /// Cheap-clone Arc for the unsubscribe path. `None` until
363    /// `attach_event_subscriptions` runs.
364    bus: Option<Arc<lattice_runtime::EventBus>>,
365}
366
367impl Drop for MultibufferInner {
368    fn drop(&mut self) {
369        // Unsubscribe + drop the bus reference so the forwarder
370        // task (which holds a Weak<MultibufferInner>) sees the
371        // upgrade fail and exits cleanly.
372        if let Ok(mut book) = self.subscriptions.lock()
373            && let Some(bus) = book.bus.take()
374        {
375            for id in book.ids.drain(..) {
376                let _ = bus.unsubscribe(id);
377            }
378        }
379    }
380}
381
382/// M.11 (2026-06-02): one outbound edit waiting to be forwarded
383/// to a source actor. Carries pre-resolved source coords so the
384/// forwarder task doesn't need to re-walk the row translation
385/// (which may have shifted by the time the task runs).
386#[derive(Debug)]
387/// What the forwarder needs to announce a source change it just made.
388///
389/// Carried in the message rather than held by the forwarder task, because the
390/// task is spawned inside `new()` — before `Self` exists — while the sender
391/// (`apply_edit_batch_sync`) has `&self` and can hand over both the bus and a
392/// `Weak` back to the view.
393struct AnnounceSource {
394    /// The source's buffer id, for the self-origin record below.
395    source_id: BufferId,
396    /// Weak so a dropped view lets the forwarder fall through to a no-op
397    /// rather than keeping the whole multibuffer alive.
398    inner: std::sync::Weak<MultibufferInner>,
399    bus: Arc<lattice_runtime::EventBus>,
400}
401
402enum SourceForwardMsg {
403    /// Propagate a composed-coordinate edit to its source actor.
404    ///
405    /// `announce` is how the source's change gets ANNOUNCED. Applying it here
406    /// used to be the end of the story: the source actor was mutated and
407    /// nothing published, so nothing downstream — this view's own syntax
408    /// reparse, a second view on the same file, the diff subsystem, a plugin
409    /// watching `DocumentChanged` — ever learned the file had changed. A
410    /// buffer that changes without an event is one every subscriber has
411    /// stale.
412    Edit {
413        source_handle: Arc<dyn Document>,
414        source_edit: Edit,
415        announce: Option<AnnounceSource>,
416    },
417    /// OA.23b: an edit written straight at a source, in SOURCE
418    /// coordinates, for a line the view does not contain.
419    ///
420    /// The agenda's `s` / `d`: a row is one line — the headline — and
421    /// the planning line goes below it, outside every excerpt. There is
422    /// no composed coordinate for it, so it cannot arrive as an
423    /// ordinary [`Self::Edit`].
424    ///
425    /// **Through the forwarder rather than straight at the handle**, so
426    /// it queues behind whatever composed edits are still in flight. A
427    /// direct `source.apply_edit(..)` could overtake them, and then
428    /// their source coordinates — computed before this insert shifted
429    /// the lines below it — would land in the wrong place. FIFO is the
430    /// whole reason this channel exists; a second door into the same
431    /// document would defeat it.
432    ///
433    /// `reply` carries the result back because this one has a caller
434    /// waiting on it: the host's `Effect::ApplyEdit` route reports
435    /// failure, unlike composed propagation which is best-effort.
436    DirectEdit {
437        source_handle: Arc<dyn Document>,
438        source_edit: Edit,
439        reply: tokio::sync::oneshot::Sender<Result<AppliedEdit, RuntimeError>>,
440    },
441    /// Save-flush barrier (2026-06-10). Sent by
442    /// [`MultibufferDocumentHandle::save`] through the SAME FIFO
443    /// channel as edits, so by the time the forwarder pops it every
444    /// prior edit has been applied to (and `await`ed on) its source
445    /// actor. The forwarder then signals `done`, telling `save()` the
446    /// sources are current — without this, `:w` would race the async
447    /// forwarder and persist stale sources (drop the last keystrokes).
448    Flush {
449        done: tokio::sync::oneshot::Sender<()>,
450    },
451}
452
453struct MultibufferState {
454    sources: HashMap<BufferId, Arc<dyn Document>>,
455    /// SS.2 (2026-08-11): the on-disk identity each source had when it
456    /// entered the view.
457    ///
458    /// A multibuffer's sources are SNAPSHOTS — read once at
459    /// view-creation and held for the view's lifetime — while
460    /// `Document::save` writes every dirty one back. Without a baseline
461    /// that silently overwrites whatever changed the file externally
462    /// (a rebase, a formatter, another pane). SS.3 compares against
463    /// this before writing.
464    ///
465    /// Pathless sources (synthetic documents) are absent and are never
466    /// stale — there is no file to conflict with.
467    /// See `docs/dev/architecture/multibuffer-stale-sources.md`.
468    source_fingerprints: HashMap<BufferId, lattice_core::on_disk::OnDiskFingerprint>,
469    /// The newest source `text_version` this view itself produced, per source.
470    ///
471    /// The view forwards its composed edits to the sources, and those now
472    /// announce themselves on the bus — which means this view's own
473    /// subscription hears its own edit come back. That echo must not be
474    /// treated as an outside change: `slide_anchors_for_source` would shift
475    /// excerpts for an edit the composed edit already accounted for, moving
476    /// every row below the one you just typed on.
477    ///
478    /// Matching on the VERSION rather than adding a field to
479    /// `Event::DocumentChanged`: the version is already carried, is already
480    /// unique per mutation, and keeps the provenance question inside the one
481    /// crate that has it. An event field would put it in the protocol for
482    /// every subscriber that does not care.
483    self_forwarded_versions: HashMap<BufferId, u64>,
484    excerpts: Vec<Excerpt>,
485    /// K.4.7 (2026-06-07): per-source SyntaxHandle for excerpt
486    /// highlighting. Populated by `add_source` when `lang_registry`
487    /// is set. Sources with `Lang::Plain` are absent.
488    source_syntax: HashMap<BufferId, Arc<SyntaxHandle>>,
489    /// K.4.5 (2026-06-02): composed-coordinate selection set
490    /// for the view. Multibuffers don't propagate selections to
491    /// their source buffers (M.3 design — composed coordinates
492    /// don't map cleanly back through edits / excerpts), but
493    /// the view itself IS a buffer and carries its own
494    /// selection state. Visual-mode highlight painting
495    /// (`Editor::visual_selection_range` → renderer) reads
496    /// these via `snapshot.selections`. Updated by
497    /// `set_selections` (Document trait); rebuilt-but-preserved
498    /// by every recompose path so excerpt mutations don't
499    /// clobber the user's selection.
500    selections: Arc<SelectionSet>,
501}
502
503// ─────────────────────────────────────────────────────────────────
504// M.4 (2026-06-01): headerline status + typed events
505// ─────────────────────────────────────────────────────────────────
506
507/// View-level headerline status. Rendered above the first
508/// excerpt (M.2.a `MultibufferExcerptHeaderProvider` extends to handle
509/// the view header in a later renderer slice).
510///
511/// Async providers transition `Idle → InProgress → Complete` /
512/// `Failed` as their scan progresses. See
513/// `multibuffer-views.md` §3.7.
514#[derive(Debug, Clone, PartialEq, Eq, Default)]
515pub enum HeaderlineStatus {
516    /// No status rendered. The view-header virtual row is empty.
517    #[default]
518    Idle,
519    /// A scan / fetch / computation is running. `label` describes
520    /// it; `count` is an optional running tally (hits found so
521    /// far, files scanned, etc.). `emphasis` is an optional
522    /// substring of `label` (e.g. the search query) that the
523    /// renderer paints with the `multibuffer.status.query` accent
524    /// role so it stands out; providers with nothing to emphasise
525    /// pass `None`.
526    InProgress {
527        label: String,
528        count: Option<usize>,
529        emphasis: Option<String>,
530    },
531    /// The operation completed successfully. `summary` is the
532    /// terminal label rendered to the user. `emphasis` is an
533    /// optional substring of `summary` painted with the accent
534    /// role (see [`HeaderlineStatus::InProgress`]).
535    Complete {
536        summary: String,
537        emphasis: Option<String>,
538    },
539    /// The operation failed. `reason` is the terminal label
540    /// rendered to the user.
541    Failed { reason: String },
542}
543
544/// M.4 (2026-06-01): published whenever a view's headerline
545/// status changes. Renderers + status-line consumers subscribe
546/// via `EventBus::subscribe_typed::<MultibufferHeaderlineChanged>`.
547#[derive(Debug, Clone)]
548pub struct MultibufferHeaderlineChanged {
549    pub view: BufferId,
550    pub status: HeaderlineStatus,
551}
552
553lattice_protocol::register_event!(
554    MultibufferHeaderlineChanged,
555    "multibuffer.headerline-changed",
556    "Multibuffer view's headerline status changed (Idle / InProgress / Complete / Failed).",
557    "lattice-multibuffer",
558);
559
560/// M.4 (2026-06-01): published when one of a multibuffer's
561/// source buffers closes. Providers subscribe to choose a
562/// source-close policy: project-search drops the stale excerpts;
563/// project-diff may keep them as historical reference.
564/// Multibuffer itself prunes the source from its internal map.
565#[derive(Debug, Clone)]
566pub struct MultibufferSourceClosed {
567    pub view: BufferId,
568    pub source: BufferId,
569}
570
571lattice_protocol::register_event!(
572    MultibufferSourceClosed,
573    "multibuffer.source-closed",
574    "One of a multibuffer view's source buffers closed; providers choose policy (drop excerpts, keep stale, etc.).",
575    "lattice-multibuffer",
576);
577
578/// PD.7c: published when one of a multibuffer's source buffers is
579/// **edited** — the peer of [`MultibufferSourceClosed`], and it exists
580/// for the same reason.
581///
582/// The view keeps composing correctly on its own (excerpts slide,
583/// content recomposes). What an edit invalidates is whatever the
584/// PROVIDER derived from the source's content at scan time — diff
585/// classifications, match positions, symbol ranges — and only the
586/// provider knows what that is or what to do about it. Publishing the
587/// fact here rather than letting each provider re-derive
588/// `DocumentId → source` from raw `DocumentChanged` events keeps that
589/// translation in the one place that already owns the source map.
590///
591/// Fires once per applied change, so a provider that reacts expensively
592/// should react once per source (project-diff clears that file's
593/// styling on the first edit and then ignores the rest until `gr`).
594#[derive(Debug, Clone)]
595pub struct MultibufferSourceEdited {
596    pub view: BufferId,
597    pub source: BufferId,
598}
599
600lattice_protocol::register_event!(
601    MultibufferSourceEdited,
602    "multibuffer.source-edited",
603    "One of a multibuffer view's source buffers was edited; providers choose policy for data they derived from its content.",
604    "lattice-multibuffer",
605);
606
607/// A multibuffer document handle. Composes N source
608/// `Arc<dyn Document>`s into one read-only composed view; impls
609/// [`Document`] so dispatch / motion / render code paths serve
610/// it the same as a regular `RopeDocumentHandle`.
611#[derive(Clone)]
612pub struct MultibufferDocumentHandle {
613    inner: Arc<MultibufferInner>,
614}
615
616/// SS.3: has `path`'s content changed since `baseline` was taken?
617///
618/// Cheap `(mtime, size)` pre-gate first; only a file that looks moved
619/// is re-read and hashed. The content hash is authoritative, so a bare
620/// `touch` — mtime bumped, bytes identical — is correctly NOT stale.
621///
622/// An unreadable file is treated as **not stale**: the subsequent save
623/// will fail on its own and report a real I/O error, which is a better
624/// message than "changed on disk" for a file that was deleted.
625fn is_stale_on_disk(
626    path: &std::path::Path,
627    baseline: &lattice_core::on_disk::OnDiskFingerprint,
628) -> bool {
629    if baseline.stat_unchanged(path) {
630        return false;
631    }
632    let Ok(disk_text) = std::fs::read_to_string(path) else {
633        return false;
634    };
635    let current = lattice_core::on_disk::OnDiskFingerprint::from_path_and_text(path, &disk_text);
636    !current.same_content(baseline)
637}
638
639/// SS.2: the on-disk baseline for one source, or `None` when it has no
640/// path (a synthetic document — nothing on disk to conflict with).
641///
642/// Called at insertion, where the source's in-memory text IS what was
643/// just read from disk, so the baseline is exact and costs one `stat`.
644fn fingerprint_source(
645    source: &Arc<dyn Document>,
646) -> Option<lattice_core::on_disk::OnDiskFingerprint> {
647    let path = source.path()?;
648    let text = source.snapshot().buffer.as_string();
649    Some(lattice_core::on_disk::OnDiskFingerprint::from_path_and_text(&path, &text))
650}
651
652impl MultibufferDocumentHandle {
653    /// Construct a multibuffer composing `sources` + `excerpts`.
654    ///
655    /// M.2.b.2 (2026-06-01): empty `sources` + empty `excerpts`
656    /// are valid — async providers (project-search, lsp-references,
657    /// etc.) open an empty view immediately and stream content in
658    /// via [`Self::append_excerpts`] / [`Self::add_source`] as
659    /// their scan progresses. The previous `EmptyExcerpts` error
660    /// was relaxed when the async-provider pattern landed; see
661    /// `multibuffer-views.md` §3.7.
662    ///
663    /// Returns `UnknownSource` if any excerpt references a
664    /// source BufferId not present in `sources`.
665    pub fn new(
666        sources: HashMap<BufferId, Arc<dyn Document>>,
667        excerpts: Vec<Excerpt>,
668        registry: CommandRegistryHandle,
669    ) -> Result<Self, MultibufferError> {
670        for ex in &excerpts {
671            if !sources.contains_key(&ex.source) {
672                return Err(MultibufferError::UnknownSource {
673                    excerpt: ex.id,
674                    source_buffer: ex.source,
675                });
676            }
677        }
678
679        let id = next_multibuffer_document_id();
680        let buffer_id = BufferId::next();
681        let row_translation = RowTranslation::build(&excerpts);
682        // M.11 (2026-06-02): build the composed Document LOCALLY
683        // from source content at construction time. From here on,
684        // the multibuffer's composed_doc is authoritative — edits
685        // land on it synchronously; sources are downstream
686        // observers that get the same edit forwarded async.
687        let composed_text = compose_text_from_sources(&sources, &excerpts);
688        let composed_doc = lattice_core::Document::from_text(composed_text);
689        let composed =
690            snapshot_from_composed_doc(&composed_doc, id, Arc::new(SelectionSet::default()));
691        let snapshot_cell = Arc::new(PublishedSnapshot::new(composed));
692
693        // M.11 (2026-06-02): spawn the source-forwarder task on
694        // the shared multi-thread runtime — the same runtime that
695        // owns source actors (via `spawn_document` →
696        // `shared_runtime().spawn(actor.run())`). This guarantees:
697        // (1) the forwarder isn't tied to the caller's runtime
698        // (which may be a current_thread editor actor about to
699        // block in `block_on`); (2) cross-runtime mpsc + oneshot
700        // semantics aren't needed (both forwarder + source actor
701        // are on the same runtime); (3) the forwarder never gets
702        // starved by the caller's runtime.
703        let (source_forward_tx, mut source_forward_rx) =
704            tokio::sync::mpsc::unbounded_channel::<SourceForwardMsg>();
705        lattice_runtime::shared_runtime().spawn(async move {
706            while let Some(msg) = source_forward_rx.recv().await {
707                match msg {
708                    // Discard the AppliedEdit — the multibuffer's
709                    // local composed_doc is already authoritative.
710                    // Best-effort propagation.
711                    SourceForwardMsg::Edit {
712                        source_handle,
713                        source_edit,
714                        announce,
715                    } => {
716                        let applied = source_handle.apply_edit(source_edit).await;
717                        // Announce the source change. Only on SUCCESS: a
718                        // failed apply left the source untouched, and telling
719                        // the world it changed would make every subscriber
720                        // recompute against content that never existed.
721                        if let (Ok(applied), Some(announce)) = (applied, announce) {
722                            let snap = source_handle.snapshot();
723                            // Record BEFORE publishing: the subscription runs
724                            // on another task and the publish is what wakes
725                            // it, so writing the version afterwards is a race
726                            // this view would lose by sliding its own anchors.
727                            if let Some(inner) = announce.inner.upgrade()
728                                && let Ok(mut state) = inner.state.lock()
729                            {
730                                state
731                                    .self_forwarded_versions
732                                    .insert(announce.source_id, snap.text_version);
733                            }
734                            announce
735                                .bus
736                                .publish(lattice_protocol::Event::DocumentChanged {
737                                    id: snap.id,
738                                    path: snap.path().map(|p| p.to_path_buf()),
739                                    version: snap.version,
740                                    edits: vec![lattice_protocol::event::AppliedEdit {
741                                        original_range: applied.original_range,
742                                        inserted_range: applied.inserted_range,
743                                        replaced_text: applied.replaced_text.clone(),
744                                        inserted_text: applied.inserted_text.clone(),
745                                    }],
746                                });
747                        }
748                    }
749                    // Same await, but the result goes back: this one has
750                    // a caller that reports failure.
751                    SourceForwardMsg::DirectEdit {
752                        source_handle,
753                        source_edit,
754                        reply,
755                    } => {
756                        let _ = reply.send(source_handle.apply_edit(source_edit).await);
757                    }
758                    // FIFO: every prior Edit was applied + awaited
759                    // above, so the sources are now current. Signal
760                    // `save()` to proceed (best-effort — a dropped
761                    // `done` just means save() falls through).
762                    SourceForwardMsg::Flush { done } => {
763                        let _ = done.send(());
764                    }
765                }
766            }
767        });
768
769        Ok(Self {
770            inner: Arc::new(MultibufferInner {
771                id,
772                buffer_id,
773                state: std::sync::Mutex::new(MultibufferState {
774                    source_fingerprints: sources
775                        .iter()
776                        .filter_map(|(id, src)| fingerprint_source(src).map(|fp| (*id, fp)))
777                        .collect(),
778                    self_forwarded_versions: HashMap::new(),
779                    sources,
780                    excerpts,
781                    source_syntax: HashMap::new(),
782                    selections: Arc::new(SelectionSet::default()),
783                }),
784                composed_doc: std::sync::Mutex::new(composed_doc),
785                source_forward_tx,
786                snapshot_cell,
787                row_translation: ArcSwap::from_pointee(row_translation),
788                headerline: ArcSwap::from_pointee(HeaderlineStatus::Idle),
789                headerline_version: AtomicU64::new(0),
790                subscriptions: std::sync::Mutex::new(SubscriptionBookkeeping::default()),
791                registry,
792                lang_registry: std::sync::OnceLock::new(),
793                excerpt_syntax_gen: std::sync::atomic::AtomicU64::new(0),
794                fold_grouping: std::sync::atomic::AtomicU8::new(FoldGrouping::SourceFile as u8),
795                publish_seq: std::sync::atomic::AtomicU64::new(0),
796            }),
797        })
798    }
799
800    /// Convenience constructor for the async-provider pattern:
801    /// build an empty view with no sources and no excerpts. The
802    /// provider streams content in via [`Self::append_excerpts`].
803    /// Infallible.
804    ///
805    /// K.4.11 (2026-06-02): takes the same `CommandRegistryHandle`
806    /// as the full [`Self::new`] constructor. The multibuffer is
807    /// grammar-capable from creation — empty-view or not.
808    pub fn empty(registry: CommandRegistryHandle) -> Self {
809        Self::new(HashMap::new(), Vec::new(), registry)
810            .expect("empty inputs are valid; UnknownSource impossible")
811    }
812
813    pub fn buffer_id(&self) -> BufferId {
814        self.inner.buffer_id
815    }
816
817    /// M.2.b.2 (2026-06-01): the multibuffer's `DocumentId`, used
818    /// by the cleanup subscriber to match an `Event::DocumentClosed`
819    /// payload (which carries `DocumentId`, not `BufferId`) back
820    /// to a registry entry keyed by `BufferId`.
821    pub fn document_id(&self) -> DocumentId {
822        self.inner.id
823    }
824
825    pub fn row_translation(&self) -> Arc<RowTranslation> {
826        self.inner.row_translation.load_full()
827    }
828
829    /// Snapshot the current excerpt list. M.2.b.2 (2026-06-01):
830    /// returns an owned `Vec` clone because excerpts now live
831    /// behind a Mutex (async providers mutate); callers that
832    /// need a borrow held across `await` points or across
833    /// concurrent mutations get a deterministic copy instead.
834    pub fn excerpts(&self) -> Vec<Excerpt> {
835        self.lock_state().excerpts.clone()
836    }
837
838    /// Count of currently-registered excerpts. Cheap probe that
839    /// avoids the `Vec` clone of [`Self::excerpts`].
840    pub fn excerpt_count(&self) -> usize {
841        self.lock_state().excerpts.len()
842    }
843
844    pub fn source_buffer_ids(&self) -> Vec<BufferId> {
845        self.lock_state().sources.keys().copied().collect()
846    }
847
848    /// OA.23b: whether `id` is one of this view's sources.
849    ///
850    /// Membership, not path-ness: a synthetic source has no path, and asking
851    /// `source_path(..).is_some()` would say a view does not own one it does.
852    pub fn has_source(&self, id: BufferId) -> bool {
853        self.lock_state().sources.contains_key(&id)
854    }
855
856    /// Generic multibuffer jump-to-source: resolve a source buffer
857    /// id to its on-disk path by reading the source document's path
858    /// directly. Unlike the per-provider `source_path` mapping
859    /// (e.g. `ProjectSearchService::source_path`), this works for
860    /// ANY multibuffer view without provider-specific state — every
861    /// source document carries its path through the `Document` trait.
862    /// Consumed by the generic `action:multibuffer-jump-to-source`
863    /// handler registered by `MultibufferMode::on_activate`. Returns
864    /// `None` when the source buffer id is unknown or has no path.
865    pub fn source_path(&self, source_buffer_id: BufferId) -> Option<PathBuf> {
866        self.lock_state().sources.get(&source_buffer_id)?.path()
867    }
868
869    /// The current text of a source document, by the same key
870    /// [`Self::source_path`] takes.
871    ///
872    /// PD.4: the peer of `source_path`, and it exists for the same
873    /// reason — a provider that spawns its own sources (project-diff,
874    /// search) hands them to `add_source` and keeps no handle, so
875    /// without this there is no way to ask whether an edit made in the
876    /// view actually arrived in the file's document. Propagating into
877    /// *some* document proves nothing; the claim is that it reaches the
878    /// one anchored to the path.
879    ///
880    /// Reads through the source's own snapshot, so it observes the
881    /// forwarder task's progress rather than the composed rope's.
882    pub fn source_text(&self, source_buffer_id: BufferId) -> Option<String> {
883        Some(self.lock_state().sources.get(&source_buffer_id)?.text())
884    }
885
886    /// OA.23b: one line of a source, by the key [`Self::source_path`] takes,
887    /// without its trailing newline. `None` for an unknown source or a line
888    /// past its last.
889    ///
890    /// The read half of acting on a row's source. A caller that has located a
891    /// headline through `translate_composed_to_source` needs to see the line
892    /// BELOW it to know whether there is already a planning line there, and
893    /// that line is outside every excerpt — the composed text cannot show it.
894    ///
895    /// Reads the source's own snapshot, so it sees edits the forwarder has
896    /// already applied. Reading the FILE instead would not: a second `s` in a
897    /// row would see the first one's absence and stack a duplicate line.
898    pub fn source_line(&self, source_buffer_id: BufferId, line: u32) -> Option<String> {
899        let source = self.lock_state().sources.get(&source_buffer_id)?.clone();
900        source.snapshot().buffer.line(line)
901    }
902
903    /// OA.23b: a source's current snapshot, by the key [`Self::source_path`]
904    /// takes. What a caller needs to publish a `DocumentChanged` for an edit
905    /// it just made through [`Self::apply_to_source`].
906    pub fn source_snapshot(&self, source_buffer_id: BufferId) -> Option<Arc<DocumentSnapshot>> {
907        Some(self.lock_state().sources.get(&source_buffer_id)?.snapshot())
908    }
909
910    /// OA.23b: apply an edit in SOURCE coordinates to one of this view's
911    /// sources — a line the view does not compose.
912    ///
913    /// See [`SourceForwardMsg::DirectEdit`] for why it goes through the
914    /// forwarder rather than at the handle. `None` when `source_buffer_id` is
915    /// not one of this view's sources; the returned `Pending` resolves to
916    /// Replay `applied` — the result of an undo or redo on the composed doc —
917    /// at each edit's source.
918    ///
919    /// Each `AppliedEdit` says a composed range held `replaced_text` and now
920    /// holds `inserted_text`. Sending exactly that as a composed-coordinate
921    /// `Edit` through the same translation the forward path uses is what
922    /// keeps the two in step, and is why there is no second code path to
923    /// drift: `resolve_edit_target` + `build_source_edit`, unchanged.
924    ///
925    /// Fire-and-forget onto the same FIFO the forward edits ride, so the
926    /// source sees the undo strictly after the edit it undoes. A `:w` flushes
927    /// that queue before reading, so a save immediately after `u` persists
928    /// the undone text rather than racing it.
929    pub(crate) fn forward_applied_to_sources(&self, applied: &[AppliedEdit]) {
930        if applied.is_empty() {
931            return;
932        }
933        let messages: Vec<SourceForwardMsg> = {
934            let state = self.lock_state();
935            applied
936                .iter()
937                .filter_map(|a| {
938                    let edit = Edit {
939                        range: a.original_range,
940                        kind: lattice_protocol::edit::EditKind::Replace {
941                            text: a.inserted_text.clone(),
942                        },
943                    };
944                    let target = resolve_edit_target(&state, edit.range.start)?;
945                    let source_edit = build_source_edit(&target, &edit);
946                    let announce = self.announce_for(target.source_id);
947                    Some(SourceForwardMsg::Edit {
948                        source_handle: target.source_handle.clone(),
949                        source_edit,
950                        announce,
951                    })
952                })
953                .collect()
954        };
955        for msg in messages {
956            let _ = self.inner.source_forward_tx.send(msg);
957        }
958    }
959
960    /// MU.1: symmetric with [`Self::undo`] in every respect, including
961    /// carrying the result to the sources. An asymmetry here would be the
962    /// nastier half of the same bug — `u` then `<C-r>` would leave the file
963    /// holding the undone text.
964    /// whatever the source actor made of the edit.
965    pub fn apply_to_source(
966        &self,
967        source_buffer_id: BufferId,
968        edit: Edit,
969    ) -> Option<Pending<AppliedEdit>> {
970        let source_handle = self.lock_state().sources.get(&source_buffer_id)?.clone();
971        let (reply, rx) = tokio::sync::oneshot::channel();
972        self.inner
973            .source_forward_tx
974            .send(SourceForwardMsg::DirectEdit {
975                source_handle,
976                source_edit: edit,
977                reply,
978            })
979            .ok()?;
980        // Wrapped rather than spawned: the work is already queued on the
981        // forwarder, and the caller is the editor actor about to block on
982        // this. See `Pending::from_channel`.
983        Some(Pending::from_channel(rx))
984    }
985
986    /// M.10.2 (2026-06-03): translate a composed-coordinate
987    /// cursor to its source-coordinate equivalent. Walks excerpts
988    /// in display order to find the one containing
989    /// `cursor.line`; returns `(source_buffer_id,
990    /// source_position)` where `source_position.line` is the
991    /// row in the originating source rope and
992    /// `source_position.byte` is preserved verbatim (each
993    /// composed row is a verbatim copy of its source line, so
994    /// byte columns map 1:1).
995    ///
996    /// Returns `None` when the cursor is past the last
997    /// excerpt's last composed row. Pure read; doesn't mutate
998    /// any state.
999    ///
1000    /// Consumed by mode handlers (search `<CR>`, project-diff
1001    /// `<CR>`, lsp-references `<CR>` once those land) — see
1002    /// `mode-architecture.md` §5.3.4 (substrate-vs-helper
1003    /// rule). Returning data, not behavior — the chord-binding +
1004    /// open-and-position logic lives in the mode's handler
1005    /// closure registered via the M.10.1.b
1006    /// `ActionHandlerRegistry`.
1007    pub fn translate_composed_to_source(&self, cursor: Position) -> Option<(BufferId, Position)> {
1008        let state = self.lock_state();
1009        let mut composed_cursor: u32 = 0;
1010        for excerpt in &state.excerpts {
1011            let next = composed_cursor.saturating_add(excerpt.line_count());
1012            if cursor.line < next {
1013                let offset = cursor.line - composed_cursor;
1014                let source_row = excerpt.start_line.saturating_add(offset);
1015                return Some((
1016                    excerpt.source,
1017                    Position {
1018                        line: source_row,
1019                        byte: cursor.byte,
1020                    },
1021                ));
1022            }
1023            composed_cursor = next;
1024        }
1025        None
1026    }
1027
1028    /// M.5 (2026-06-01): grow / shrink the excerpt containing
1029    /// `cursor_row` by `delta_rows` total rows, split
1030    /// symmetrically above and below.
1031    ///
1032    /// Behaviour:
1033    /// - `delta_rows > 0` expands; `delta_rows < 0` contracts;
1034    ///   `delta_rows == 0` is a no-op.
1035    /// - Symmetric split: `delta_rows / 2` above, the remainder
1036    ///   below. With `delta_rows = 5`: 2 rows added above, 3 below.
1037    /// - Clip: `start_line` never goes below 0; `end_line` never
1038    ///   exceeds the source's last row (read from
1039    ///   `source.snapshot().buffer.line_count() - 1`).
1040    /// - Min size: if the contract would make `start > end`,
1041    ///   no-op (excerpt keeps its existing range).
1042    /// - No-op when the cursor sits outside every excerpt OR the
1043    ///   excerpt's source isn't in the source map (closed source).
1044    ///
1045    /// Recomposes + publishes after the mutation, matching
1046    /// `append_excerpts` / `replace_excerpts` shape.
1047    pub fn expand_excerpt_at(&self, cursor_row: u32, delta_rows: i32) {
1048        if delta_rows == 0 {
1049            return;
1050        }
1051        let mut state = self.lock_state();
1052        let Some(idx) = crate::motions::containing_excerpt_index(&state.excerpts, cursor_row)
1053        else {
1054            return;
1055        };
1056        // `containing_excerpt_index` returns the last excerpt for
1057        // rows past the view's end (motion-friendly). For
1058        // expand-context the cursor must actually sit within the
1059        // excerpt's composed range — verify by checking the
1060        // start-rows table.
1061        let starts = crate::motions::excerpt_start_rows(&state.excerpts);
1062        let excerpt_start_composed = starts[idx];
1063        let excerpt_end_composed = excerpt_start_composed
1064            .saturating_add(state.excerpts[idx].line_count())
1065            .saturating_sub(1);
1066        if cursor_row > excerpt_end_composed {
1067            return;
1068        }
1069
1070        let source_id = state.excerpts[idx].source;
1071        let Some(source) = state.sources.get(&source_id) else {
1072            return;
1073        };
1074        // CV.3: content space — an excerpt may only expand onto lines
1075        // the source actually has, never the phantom one ropey reports
1076        // after a terminating newline.
1077        let source_line_count = source.snapshot().buffer.content_line_count() as i64;
1078        if source_line_count == 0 {
1079            return;
1080        }
1081
1082        // Symmetric split: half above (integer divide rounds
1083        // toward zero, so positive delta puts the extra below;
1084        // negative delta puts the extra above).
1085        let above = (delta_rows / 2) as i64;
1086        let below = (delta_rows as i64) - above;
1087
1088        let current_start = state.excerpts[idx].start_line as i64;
1089        let current_end = state.excerpts[idx].end_line as i64;
1090
1091        let new_start = (current_start - above).clamp(0, source_line_count - 1);
1092        let new_end = (current_end + below).clamp(0, source_line_count - 1);
1093
1094        if new_end < new_start {
1095            // Contract would invert: leave the excerpt as-is.
1096            return;
1097        }
1098        if new_start == current_start && new_end == current_end {
1099            // Hit both clips; no observable change.
1100            return;
1101        }
1102
1103        state.excerpts[idx].start_line = new_start as u32;
1104        state.excerpts[idx].end_line = new_end as u32;
1105
1106        let snapshot = compose_snapshot(
1107            self.inner.id,
1108            &state.sources,
1109            &state.excerpts,
1110            state.selections.clone(),
1111        );
1112        let translation = RowTranslation::build(&state.excerpts);
1113        drop(state);
1114        self.inner.snapshot_cell.store(snapshot);
1115        self.inner.row_translation.store(Arc::new(translation));
1116    }
1117
1118    /// M.2.b.2 (2026-06-01): append excerpts to the end of the
1119    /// view. Used by async providers streaming batches of
1120    /// results (project-search, lsp-references, etc.). Any
1121    /// excerpts whose source isn't present are silently
1122    /// skipped (log + drop). Recomposes + publishes after the
1123    /// mutation.
1124    pub fn append_excerpts(&self, excerpts: Vec<Excerpt>) {
1125        if excerpts.is_empty() {
1126            return;
1127        }
1128        let mut state = self.lock_state();
1129        // MH.B1 (2026-06-19): collect the excerpts actually added
1130        // this call (mirroring the skip-if-missing-source filter)
1131        // so we can compose + translate ONLY the batch, not the
1132        // accumulated total.
1133        let mut added: Vec<Excerpt> = Vec::with_capacity(excerpts.len());
1134        for ex in excerpts {
1135            if !state.sources.contains_key(&ex.source) {
1136                // Silently drop — the provider is responsible
1137                // for adding the source first via `add_source`
1138                // if it's a new file. M.6 SearchProvider does
1139                // this in its scan task.
1140                continue;
1141            }
1142            state.excerpts.push(ex.clone());
1143            added.push(ex);
1144        }
1145        if added.is_empty() {
1146            // Every excerpt was dropped (unknown source). Nothing
1147            // to compose or publish — leave the view untouched.
1148            return;
1149        }
1150        // MH.B1 (2026-06-19): compose ONLY the batch we just added.
1151        // `compose_text_from_sources` carries no cross-excerpt
1152        // state — each line is pulled independently and given a
1153        // trailing `\n` — so composing the added sub-list yields
1154        // exactly the bytes that sub-list contributes to the full
1155        // composition. Appending those bytes to the END of the
1156        // existing composed_doc is therefore byte-identical to
1157        // `from_text(old_full_text + batch_text)`, at O(batch)
1158        // instead of O(total). Pinned by
1159        // `incremental_append_matches_full_build`.
1160        let batch_text = compose_text_from_sources(&state.sources, &added);
1161        // K.4.7: stamp with monotonic seq — `Document::apply_edit`
1162        // bumps text_version, but starting from a `from_text`-built
1163        // rope (which begins at text_version=0) means an empty view
1164        // streaming its first batch would land at text_version=1
1165        // regardless; we use the inner monotonic publish_seq so the
1166        // MatrixVersion advances on every publish.
1167        let seq = self.inner.publish_seq.fetch_add(1, Ordering::Relaxed) + 1;
1168        let selections = state.selections.clone();
1169        // MH.B1: extend (not rebuild) the row translation by the
1170        // batch's entries. Pure concatenation == byte-identical to
1171        // `RowTranslation::build(&state.excerpts)`.
1172        let mut translation = (*self.inner.row_translation.load_full()).clone();
1173        translation.append(&added);
1174        drop(state);
1175        // MH.B1 (2026-06-02 superseded): append the batch text to
1176        // the END of composed_doc rather than rebuilding it from
1177        // all sources. This APPENDS — it preserves any local edits
1178        // already present in composed_doc (the previous full-rebuild
1179        // here CLOBBERED them). Insert-at-end keeps the local rope
1180        // authoritative + in sync with the growing excerpt set so
1181        // user edits land on a rope reflecting current content.
1182        let snapshot = {
1183            let mut doc = self
1184                .inner
1185                .composed_doc
1186                .lock()
1187                .expect("composed_doc mutex poisoned");
1188            if !batch_text.is_empty() {
1189                // Insert at the very end of the current rope. The
1190                // end Position is derived from the rope's byte
1191                // length so it is correct whether or not the rope
1192                // ends in a trailing newline.
1193                let end_byte = doc.buffer().byte_len() as usize;
1194                let at = doc
1195                    .buffer()
1196                    .byte_to_position(end_byte)
1197                    .expect("end-of-rope position is always in bounds");
1198                // Best-effort: a failed append leaves the rope as-is
1199                // (recoverable — we still publish the extended
1200                // translation + bumped seq). Never panic on this path.
1201                if let Err(err) = doc.apply_edit(Edit::insert(at, batch_text)) {
1202                    tracing::debug!(
1203                        ?err,
1204                        "append_excerpts: composed_doc append failed; rope unchanged"
1205                    );
1206                }
1207            }
1208            let mut snapshot = snapshot_from_composed_doc(&doc, self.inner.id, selections);
1209            snapshot.text_version = seq;
1210            snapshot
1211        };
1212        self.inner.snapshot_cell.store(snapshot);
1213        self.inner.row_translation.store(Arc::new(translation));
1214    }
1215
1216    /// M.2.b.2 (2026-06-01): replace the entire excerpt list +
1217    /// source map atomically. Used by providers reacting to a
1218    /// query / filter change (e.g. user refines a search). The
1219    /// previous excerpts are dropped; the new set is composed
1220    /// + published in one mutation.
1221    pub fn replace_excerpts(
1222        &self,
1223        sources: HashMap<BufferId, Arc<dyn Document>>,
1224        excerpts: Vec<Excerpt>,
1225    ) {
1226        for ex in &excerpts {
1227            if !sources.contains_key(&ex.source) {
1228                // Same skip-and-continue behaviour as append.
1229                // Provider's responsibility to keep sources
1230                // map coherent with excerpts.
1231            }
1232        }
1233        let mut state = self.lock_state();
1234        // SS.2: re-baseline against the NEW source set. Carrying the old
1235        // map forward would leave fingerprints for sources that are gone
1236        // and none for the ones just added — a refresh must not inherit
1237        // a stale baseline.
1238        state.source_fingerprints = sources
1239            .iter()
1240            .filter_map(|(id, src)| fingerprint_source(src).map(|fp| (*id, fp)))
1241            .collect();
1242        state.sources = sources;
1243        state.excerpts = excerpts;
1244        // M.11 (2026-06-02): same rebuild as `append_excerpts` —
1245        // keep composed_doc in sync with the new excerpt set so
1246        // user edits land on a rope reflecting current content.
1247        let composed_text = compose_text_from_sources(&state.sources, &state.excerpts);
1248        let new_composed_doc = lattice_core::Document::from_text(composed_text);
1249        // K.4.7: same monotonic seq stamp as append_excerpts.
1250        let seq = self.inner.publish_seq.fetch_add(1, Ordering::Relaxed) + 1;
1251        let mut snapshot =
1252            snapshot_from_composed_doc(&new_composed_doc, self.inner.id, state.selections.clone());
1253        snapshot.text_version = seq;
1254        let translation = RowTranslation::build(&state.excerpts);
1255        drop(state);
1256        {
1257            let mut doc = self
1258                .inner
1259                .composed_doc
1260                .lock()
1261                .expect("composed_doc mutex poisoned");
1262            *doc = new_composed_doc;
1263        }
1264        self.inner.snapshot_cell.store(snapshot);
1265        self.inner.row_translation.store(Arc::new(translation));
1266    }
1267
1268    /// M.2.b.2 (2026-06-01): add a source buffer to the view's
1269    /// source map. Subsequent `append_excerpts` calls can
1270    /// reference it. Idempotent: re-adding an existing source
1271    /// updates the handle reference (which may have been
1272    /// replaced via slot-replacement upstream).
1273    ///
1274    /// K.4.7 (2026-06-07): if `set_lang_registry` has been called,
1275    /// detect the source's language from its path and create a
1276    /// long-lived `SyntaxHandle` for it. The handle worker runs on
1277    /// the tokio runtime of the caller (the scan task); subsequent
1278    /// reparsing is async and wait-free at read time.
1279    /// SS.2: the recorded on-disk baseline for `source`, if any.
1280    /// `None` for a pathless (synthetic) source, which is never stale.
1281    pub fn source_fingerprint(
1282        &self,
1283        source: BufferId,
1284    ) -> Option<lattice_core::on_disk::OnDiskFingerprint> {
1285        self.lock_state().source_fingerprints.get(&source).cloned()
1286    }
1287
1288    pub fn add_source(&self, id: BufferId, source: Arc<dyn Document>) {
1289        let mut state = self.lock_state();
1290        if let Some(fp) = fingerprint_source(&source) {
1291            state.source_fingerprints.insert(id, fp);
1292        }
1293        state.sources.insert(id, source.clone());
1294        let path = source.path();
1295        tracing::debug!(
1296            buffer = ?id,
1297            path = ?path,
1298            has_lang_registry = self.inner.lang_registry.get().is_some(),
1299            "add_source: checking for syntax handle creation"
1300        );
1301        if self.inner.lang_registry.get().is_some() {
1302            let lang = Lang::detect_from_path(path.as_deref());
1303            tracing::debug!(buffer = ?id, ?lang, "add_source: detected language");
1304            if lang != Lang::Plain {
1305                // AH.1: resolve against the LIVE registry, not the stored
1306                // handle.
1307                //
1308                // `LangRegistry::standard()` *is* `registry::live()` — it
1309                // returns a SNAPSHOT of the process-global ArcSwap. Plugin
1310                // grammars arrive later: `install_plugin_config` RCUs a new
1311                // `LangRegistry` in, which leaves every previously-held `Arc`
1312                // pointing at the value from before the plugin loaded. The
1313                // stored registry is captured at boot, so it is bundled-only
1314                // and can NEVER contain a plugin language.
1315                //
1316                // The symptom was total and silent: every excerpt over a
1317                // plugin-language file — the whole org agenda — got no
1318                // `SyntaxHandle`, so every row painted uncoloured while the
1319                // same file syntax-highlighted perfectly when opened directly.
1320                // `detect_from_path` resolved `.org` correctly, the registry
1321                // was present, and the grammar existed; only the registry
1322                // being asked was the wrong one.
1323                //
1324                // The stored field survives as the host's "is highlighting
1325                // wired" gate (`set_lang_registry` is what turns this on) —
1326                // its VALUE is what could not be trusted, not its presence.
1327                let live = match lattice_syntax::registry::live() {
1328                    Ok(lr) => lr,
1329                    Err(e) => {
1330                        tracing::debug!(buffer = ?id, error = ?e, "add_source: no live registry");
1331                        return;
1332                    }
1333                };
1334                match Syntax::for_language_with_registry(lang, live) {
1335                    Ok(Some(mut syntax)) => {
1336                        let snap = source.snapshot();
1337                        let text = snap.buffer.as_string();
1338                        syntax.parse(&text);
1339                        let handle = self.seed_source_syntax(syntax);
1340                        state.source_syntax.insert(id, Arc::new(handle));
1341                        self.inner
1342                            .excerpt_syntax_gen
1343                            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1344                        tracing::debug!(buffer = ?id, ?lang, "add_source: syntax handle created");
1345                    }
1346                    Ok(None) => {
1347                        tracing::debug!(buffer = ?id, ?lang, "add_source: no grammar registered");
1348                    }
1349                    Err(e) => {
1350                        tracing::debug!(buffer = ?id, ?lang, error = ?e, "add_source: grammar error");
1351                    }
1352                }
1353            }
1354        }
1355    }
1356
1357    /// K.4.7 (2026-06-07): enable per-source syntax highlighting.
1358    /// Called by the host immediately after `create_multibuffer_view`.
1359    /// Subsequent `add_source` calls use the registry to detect the
1360    /// source language and create a `SyntaxHandle` per source.
1361    ///
1362    /// AF.1: how this view's rows group for folding. Declared by the provider
1363    /// that builds the view, because the provider is the only thing that knows
1364    /// what its rows MEAN — see [`FoldGrouping`].
1365    pub fn set_fold_grouping(&self, grouping: FoldGrouping) {
1366        self.inner
1367            .fold_grouping
1368            .store(grouping as u8, std::sync::atomic::Ordering::Relaxed);
1369    }
1370
1371    /// This view's fold grouping. [`FoldGrouping::SourceFile`] unless a
1372    /// provider said otherwise, which is the pre-AF.1 behaviour.
1373    pub fn fold_grouping(&self) -> FoldGrouping {
1374        match self
1375            .inner
1376            .fold_grouping
1377            .load(std::sync::atomic::Ordering::Relaxed)
1378        {
1379            x if x == FoldGrouping::HeaderRuns as u8 => FoldGrouping::HeaderRuns,
1380            _ => FoldGrouping::SourceFile,
1381        }
1382    }
1383
1384    /// Also retroactively creates handles for sources that were already
1385    /// added before this call (the common case: `new(sources, ...)` is
1386    /// called first, then `set_lang_registry` wires highlighting).
1387    pub fn set_lang_registry(&self, lr: Arc<LangRegistry>) {
1388        if self.inner.lang_registry.set(lr).is_err() {
1389            return;
1390        }
1391        // AH.1 (mirrored from `add_source`): the back-fill must resolve
1392        // grammars against the LIVE process-global registry, NOT the `lr`
1393        // just stored. `lr` is a boot snapshot — bundled-only, captured before
1394        // any plugin grammar RCUs itself in — so resolving against it silently
1395        // drops every excerpt over a plugin-language file (the whole org agenda
1396        // painted uncoloured while the same file highlighted fine opened
1397        // directly). This is the path `*problems*` and the references view take
1398        // (all sources handed to `new` up front, then back-filled here), where
1399        // `add_source`'s streaming path — already on the live registry — is
1400        // what project-search uses; the asymmetry was why search highlighted
1401        // and `*problems*` did not. The stored field survives as the "is
1402        // highlighting wired" gate; only its VALUE could not be trusted.
1403        let live = match lattice_syntax::registry::live() {
1404            Ok(reg) => reg,
1405            Err(e) => {
1406                tracing::debug!(error = ?e, "set_lang_registry: no live registry; back-fill skipped");
1407                return;
1408            }
1409        };
1410        let mut state = self.lock_state();
1411        let ids: Vec<(BufferId, Arc<dyn Document>)> = state
1412            .sources
1413            .iter()
1414            .filter(|(id, _)| !state.source_syntax.contains_key(id))
1415            .map(|(id, src)| (*id, src.clone()))
1416            .collect();
1417        let mut added = 0u64;
1418        for (id, source) in ids {
1419            let lang = Lang::detect_from_path(source.path().as_deref());
1420            if lang == Lang::Plain {
1421                continue;
1422            }
1423            if let Ok(Some(mut syntax)) = Syntax::for_language_with_registry(lang, live.clone()) {
1424                let snap = source.snapshot();
1425                let text = snap.buffer.as_string();
1426                syntax.parse(&text);
1427                let handle = self.seed_source_syntax(syntax);
1428                state.source_syntax.insert(id, Arc::new(handle));
1429                added += 1;
1430            }
1431        }
1432        if added > 0 {
1433            self.inner
1434                .excerpt_syntax_gen
1435                .fetch_add(added, std::sync::atomic::Ordering::Relaxed);
1436        }
1437    }
1438
1439    /// The payload the forwarder needs to announce a source change, or `None`
1440    /// before `attach_event_subscriptions` has handed this view a bus.
1441    ///
1442    /// `None` is the correct quiet case, not a failure: with no bus there is
1443    /// no subscriber to tell, and a view that has not been attached yet is not
1444    /// on screen.
1445    fn announce_for(&self, source_id: BufferId) -> Option<AnnounceSource> {
1446        let bus = self
1447            .inner
1448            .subscriptions
1449            .lock()
1450            .ok()
1451            .and_then(|b| b.bus.clone())?;
1452        Some(AnnounceSource {
1453            source_id,
1454            inner: Arc::downgrade(&self.inner),
1455            bus,
1456        })
1457    }
1458
1459    /// Build a source's `SyntaxHandle` with its reparse WAKE already wired.
1460    ///
1461    /// The one constructor both creation sites use, because the wake is the
1462    /// part that gets forgotten. `SyntaxHandle::seeded` passes
1463    /// `on_publish: None`, so a handle built with it parses exactly once and
1464    /// its snapshot is frozen for the life of the view. That is what made a
1465    /// task toggled to DONE keep painting in TODO's colour: the composed text
1466    /// updated (the `DocumentChanged` arm recomposes), the spans did not, and
1467    /// re-rendering — including `<C-l>` — re-read the same frozen snapshot, so
1468    /// the one escape hatch a user has did not work either.
1469    ///
1470    /// `on_publish` does the two things a fresh parse needs:
1471    ///
1472    /// 1. **Bump `excerpt_syntax_gen`.** It is folded into
1473    ///    `MatrixVersion::syntax`, so without it the cells worker sees an
1474    ///    unchanged version and returns `CacheHit` — a correct new snapshot
1475    ///    nobody reads.
1476    /// 2. **Publish `MultibufferExcerptsReady`.** `install`'s
1477    ///    `wake_on_event` turns it into `async_landed`, which is what makes an
1478    ///    async result reach the screen with no keypress in flight. A reparse
1479    ///    that lands while the user is reading their agenda has no next
1480    ///    keystroke to hide behind.
1481    ///
1482    /// Mirrors how the host wires a regular document's handle
1483    /// (`seeded_with_runtime` + a wake that fires `async_landed` and an
1484    /// invalidation event) rather than inventing a second mechanism.
1485    fn seed_source_syntax(&self, syntax: Syntax) -> SyntaxHandle {
1486        let weak = Arc::downgrade(&self.inner);
1487        let view = self.inner.buffer_id;
1488        SyntaxHandle::seeded_with_runtime(
1489            syntax,
1490            lattice_runtime::shared_runtime(),
1491            Some(Arc::new(move || {
1492                let Some(inner) = weak.upgrade() else {
1493                    return;
1494                };
1495                inner
1496                    .excerpt_syntax_gen
1497                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1498                // The bus is stashed by `attach_event_subscriptions`; before
1499                // that there is no one to wake and nothing on screen yet.
1500                let bus = inner.subscriptions.lock().ok().and_then(|b| b.bus.clone());
1501                if let Some(bus) = bus {
1502                    bus.publish_typed(crate::events::MultibufferExcerptsReady { view });
1503                }
1504            })),
1505        )
1506    }
1507
1508    /// K.4.7 (2026-06-08): monotonic version that increments whenever
1509    /// per-source `SyntaxHandle`s are created. Used by `publish_render_state`
1510    /// to invalidate the cells-worker cache when handles are first populated.
1511    pub fn excerpt_syntax_version(&self) -> u64 {
1512        self.inner
1513            .excerpt_syntax_gen
1514            .load(std::sync::atomic::Ordering::Relaxed)
1515    }
1516
1517    /// K.4.7 (2026-06-07): per-excerpt syntax entries for the cells
1518    /// worker. Each entry is `(composed_start, composed_end,
1519    /// source_start, handle)` where `composed_start`/`composed_end`
1520    /// are inclusive row bounds in the composed snapshot (0-indexed)
1521    /// and `source_start` is the first source row mapped to
1522    /// `composed_start`. Only excerpts with a `SyntaxHandle` are
1523    /// included.
1524    pub fn excerpt_syntax_entries(&self) -> Vec<(u32, u32, u32, Arc<SyntaxHandle>)> {
1525        let state = self.lock_state();
1526        let mut entries = Vec::new();
1527        let mut composed_row = 0u32;
1528        for ex in &state.excerpts {
1529            let line_count = ex.end_line.saturating_sub(ex.start_line) + 1;
1530            if let Some(handle) = state.source_syntax.get(&ex.source) {
1531                let composed_end = composed_row + line_count - 1;
1532                entries.push((composed_row, composed_end, ex.start_line, handle.clone()));
1533            }
1534            composed_row += line_count;
1535        }
1536        entries
1537    }
1538
1539    /// Recompose the snapshot from current source state.
1540    /// Rebuilds the composed buffer + row translation, then
1541    /// publishes via `ArcSwap::store`.
1542    ///
1543    /// M.1 shipped this as a manual API; M.4 wires automatic
1544    /// invocation via source-edit event subscriptions. M.2.b.2
1545    /// kept the public surface stable but rerouted reads through
1546    /// the Mutex.
1547    pub fn recompose(&self) {
1548        let state = self.lock_state();
1549        let new_snapshot = compose_snapshot(
1550            self.inner.id,
1551            &state.sources,
1552            &state.excerpts,
1553            state.selections.clone(),
1554        );
1555        let new_translation = RowTranslation::build(&state.excerpts);
1556        drop(state);
1557        self.inner.snapshot_cell.store(new_snapshot);
1558        self.inner.row_translation.store(Arc::new(new_translation));
1559    }
1560
1561    fn lock_state(&self) -> std::sync::MutexGuard<'_, MultibufferState> {
1562        self.inner
1563            .state
1564            .lock()
1565            .expect("MultibufferInner state mutex poisoned")
1566    }
1567
1568    /// M.4 (2026-06-01): the view's current headerline status.
1569    /// Lock-free read.
1570    pub fn headerline(&self) -> Arc<HeaderlineStatus> {
1571        self.inner.headerline.load_full()
1572    }
1573
1574    /// M.4 (2026-06-01): set the view's headerline status.
1575    /// Publishes `MultibufferHeaderlineChanged` on the event bus
1576    /// the handle was attached to (no-op if
1577    /// [`Self::attach_event_subscriptions`] hasn't been called —
1578    /// the status still updates locally).
1579    pub fn set_headerline(&self, status: HeaderlineStatus) {
1580        let bus = self
1581            .inner
1582            .subscriptions
1583            .lock()
1584            .ok()
1585            .and_then(|book| book.bus.clone());
1586        self.inner.headerline.store(Arc::new(status.clone()));
1587        self.inner
1588            .headerline_version
1589            .fetch_add(1, Ordering::Release);
1590        if let Some(bus) = bus {
1591            bus.publish_typed(MultibufferHeaderlineChanged {
1592                view: self.inner.buffer_id,
1593                status,
1594            });
1595        }
1596    }
1597
1598    /// M.4 (2026-06-01): subscribe the view to its sources'
1599    /// `DocumentChanged` / `DocumentClosed` events. On a source
1600    /// change, the view auto-recomposes; on a source close, the
1601    /// view publishes [`MultibufferSourceClosed`] and removes the
1602    /// source from its internal map.
1603    ///
1604    /// Subscriptions live until the handle drops — `MultibufferInner::drop`
1605    /// unsubscribes via the bookkeeping. The spawned forwarder
1606    /// task holds a `Weak<MultibufferInner>` so it exits cleanly
1607    /// once the handle is dropped.
1608    ///
1609    /// Idempotent: re-calling on an already-attached handle is a
1610    /// no-op. Requires a current tokio runtime context (the
1611    /// forwarder task is spawned via `tokio::spawn`).
1612    pub fn attach_event_subscriptions(&self, events: &Arc<lattice_runtime::EventBus>) {
1613        let mut book = self
1614            .inner
1615            .subscriptions
1616            .lock()
1617            .expect("subscriptions mutex poisoned");
1618        if book.bus.is_some() {
1619            // Already attached.
1620            return;
1621        }
1622        // Drop into the no-tokio-runtime case gracefully: the
1623        // event-bus subscribe still works, but the forwarder
1624        // task can't spawn. Match `register_multibuffer_modes`'s
1625        // shape.
1626        if tokio::runtime::Handle::try_current().is_err() {
1627            tracing::debug!(
1628                "MultibufferDocumentHandle::attach_event_subscriptions: no tokio runtime; \
1629                 skipping forwarder task wiring (expected in test paths)"
1630            );
1631            // Still stash the bus so set_headerline can publish.
1632            book.bus = Some(events.clone());
1633            return;
1634        }
1635
1636        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<lattice_protocol::Event>();
1637        let sub_id = events.subscribe(
1638            lattice_runtime::EventFilter::kinds(vec![
1639                lattice_protocol::EventKind::DocumentChanged,
1640                lattice_protocol::EventKind::DocumentClosed,
1641            ]),
1642            lattice_runtime::SubscriptionTarget::Channel(tx),
1643        );
1644        book.ids.push(sub_id);
1645        book.bus = Some(events.clone());
1646        drop(book);
1647
1648        let weak_inner = Arc::downgrade(&self.inner);
1649        let events_for_task = events.clone();
1650        tokio::spawn(async move {
1651            while let Some(event) = rx.recv().await {
1652                let Some(inner) = weak_inner.upgrade() else {
1653                    break;
1654                };
1655                match event {
1656                    lattice_protocol::Event::DocumentChanged {
1657                        id, edits, version, ..
1658                    } => {
1659                        if let Some(source_id) = source_buffer_for_document_id(&inner, id) {
1660                            // Is this our OWN edit coming back? The composed
1661                            // edit already moved the rope and the anchors, so
1662                            // treating the echo as an outside change would
1663                            // slide every excerpt below the edited row a
1664                            // second time.
1665                            let echo = take_self_forwarded(&inner, source_id, version);
1666                            // M.4.1: slide excerpts whose start
1667                            // row sits strictly below the edit's
1668                            // original end. Edits that overlap
1669                            // an excerpt's range, or sit below
1670                            // it, leave excerpts alone — the
1671                            // recompose picks up the new
1672                            // content for in-excerpt edits.
1673                            if !echo {
1674                                slide_anchors_for_source(&inner, source_id, &edits);
1675                                recompose_inner(&inner);
1676                            }
1677                            // Reparse on BOTH paths. The echo is the case that
1678                            // matters most — an edit made THROUGH the view is
1679                            // how a task gets toggled to DONE — and it is the
1680                            // one where the composed text is already right, so
1681                            // skipping the reparse here is precisely the "new
1682                            // text, old colours" bug.
1683                            reparse_source_syntax(&inner, source_id);
1684                            // PD.7c: the view has recomposed correctly;
1685                            // what may now be wrong is whatever the
1686                            // PROVIDER derived from this source's
1687                            // content. Tell it, with the translation
1688                            // already done — the source map lives here.
1689                            events_for_task.publish_typed(MultibufferSourceEdited {
1690                                view: inner.buffer_id,
1691                                source: source_id,
1692                            });
1693                        }
1694                    }
1695                    lattice_protocol::Event::DocumentClosed { id } => {
1696                        if let Some(source_id) = source_buffer_for_document_id(&inner, id) {
1697                            // Remove the source from our map.
1698                            if let Ok(mut state) = inner.state.lock() {
1699                                state.sources.remove(&source_id);
1700                            }
1701                            // Publish the typed event so providers
1702                            // pick up the close + choose policy.
1703                            events_for_task.publish_typed(MultibufferSourceClosed {
1704                                view: inner.buffer_id,
1705                                source: source_id,
1706                            });
1707                            // Recompose: removed source's
1708                            // excerpts will render empty rows
1709                            // (no entries in the source map).
1710                            recompose_inner(&inner);
1711                        }
1712                    }
1713                    _ => {}
1714                }
1715            }
1716        });
1717    }
1718}
1719
1720/// Translate a [`DocumentId`] (carried by `Event::DocumentChanged`
1721/// / `Event::DocumentClosed`) to the `BufferId` key in our source
1722/// map, if the document is one of our sources.
1723fn source_buffer_for_document_id(
1724    inner: &Arc<MultibufferInner>,
1725    document_id: DocumentId,
1726) -> Option<BufferId> {
1727    let state = inner.state.lock().ok()?;
1728    state
1729        .sources
1730        .iter()
1731        .find(|(_, h)| h.id() == document_id)
1732        .map(|(id, _)| *id)
1733}
1734
1735/// M.4.1 (2026-06-01): walk the `AppliedEdit`s from a source's
1736/// `DocumentChanged` event and slide excerpts of that source
1737/// whose `start_line` sits strictly below the edit's original
1738/// end row. Edits overlapping or below the excerpt leave it
1739/// alone — the recompose picks up new content for in-excerpt
1740/// edits; below-edits don't affect the excerpt's position.
1741///
1742/// Behaviourally equivalent to anchor tracking in the
1743/// linewise case (which is what excerpts care about — they're
1744/// line-bounded). A first-class `Anchor` primitive (line + col +
1745/// generation) can land later if column-precise tracking
1746/// proves load-bearing (none of the M.4.1 worked examples
1747/// need it).
1748///
1749/// Conservative bias: edits whose original_range end is AT or
1750/// ABOVE the excerpt's start_line don't slide. Erring against
1751/// false-positive slides keeps the user's mental model stable
1752/// when an edit straddles an excerpt boundary.
1753fn slide_anchors_for_source(
1754    inner: &Arc<MultibufferInner>,
1755    source: BufferId,
1756    edits: &[lattice_protocol::event::AppliedEdit],
1757) {
1758    if edits.is_empty() {
1759        return;
1760    }
1761    let Ok(mut state) = inner.state.lock() else {
1762        return;
1763    };
1764    for edit in edits {
1765        let old_end_row = edit.original_range.end.line;
1766        let new_end_row = edit.inserted_range.end.line;
1767        let row_delta = (new_end_row as i64) - (old_end_row as i64);
1768        if row_delta == 0 {
1769            continue;
1770        }
1771        for excerpt in state.excerpts.iter_mut() {
1772            if excerpt.source != source {
1773                continue;
1774            }
1775            if old_end_row < excerpt.start_line {
1776                let new_start = (excerpt.start_line as i64).saturating_add(row_delta).max(0);
1777                let new_end = (excerpt.end_line as i64).saturating_add(row_delta).max(0);
1778                excerpt.start_line = new_start as u32;
1779                excerpt.end_line = new_end as u32;
1780            }
1781        }
1782    }
1783}
1784
1785/// Was this `DocumentChanged` the view's OWN forwarded edit coming back?
1786///
1787/// Consumes the record when it matches, so the answer is true exactly once per
1788/// forwarded edit: a later outside change at a higher version is a genuine
1789/// outside change and must slide anchors normally.
1790///
1791/// Compares `>=` rather than `==` because several composed edits can be in
1792/// flight at once (`apply_edit_batch`), and the newest recorded version is the
1793/// tip of what this view produced. A stale record below the incoming version
1794/// cannot be an echo of it.
1795fn take_self_forwarded(inner: &Arc<MultibufferInner>, source_id: BufferId, version: u64) -> bool {
1796    let Ok(mut state) = inner.state.lock() else {
1797        return false;
1798    };
1799    match state.self_forwarded_versions.get(&source_id).copied() {
1800        Some(recorded) if recorded >= version => {
1801            if recorded == version {
1802                state.self_forwarded_versions.remove(&source_id);
1803            }
1804            true
1805        }
1806        _ => false,
1807    }
1808}
1809
1810/// Ask a source's `SyntaxHandle` to reparse, after that source's text changed.
1811///
1812/// Peer of [`recompose_inner`], and called beside it for the same reason: a
1813/// `DocumentChanged` makes BOTH the composed text and the source's spans
1814/// stale, and recomposing only the first is what leaves new text under old
1815/// colours.
1816///
1817/// **Full reparse — `edits` is empty on purpose.** The worker reads empty
1818/// edits as "reparse from scratch", which is always correct. The incremental
1819/// path would need the event's `AppliedEdit`s translated into tree-sitter
1820/// deltas against the version the handle's cached tree is actually at, and a
1821/// delta applied to the wrong baseline corrupts the tree silently — colours
1822/// that are wrong until something forces a full parse, which is the bug class
1823/// this function exists to close, reintroduced one layer down. Sources are
1824/// agenda-sized files and the parse runs on the handle's own blocking pool,
1825/// off both the UI thread and this event task; if it ever shows up in a
1826/// profile the deltas can be threaded through then, with a test that pins the
1827/// baseline.
1828fn reparse_source_syntax(inner: &Arc<MultibufferInner>, source_id: BufferId) {
1829    let Ok(state) = inner.state.lock() else {
1830        return;
1831    };
1832    let (Some(handle), Some(source)) = (
1833        state.source_syntax.get(&source_id).cloned(),
1834        state.sources.get(&source_id).cloned(),
1835    ) else {
1836        return;
1837    };
1838    drop(state);
1839    let snap = source.snapshot();
1840    let from = handle.with_snapshot(|s| s.text_version());
1841    handle.request_reparse(from, snap.text_version, snap.buffer.clone(), Vec::new());
1842}
1843
1844/// Recompose an Inner — same shape as `MultibufferDocumentHandle::recompose`
1845/// but works against an `Arc<MultibufferInner>` so the forwarder
1846/// task can call it without holding a strong handle reference.
1847fn recompose_inner(inner: &Arc<MultibufferInner>) {
1848    let Ok(state) = inner.state.lock() else {
1849        return;
1850    };
1851    let new_snapshot = compose_snapshot(
1852        inner.id,
1853        &state.sources,
1854        &state.excerpts,
1855        state.selections.clone(),
1856    );
1857    let new_translation = RowTranslation::build(&state.excerpts);
1858    drop(state);
1859    inner.snapshot_cell.store(new_snapshot);
1860    inner.row_translation.store(Arc::new(new_translation));
1861}
1862
1863impl Document for MultibufferDocumentHandle {
1864    fn snapshot(&self) -> Arc<DocumentSnapshot> {
1865        self.inner.snapshot_cell.load()
1866    }
1867
1868    fn snapshot_cache(&self) -> SnapshotCache {
1869        SnapshotCache::new(self.inner.snapshot_cell.clone())
1870    }
1871
1872    /// M.3 (2026-06-01): translate the composed-coordinate `edit`
1873    /// to its source-coordinate equivalent and forward to the
1874    /// source document's `apply_edit`.
1875    ///
1876    /// The returned `Pending<AppliedEdit>` carries the source's
1877    /// AppliedEdit — ranges + delta in source coordinates. Caller
1878    /// recompose()s (M.3) or auto-subscribes (M.4) to reflect.
1879    ///
1880    /// Boundary clipping (architecture §4): if `edit.range.end`
1881    /// extends past the start excerpt's last composed row, the
1882    /// end is clipped to the end-of-line of the excerpt's last
1883    /// source row. The edit's contribution to subsequent
1884    /// excerpts (and their sources) is dropped — matching Zed's
1885    /// "edits stay in the excerpt" rule. Out-of-range edits
1886    /// (cursor past view end, no excerpts) return
1887    /// `RuntimeError::ReadOnly`.
1888    fn apply_edit(&self, edit: Edit) -> Pending<AppliedEdit> {
1889        // M.11 (2026-06-02): byte-identical shape to
1890        // `RopeDocumentHandle::apply_edit` — mutate the LOCAL
1891        // composed rope, publish the new snapshot, return
1892        // synchronously. The multibuffer is now a true buffer at
1893        // the substrate level: insert-mode keystrokes, motions,
1894        // operators, undo/redo all flow through the same
1895        // `Document` code path as a regular `Document`. No
1896        // cross-actor round-trip, no Pending::spawn, no race
1897        // with M.4 forwarder. Source actors catch up async via
1898        // the source-forwarder task spawned at construction.
1899        //
1900        // Look up where the edit lands so the source forwarder
1901        // can translate to source coords. The lookup uses the
1902        // PRE-edit row translation (the only one that still
1903        // describes the source position the cursor is on); after
1904        // the edit the composed_doc's contents diverge from the
1905        // source until the forwarder catches up, but the row
1906        // translation continues to describe excerpt boundaries
1907        // in the composed view (one row per excerpt today).
1908        let state = self.lock_state();
1909        let source_forward = resolve_edit_target(&state, edit.range.start).map(|target| {
1910            let source_edit = build_source_edit(&target, &edit);
1911            SourceForwardMsg::Edit {
1912                source_handle: target.source_handle.clone(),
1913                source_edit,
1914                announce: self.announce_for(target.source_id),
1915            }
1916        });
1917        drop(state);
1918
1919        // Mutate the local composed_doc synchronously.
1920        let applied = {
1921            let mut doc = self
1922                .inner
1923                .composed_doc
1924                .lock()
1925                .expect("composed_doc mutex poisoned");
1926            match doc.apply_edit(edit) {
1927                Ok(applied) => {
1928                    // Publish the post-edit snapshot before
1929                    // releasing the lock so consumers see a
1930                    // consistent (rope, version) pair.
1931                    let selections = self.lock_state().selections.clone();
1932                    let snap = snapshot_from_composed_doc(&doc, self.inner.id, selections);
1933                    self.inner.snapshot_cell.store(snap);
1934                    applied
1935                }
1936                Err(e) => return Pending::ready(Err(RuntimeError::Core(e))),
1937            }
1938        };
1939
1940        // Fire-and-forget source forwarding. `try_send` so a full
1941        // unbounded mpsc (effectively impossible) doesn't block;
1942        // the forwarder task drains FIFO order so source actors
1943        // see edits in the same order the user typed them. If
1944        // construction had no tokio runtime (test paths), the rx
1945        // half was dropped and try_send returns Err — that's
1946        // expected, edits stay local-only.
1947        if let Some(msg) = source_forward {
1948            let _ = self.inner.source_forward_tx.send(msg);
1949        }
1950
1951        Pending::ready(Ok(applied))
1952    }
1953
1954    /// M.3 (2026-06-01): translate + forward each edit to its
1955    /// source. The batch is serialised through `apply_edit`
1956    /// per-edit and combined via `Pending::spawn` so the
1957    /// returned `Pending` resolves asynchronously without
1958    /// blocking the runtime. Multi-source batches dispatch
1959    /// each sub-edit sequentially; per-edit parallelism is a
1960    /// later refinement once a consumer needs it.
1961    fn apply_edit_batch(&self, edits: Vec<Edit>) -> Pending<Vec<AppliedEdit>> {
1962        Pending::ready(self.apply_edit_batch_sync(edits))
1963    }
1964
1965    /// MU.1: undo the composed view **and carry it to the sources**.
1966    ///
1967    /// ## Why this is not `source.undo()`
1968    ///
1969    /// The obvious shape — fan out and pop each source's undo stack — is
1970    /// wrong twice over, and the code carried both bugs in turn.
1971    ///
1972    /// It is wrong on *correctness*: a source's undo stack is its own. If a
1973    /// third pane edited that file since, its most-recent entry is the third
1974    /// pane's edit, so `u` in the multibuffer would silently roll back
1975    /// somebody else's work. The old comment here called that "v1 atomicity"
1976    /// and queued transaction tracking for later; that is the later.
1977    ///
1978    /// It was also wrong on *liveness*: the pre-M.11 version awaited each
1979    /// source and deadlocked the editor actor. M.11 fixed it by dropping the
1980    /// fan-out entirely, which left `u` looking like it worked while the file
1981    /// on disk kept the change — a visual undo over a real edit, which is the
1982    /// worse failure of the two because nothing on screen says so.
1983    ///
1984    /// ## What it does instead
1985    ///
1986    /// The undo's OWN result is the transaction record. `composed_doc.undo()`
1987    /// returns `AppliedEdit`s describing exactly what changed in composed
1988    /// coordinates — `original_range` and `inserted_text` — so each one is
1989    /// replayed at its source through the same `resolve_edit_target` +
1990    /// `build_source_edit` translation the forward path uses.
1991    ///
1992    /// Nothing is guessed and nothing is stored: the record is derived from
1993    /// the operation that just happened, so it cannot drift from it, and a
1994    /// concurrent edit in another pane is untouched because we address a
1995    /// range rather than pop a stack.
1996    fn undo(&self) -> Pending<Vec<AppliedEdit>> {
1997        let applied = {
1998            let mut doc = self
1999                .inner
2000                .composed_doc
2001                .lock()
2002                .expect("composed_doc mutex poisoned");
2003            match doc.undo() {
2004                Ok(applied) => {
2005                    let selections = self.lock_state().selections.clone();
2006                    let snap = snapshot_from_composed_doc(&doc, self.inner.id, selections);
2007                    self.inner.snapshot_cell.store(snap);
2008                    applied
2009                }
2010                Err(e) => return Pending::ready(Err(RuntimeError::Core(e))),
2011            }
2012        };
2013        // AFTER the composed_doc lock is released, like `apply_edit` — the
2014        // forwarding takes the state lock, and taking it under the doc lock
2015        // would invent a lock order this file otherwise does not have.
2016        self.forward_applied_to_sources(&applied);
2017        Pending::ready(Ok(applied))
2018    }
2019
2020    fn redo(&self) -> Pending<Vec<AppliedEdit>> {
2021        let applied = {
2022            let mut doc = self
2023                .inner
2024                .composed_doc
2025                .lock()
2026                .expect("composed_doc mutex poisoned");
2027            match doc.redo() {
2028                Ok(applied) => {
2029                    let selections = self.lock_state().selections.clone();
2030                    let snap = snapshot_from_composed_doc(&doc, self.inner.id, selections);
2031                    self.inner.snapshot_cell.store(snap);
2032                    applied
2033                }
2034                Err(e) => return Pending::ready(Err(RuntimeError::Core(e))),
2035            }
2036        };
2037        self.forward_applied_to_sources(&applied);
2038        Pending::ready(Ok(applied))
2039    }
2040
2041    fn save(&self) -> Pending<std::path::PathBuf> {
2042        // 2026-06-10: save every dirty source back to disk. Generic
2043        // for ALL multibuffer views (narrow, project-search, future
2044        // diff / references): the host's `save_blocking` calls this
2045        // `Document::save` uniformly, so `:w` persists the underlying
2046        // files with no kind-branch.
2047        //
2048        // Edits reach sources ASYNC via the source-forwarder, so we
2049        // FLUSH it first — a barrier sent through the same FIFO
2050        // channel guarantees every queued edit has been applied to
2051        // (and awaited on) its source actor before we read + save the
2052        // sources. Without the flush, `:w` races the forwarder and
2053        // could persist a source missing the user's last keystrokes.
2054        // SS.3: carry each source's baseline alongside it so the write
2055        // can be refused if the file moved underneath the view.
2056        let sources: Vec<(
2057            Arc<dyn Document>,
2058            Option<lattice_core::on_disk::OnDiskFingerprint>,
2059        )> = {
2060            let state = self.lock_state();
2061            state
2062                .sources
2063                .iter()
2064                .map(|(id, src)| (src.clone(), state.source_fingerprints.get(id).cloned()))
2065                .collect()
2066        };
2067        let forward_tx = self.inner.source_forward_tx.clone();
2068        Pending::spawn(async move {
2069            // Flush barrier. Best-effort: if the forwarder is gone the
2070            // send fails and we fall through (sources are as current
2071            // as they can be); if `done` is dropped the await errors
2072            // and we likewise proceed.
2073            let (done_tx, done_rx) = tokio::sync::oneshot::channel();
2074            if forward_tx
2075                .send(SourceForwardMsg::Flush { done: done_tx })
2076                .is_ok()
2077            {
2078                let _ = done_rx.await;
2079            }
2080
2081            // Save each dirty source; report the first saved path for
2082            // the `:w` echo. A view whose sources are all clean reports
2083            // the first source's path as a no-op success (matching
2084            // vim's `:w` on an unmodified buffer); an empty view (no
2085            // sources) is `ReadOnly`.
2086            let mut saved_path: Option<std::path::PathBuf> = None;
2087            let mut fallback_path: Option<std::path::PathBuf> = None;
2088            let mut last_err: Option<RuntimeError> = None;
2089            let mut skipped: Vec<std::path::PathBuf> = Vec::new();
2090            for (source, baseline) in &sources {
2091                if fallback_path.is_none() {
2092                    fallback_path = source.path();
2093                }
2094                if !source.dirty() {
2095                    continue;
2096                }
2097                // SS.3: refuse a source whose file changed on disk after
2098                // the view snapshotted it. Writing it would silently
2099                // discard that external change — the whole reason this
2100                // guard exists.
2101                //
2102                // Refuse the SOURCE, not the save: a 30-file view must
2103                // not fail wholesale because one file moved, and a save
2104                // that fails wholesale teaches a `:w!` habit that
2105                // discards exactly what is being protected.
2106                if let (Some(path), Some(baseline)) = (source.path(), baseline.as_ref())
2107                    && is_stale_on_disk(&path, baseline)
2108                {
2109                    tracing::warn!(
2110                        path = %path.display(),
2111                        "multibuffer save: source changed on disk since the view loaded it; \
2112                         refusing to overwrite"
2113                    );
2114                    skipped.push(path);
2115                    continue;
2116                }
2117                match source.save().await {
2118                    Ok(path) => {
2119                        saved_path.get_or_insert(path);
2120                    }
2121                    Err(e) => {
2122                        last_err = Some(e);
2123                    }
2124                }
2125            }
2126            if let Some(e) = last_err {
2127                return Err(e);
2128            }
2129            if !skipped.is_empty() {
2130                // Surface WHICH files, not a count: the user has to go
2131                // look at them, and the recovery (refresh the view —
2132                // `gr`, `:copen`, `:search`) re-reads from disk. The
2133                // other sources already persisted above; this is a
2134                // partial-success report.
2135                return Err(RuntimeError::SourcesChangedOnDisk { paths: skipped });
2136            }
2137            saved_path.or(fallback_path).ok_or(RuntimeError::ReadOnly)
2138        })
2139    }
2140
2141    fn save_as(&self, _path: std::path::PathBuf) -> Pending<()> {
2142        Pending::ready(Err(RuntimeError::ReadOnly))
2143    }
2144
2145    fn set_selections(&self, selections: SelectionSet) -> Pending<()> {
2146        // K.4.5 (2026-06-02): selections ARE view-owned in the
2147        // composed coordinate space (M.3 design — they don't
2148        // propagate to sources). Prior shape returned
2149        // `Err(ReadOnly)` which left the snapshot's selections
2150        // at `SelectionSet::default()`, breaking Visual-mode
2151        // highlight painting on multibuffer views
2152        // (`Editor::visual_selection_range` reads
2153        // `self.document.selections().primary()` uniformly
2154        // across BufferKinds — the right fix is for the
2155        // Document impl to honour the call, not for callers
2156        // to special-case multibuffer).
2157        //
2158        // Store the new selection set in `state.selections`
2159        // and rebuild the snapshot so the next snapshot read
2160        // sees the updated selections. Synchronous (Mutex-
2161        // routed write + ArcSwap publish) so
2162        // `set_selections_blocking` callers see the change
2163        // immediately. See [[feedback_buffers_no_special_case]].
2164        let selections = Arc::new(selections);
2165        let snapshot = {
2166            let mut state = self.lock_state();
2167            state.selections = Arc::clone(&selections);
2168            compose_snapshot(self.inner.id, &state.sources, &state.excerpts, selections)
2169        };
2170        self.inner.snapshot_cell.store(snapshot);
2171        Pending::ready(Ok(()))
2172    }
2173
2174    /// K.4.6 follow-up (2026-06-02): publish the composed→source
2175    /// row map so the gutter can show original file line numbers
2176    /// (429, 430, 432, …) instead of composed-row indices
2177    /// (0, 1, 2, …). Walks the published `RowTranslation` once
2178    /// per call; cheap (typical N = hundreds-to-thousands; called
2179    /// once per render-state publish, NOT per keystroke).
2180    fn display_line_numbers(&self) -> Option<Arc<[u32]>> {
2181        let translation = self.inner.row_translation.load_full();
2182        let rows: Vec<u32> = translation
2183            .entries
2184            .iter()
2185            .map(|e| match e {
2186                RowEntry::Excerpt { source_row, .. } => *source_row,
2187            })
2188            .collect();
2189        Some(Arc::from(rows.into_boxed_slice()))
2190    }
2191
2192    // K.4.7 (2026-06-08): mode owns its highlighting surface. The host's
2193    // `publish_render_state` calls these uniformly on every document; no
2194    // `BufferKind` branch needed there.
2195    fn excerpt_highlights(&self) -> Vec<lattice_cells::ExcerptHighlight> {
2196        let state = self.lock_state();
2197        let mut out = Vec::new();
2198        let mut composed_row = 0u32;
2199        for ex in &state.excerpts {
2200            let line_count = ex.end_line.saturating_sub(ex.start_line) + 1;
2201            if let Some(handle) = state.source_syntax.get(&ex.source) {
2202                out.push(lattice_cells::ExcerptHighlight {
2203                    composed_start: composed_row,
2204                    composed_end: composed_row + line_count - 1,
2205                    source_start: ex.start_line,
2206                    highlighter: Arc::clone(handle) as Arc<dyn lattice_cells::ExcerptHighlighter>,
2207                    // OA.7b: the row's own grammar, so conceal can resolve
2208                    // per excerpt the way highlighting already does.
2209                    lang: Some(handle.lang().name()),
2210                });
2211            }
2212            composed_row += line_count;
2213        }
2214        out
2215    }
2216
2217    fn excerpt_syntax_version(&self) -> u64 {
2218        self.inner
2219            .excerpt_syntax_gen
2220            .load(std::sync::atomic::Ordering::Relaxed)
2221    }
2222
2223    fn dispatch_with_cancel(
2224        &self,
2225        invocation: CommandInvocation,
2226        cursor: Position,
2227        cancel: CancellationToken,
2228    ) -> Pending<Effect> {
2229        self.dispatch_composed(
2230            invocation,
2231            cursor,
2232            cancel,
2233            lattice_runtime::DispatchEnv::default(),
2234        )
2235    }
2236
2237    /// IN.0: the multibuffer overrides this too, purely to forward
2238    /// `env.indent` to `>` / `<`.
2239    ///
2240    /// It still ignores `env.scope_resolver` -- it builds its own
2241    /// composed↔source resolver in `dispatch_composed` (N.1.5), which
2242    /// is why the env was ignored wholesale before. But `indent` is a
2243    /// plain resolved value with nothing multibuffer-specific about it,
2244    /// and dropping it would mean `>` obeys `:setlocal shiftwidth=2` in
2245    /// a document buffer and silently ignores it in a multibuffer --
2246    /// kind-specific behaviour in a buffer, which is the thing
2247    /// `multibuffer_is_a_regular_buffer.rs` exists to prevent.
2248    fn dispatch_with_env(
2249        &self,
2250        invocation: CommandInvocation,
2251        cursor: Position,
2252        cancel: CancellationToken,
2253        env: lattice_runtime::DispatchEnv,
2254    ) -> Pending<Effect> {
2255        // VM.3c: `last_find` is forwarded, unlike the composed-coordinate
2256        // fields below. It needs no source mapping — `;` searches the line in
2257        // front of the user, and in a composed view that line IS the composed
2258        // one — so defaulting it to `None` would make `;` dead here for no
2259        // reason other than the field being new.
2260        // VM.3i: so is the fold resolver. The host's folds for a composed
2261        // view are composed-view lines, which are the lines `zj` walks here.
2262        // VM.3d-2: and so is the last search, for `n` / `N`: in a composed view
2263        // the text `n` searches is the composed text.
2264        self.dispatch_composed(invocation, cursor, cancel, env)
2265    }
2266}
2267
2268impl MultibufferDocumentHandle {
2269    /// The batch-apply body, synchronous.
2270    ///
2271    /// M.11 (2026-06-02): local mutation per edit, sync source-forward
2272    /// enqueue, return synchronously. No `Pending::spawn`, no
2273    /// cross-actor round-trip.
2274    ///
2275    /// Factored out of [`Document::apply_edit_batch`] so grammar
2276    /// dispatch can reach it too — a `Pending` is the wrong shape for a
2277    /// caller that is already inside the synchronous dispatch path and
2278    /// would otherwise have to block on a future it knows is ready.
2279    fn apply_edit_batch_sync(&self, edits: Vec<Edit>) -> Result<Vec<AppliedEdit>, RuntimeError> {
2280        let mut applied_results = Vec::with_capacity(edits.len());
2281        let mut forwards = Vec::with_capacity(edits.len());
2282
2283        for edit in edits {
2284            // Pre-resolve source forward target before mutating
2285            // (the row translation uses composed coords valid
2286            // before this edit lands).
2287            let state = self.lock_state();
2288            let source_forward = resolve_edit_target(&state, edit.range.start).map(|target| {
2289                let source_edit = build_source_edit(&target, &edit);
2290                SourceForwardMsg::Edit {
2291                    source_handle: target.source_handle.clone(),
2292                    source_edit,
2293                    announce: self.announce_for(target.source_id),
2294                }
2295            });
2296            drop(state);
2297
2298            let mut doc = self
2299                .inner
2300                .composed_doc
2301                .lock()
2302                .expect("composed_doc mutex poisoned");
2303            match doc.apply_edit(edit) {
2304                Ok(applied) => {
2305                    let selections = self.lock_state().selections.clone();
2306                    let snap = snapshot_from_composed_doc(&doc, self.inner.id, selections);
2307                    self.inner.snapshot_cell.store(snap);
2308                    applied_results.push(applied);
2309                    if let Some(msg) = source_forward {
2310                        forwards.push(msg);
2311                    }
2312                }
2313                Err(e) => return Err(RuntimeError::Core(e)),
2314            }
2315        }
2316
2317        for msg in forwards {
2318            let _ = self.inner.source_forward_tx.send(msg);
2319        }
2320
2321        Ok(applied_results)
2322    }
2323
2324    /// K.4.11-fix: land the edits a grammar operator produced.
2325    ///
2326    /// `dispatch_composed` runs the grammar against a **scratch**
2327    /// `Document` cloned from the composed snapshot — cheap, because
2328    /// `Buffer::clone` is an `Arc`-backed rope clone, and necessary,
2329    /// because `execute_with_env` wants `&mut Document` while the real
2330    /// composed doc lives behind a mutex the dispatch also reads through.
2331    /// The scratch is then dropped.
2332    ///
2333    /// That is fine for a motion, which only reports a cursor, and wrong
2334    /// for an operator, which reports edits it has already made — to the
2335    /// throwaway. The host's `Effect::Edits` arm (`handle_edits`) records
2336    /// the cursor and publishes the deltas on the premise, true for a
2337    /// real `Document` actor and false here, that the document has
2338    /// already applied them. Nothing wrote back, so `x` / `dd` / `cw`
2339    /// were inert in every multibuffer view — search results, references,
2340    /// project diff — while insert-mode typing worked, because typing
2341    /// goes through `apply_edit` rather than through dispatch.
2342    ///
2343    /// So: replay the scratch's edits onto the real composed doc through
2344    /// [`Self::apply_edit_batch_sync`], which is the M.3 path that also
2345    /// forwards to the source documents, and return the `AppliedEdit`s
2346    /// that landed rather than the scratch's. Replaying is sound because
2347    /// the batch applies in the same order the grammar did, so each
2348    /// edit's `original_range` describes the same text in both runs.
2349    fn land_composed_edits(&self, effect: Effect) -> Result<Effect, RuntimeError> {
2350        match effect {
2351            Effect::Edits(scratch_edits) => {
2352                if scratch_edits.is_empty() {
2353                    return Ok(Effect::Edits(scratch_edits));
2354                }
2355                let edits: Vec<Edit> = scratch_edits
2356                    .iter()
2357                    .map(|a| Edit::replace(a.original_range, a.inserted_text.clone()))
2358                    .collect();
2359                Ok(Effect::Edits(self.apply_edit_batch_sync(edits)?))
2360            }
2361            // `Many` is how an operator reports edits alongside its yank
2362            // and cursor move, so the edits are almost always nested one
2363            // level down rather than at the top.
2364            Effect::Many(effects) => Ok(Effect::Many(
2365                effects
2366                    .into_iter()
2367                    .map(|e| self.land_composed_edits(e))
2368                    .collect::<Result<Vec<_>, _>>()?,
2369            )),
2370            other => Ok(other),
2371        }
2372    }
2373
2374    /// Body shared by [`Document::dispatch_with_cancel`] and
2375    /// [`Document::dispatch_with_env`].
2376    fn dispatch_composed(
2377        &self,
2378        invocation: CommandInvocation,
2379        cursor: Position,
2380        cancel: CancellationToken,
2381        env: lattice_runtime::DispatchEnv,
2382    ) -> Pending<Effect> {
2383        // Only the fields that need no composed↔source mapping are read here;
2384        // the coordinate-bearing ones (`scope_resolver`, …) are rebuilt below.
2385        let lattice_runtime::DispatchEnv {
2386            indent,
2387            last_find,
2388            fold_resolver,
2389            last_search,
2390            marks,
2391            viewport,
2392            nostartofline,
2393            scrolloff,
2394            curswant,
2395            display,
2396            curswant_out,
2397            ..
2398        } = env;
2399        // K.4.11 (2026-06-02): the multibuffer now owns grammar
2400        // dispatch directly. Pre-K.4.11 this returned
2401        // Err(ReadOnly), and `Editor::dispatch_blocking`
2402        // carried a kind-branch that ran `lattice_grammar::execute`
2403        // against a scratch `lattice_core::Document` built from
2404        // the composed snapshot. That was a paramount-#3 violation
2405        // (kind-special-casing in the host); the registry now
2406        // lives on `MultibufferInner` (passed at construction
2407        // per spawn_document's shape) so the multibuffer can do
2408        // the same work itself and the host's kind-branch
2409        // disappears.
2410        //
2411        // Resulting Effect flows through the usual host pipeline:
2412        // motions return a cursor Effect; operators return
2413        // Effect::Edits in composed coordinates.
2414        //
2415        // This comment used to claim those edits reached the sources
2416        // because "the host's apply_edit_blocking routes through this
2417        // handle's `apply_edit`". It did not — the host's Effect::Edits
2418        // arm only records the cursor and publishes deltas, on the
2419        // premise that the document already applied them. Nothing
2420        // applied them here, so every operator was a silent no-op. The
2421        // route the comment described now exists for real, in
2422        // `land_composed_edits` below; see its doc.
2423        // K.4.11.perf-fix (2026-06-02): Pre-fix this routed
2424        // `snapshot.buffer.as_string() → Document::from_text(&composed)`,
2425        // which allocated O(composed_size) bytes + rebuilt a fresh
2426        // Rope on EVERY keystroke on the App thread. For a search
2427        // multibuffer growing to 100s of KB during the scan, that
2428        // was tens of ms per `j`/`k` motion — the user-visible
2429        // "cursor moves after a lot of delay" + "lattice freezes
2430        // during scan" regressions. Architectural relocation per
2431        // [[feedback_no_ui_thread_work]]: reuse the snapshot's
2432        // existing Rope-backed Buffer directly. `Buffer::clone`
2433        // is `Rope::clone` which is Arc-backed + O(1); the new
2434        // path is one Arc bump per keystroke.
2435        let snapshot = self.snapshot();
2436        let mut scratch = lattice_core::Document::from_buffer(snapshot.buffer.clone());
2437        let buffer_id = self.inner.buffer_id;
2438        // B3b: owned snapshot for this dispatch (was `Arc::clone`); a
2439        // runtime plugin registration is live on the next keystroke.
2440        let registry = self.inner.registry.load_full();
2441        // N.1.5: tree-sitter text objects (`af` / `daf` / `znaf`) inside a
2442        // multibuffer view resolve against the SOURCE syntax, not the
2443        // composed text (which has no parse tree of its own). Build a
2444        // composed↔source `ScopeResolver` from the per-excerpt source
2445        // SyntaxSnapshots (K.4.7) and hand it to the dispatcher. Only
2446        // built for Operator / TextObject invocations -- motions
2447        // (j/k/w/...) never read it, so navigation in a big search view
2448        // stays O(1) per keystroke (paramount #1).
2449        let composed_resolver = if matches!(
2450            registry.lookup(invocation.command).map(|s| s.kind),
2451            Some(CommandKind::Operator | CommandKind::TextObject)
2452        ) {
2453            ComposedScopeResolver::build(&self.lock_state())
2454        } else {
2455            None
2456        };
2457        let result = execute_with_env(
2458            &registry,
2459            &mut scratch,
2460            buffer_id,
2461            cursor,
2462            invocation,
2463            &cancel,
2464            lattice_grammar::GrammarEnv {
2465                scope_resolver: composed_resolver
2466                    .as_ref()
2467                    .map(|r| r as &dyn lattice_grammar::ScopeResolver),
2468                // Comment objects inside multibuffer views would resolve
2469                // the per-excerpt source's comment leader -- deferred (a
2470                // follow-up, like N.1.5's scope resolver was). v1: None.
2471                comment_syntax: None,
2472                // A composed view has no tree of its own, and there is no ONE
2473                // source snapshot to hand over — an excerpt list spans several
2474                // files. `ComposedScopeResolver` above exists precisely because
2475                // the composed↔source mapping has to be applied per excerpt,
2476                // and a raw handle carries no mapping. So `None` here is the
2477                // honest answer rather than a gap: a plugin text object inside
2478                // a multibuffer view resolves from text, as it did before OT.1.
2479                // The composed peer of this handle is the same shape N.1.5
2480                // built for the resolver, and is deferred with it.
2481                syntax: None,
2482                indent,
2483                // IN.7: `=` inside a multibuffer view would need a
2484                // composed->source indent resolver, the same shape
2485                // N.1.5 built for text objects. Deferred; `=` is a
2486                // no-op there rather than wrong.
2487                indent_resolver: None,
2488                // RF.2: `gq` in a composed view reflows the COMPOSED text,
2489                // which is the right thing -- unlike `=` above it needs no
2490                // source mapping, because filling is a property of the lines
2491                // in front of the user. The width is the registered default
2492                // rather than the source buffer's `:setlocal textwidth`;
2493                // resolving that per excerpt is the same deferred mapping,
2494                // and a wrong-by-a-column reflow beats no reflow.
2495                textwidth: lattice_core::WrapWidth::default(),
2496                // RF.5b: a composed view formats natively or not at all —
2497                // delegating would send an LSP a range in COMPOSED
2498                // coordinates, which is the wrong-not-merely-absent
2499                // failure the `indent_resolver` deferral above avoids.
2500                native_format: Default::default(),
2501                // OS.2: a composed view's region would need the same
2502                // composed->source mapping the resolver above is deferred for
2503                // -- a selection in composed coordinates handed to an action
2504                // that edits SOURCE lines would be wrong, not merely absent.
2505                // `None` is the honest answer, and matches the `syntax` and
2506                // `indent_resolver` deferrals either side of it.
2507                selection: None,
2508                // VM.3c: forwarded, not deferred — see `dispatch_with_env`.
2509                last_find,
2510                // VM.3i: forwarded, not deferred — see `dispatch_with_env`.
2511                fold_resolver: fold_resolver.as_deref().map(|r| r as _),
2512                // VM.3d-2: forwarded, like `last_find`: `n` searches the text in
2513                // front of the user, which in a composed view is the composed text.
2514                last_search: last_search.as_ref(),
2515                marks: marks.as_deref().map(|m| m as _),
2516                viewport: viewport.as_deref().map(|v| v as _),
2517                nostartofline,
2518                // VM.3f: forwarded, like `nostartofline` — `H` / `L` in a
2519                // composed view keep the same margin they do anywhere else.
2520                scrolloff,
2521                curswant,
2522                display: display.as_deref().map(|d| d as _),
2523                // VM.3g-3: forwarded, like `curswant` and `display` above —
2524                // `gj` in a composed view aims and clamps against the COMPOSED
2525                // rows, so its goal is the composed one, and dropping the slot
2526                // here would make the goal stick in multibuffers only.
2527                curswant_out: curswant_out.as_deref(),
2528            },
2529        )
2530        .map_err(RuntimeError::Grammar);
2531        // K.4.11-fix: the grammar edited the scratch. Land those edits on
2532        // the real composed doc (and forward them to the sources), or the
2533        // operator is a no-op the host cannot tell from a successful one.
2534        Pending::ready(result.and_then(|effect| self.land_composed_edits(effect)))
2535    }
2536}
2537
2538// ──────────────────────────────────────────────────────────────
2539// N.1.5 — composed↔source scope resolver
2540// ──────────────────────────────────────────────────────────────
2541
2542/// N.1.5: a [`lattice_grammar::ScopeResolver`] that bridges composed
2543/// multibuffer coordinates to the per-excerpt SOURCE syntax. Tree-sitter
2544/// text objects dispatched against a multibuffer view (narrow / search /
2545/// project-diff) resolve their enclosing scope against the source file's
2546/// parse tree — the composed text has no tree of its own — and the
2547/// result is mapped back to composed coordinates so the operator applies
2548/// in the view. Built per Operator/TextObject dispatch from a snapshot
2549/// of the excerpt layout + source `SyntaxSnapshot`s (cheap Arc clones);
2550/// see [`MultibufferDocumentHandle::dispatch_with_cancel`].
2551struct ComposedScopeResolver {
2552    excerpts: Vec<ComposedExcerptSpan>,
2553    snapshots: HashMap<BufferId, Arc<SyntaxSnapshot>>,
2554}
2555
2556/// One excerpt's placement: its source rows `[start_line, start_line +
2557/// line_count)` occupy composed rows `[composed_offset, composed_offset
2558/// + line_count)`.
2559struct ComposedExcerptSpan {
2560    source: BufferId,
2561    start_line: u32,
2562    line_count: u32,
2563    composed_offset: u32,
2564}
2565
2566impl ComposedScopeResolver {
2567    /// Snapshot the excerpt layout + per-source `SyntaxSnapshot`s.
2568    /// Returns `None` when no excerpt has a source `SyntaxHandle` (a
2569    /// plain-text / no-language multibuffer) — the resolver would
2570    /// resolve nothing, so the caller passes `None` and text objects
2571    /// degrade to empty (graceful operator no-op).
2572    fn build(state: &MultibufferState) -> Option<Self> {
2573        let mut excerpts = Vec::with_capacity(state.excerpts.len());
2574        let mut snapshots: HashMap<BufferId, Arc<SyntaxSnapshot>> = HashMap::new();
2575        let mut composed_offset = 0u32;
2576        for ex in &state.excerpts {
2577            excerpts.push(ComposedExcerptSpan {
2578                source: ex.source,
2579                start_line: ex.start_line,
2580                line_count: ex.line_count(),
2581                composed_offset,
2582            });
2583            composed_offset = composed_offset.saturating_add(ex.line_count());
2584            if !snapshots.contains_key(&ex.source)
2585                && let Some(h) = state.source_syntax.get(&ex.source)
2586            {
2587                snapshots.insert(ex.source, h.snapshot());
2588            }
2589        }
2590        if snapshots.is_empty() {
2591            return None;
2592        }
2593        Some(Self {
2594            excerpts,
2595            snapshots,
2596        })
2597    }
2598}
2599
2600impl lattice_grammar::ScopeResolver for ComposedScopeResolver {
2601    fn scope_at(
2602        &self,
2603        composed_line: u32,
2604        col_byte: u32,
2605        suffix: &str,
2606    ) -> Option<lattice_protocol::position::Range> {
2607        for ex in &self.excerpts {
2608            // First composed row PAST this excerpt (exclusive upper bound).
2609            let composed_end_exclusive = ex.composed_offset.saturating_add(ex.line_count);
2610            if composed_line < composed_end_exclusive {
2611                let source_line = ex.start_line + (composed_line - ex.composed_offset);
2612                let snap = self.snapshots.get(&ex.source)?;
2613                let src = snap.scope_at_cursor(source_line, col_byte, suffix)?;
2614                // Clamp the source range to THIS excerpt's source bounds,
2615                // then map back to composed rows. A scope extending past
2616                // the excerpt (a narrowed sub-region) is clipped to the
2617                // visible rows; a clamped edge loses its byte column
2618                // (falls to col 0) since it no longer marks a real token.
2619                // `saturating_sub` guards a (shouldn't-happen) zero-line
2620                // excerpt rather than underflowing to u32::MAX.
2621                let ex_last = ex
2622                    .start_line
2623                    .saturating_add(ex.line_count)
2624                    .saturating_sub(1);
2625                let cs = src.start.line.max(ex.start_line);
2626                let ce = src.end.line.min(ex_last);
2627                if cs > ce {
2628                    return None;
2629                }
2630                let start_byte = if src.start.line >= ex.start_line {
2631                    src.start.byte
2632                } else {
2633                    0
2634                };
2635                let end_byte = if src.end.line <= ex_last {
2636                    src.end.byte
2637                } else {
2638                    0
2639                };
2640                return Some(lattice_protocol::position::Range::new(
2641                    lattice_protocol::position::Position::new(
2642                        ex.composed_offset + (cs - ex.start_line),
2643                        start_byte,
2644                    ),
2645                    lattice_protocol::position::Position::new(
2646                        ex.composed_offset + (ce - ex.start_line),
2647                        end_byte,
2648                    ),
2649                ));
2650            }
2651        }
2652        None
2653    }
2654
2655    // TSM.1: stub -- the real composed↔source tree walk for structural
2656    // motions (`]f`/`[c`/…) lands in a later slice, mirroring how
2657    // `scope_at` maps source scopes back to composed coordinates.
2658    // Graceful no-op until then (heuristic #5).
2659    fn scope_toward(
2660        &self,
2661        _composed_line: u32,
2662        _col_byte: u32,
2663        _suffix: &str,
2664        _dir: lattice_grammar::NavDir,
2665        _boundary: lattice_grammar::NavBoundary,
2666        _count: u32,
2667    ) -> Option<lattice_protocol::Position> {
2668        None
2669    }
2670}
2671
2672// ──────────────────────────────────────────────────────────────
2673// M.3 translation helpers
2674// ──────────────────────────────────────────────────────────────
2675
2676/// One excerpt + position pair resolved from a composed-coordinate
2677/// edit point. M.4 will likely read `source_id` for live-update
2678/// subscription bookkeeping; M.3 only needs the handle.
2679#[allow(dead_code)]
2680struct EditTarget {
2681    source_id: BufferId,
2682    source_handle: Arc<dyn Document>,
2683    /// The composed `Position` we translated from.
2684    composed_start: Position,
2685    /// Source-coordinate position equivalent to `composed_start`.
2686    source_start: Position,
2687    /// Last composed row of the containing excerpt (inclusive).
2688    excerpt_end_composed_row: u32,
2689    /// Last source row of the containing excerpt (inclusive).
2690    excerpt_end_source_row: u32,
2691}
2692
2693/// Walk excerpts in display order to find the one that contains
2694/// `composed_pos`. Returns the source handle + the source
2695/// position equivalent. `None` when the position is past the
2696/// last excerpt or the source map doesn't have the excerpt's
2697/// source (an invariant violation, treated as out-of-range).
2698fn resolve_edit_target(state: &MultibufferState, composed_pos: Position) -> Option<EditTarget> {
2699    let mut composed_cursor: u32 = 0;
2700    for excerpt in &state.excerpts {
2701        let lines = excerpt.line_count();
2702        let next_cursor = composed_cursor.saturating_add(lines);
2703        if composed_pos.line < next_cursor {
2704            let offset_in_excerpt = composed_pos.line - composed_cursor;
2705            let source_row = excerpt.start_line.saturating_add(offset_in_excerpt);
2706            let source_handle = state.sources.get(&excerpt.source)?.clone();
2707            return Some(EditTarget {
2708                source_id: excerpt.source,
2709                source_handle,
2710                composed_start: composed_pos,
2711                source_start: Position {
2712                    line: source_row,
2713                    byte: composed_pos.byte,
2714                },
2715                excerpt_end_composed_row: next_cursor.saturating_sub(1),
2716                excerpt_end_source_row: excerpt.end_line,
2717            });
2718        }
2719        composed_cursor = next_cursor;
2720    }
2721    None
2722}
2723
2724/// Build the source-coordinate `Edit` from a translation target +
2725/// the original composed-coordinate edit. Applies boundary
2726/// clipping: if `edit.range.end` extends past the start
2727/// excerpt's last row, the end is clipped to the end-of-line
2728/// of the excerpt's last source row.
2729fn build_source_edit(target: &EditTarget, edit: &Edit) -> Edit {
2730    let end_composed = edit.range.end;
2731    let source_end = if end_composed.line > target.excerpt_end_composed_row {
2732        // Boundary clip: pull `end` back to end-of-line of the
2733        // excerpt's last source row. Length comes from the
2734        // source's current snapshot — we already hold the
2735        // handle.
2736        let snap = target.source_handle.snapshot();
2737        let line_text = snap.buffer.line(target.excerpt_end_source_row);
2738        let line_byte_len = line_text
2739            .as_deref()
2740            .map(|s| s.trim_end_matches('\n').len() as u32)
2741            .unwrap_or(0);
2742        Position {
2743            line: target.excerpt_end_source_row,
2744            byte: line_byte_len,
2745        }
2746    } else {
2747        let row_offset = end_composed.line.saturating_sub(target.composed_start.line);
2748        Position {
2749            line: target.source_start.line.saturating_add(row_offset),
2750            byte: end_composed.byte,
2751        }
2752    };
2753
2754    Edit {
2755        range: lattice_protocol::position::Range {
2756            start: target.source_start,
2757            end: source_end,
2758        },
2759        kind: edit.kind.clone(),
2760    }
2761}
2762
2763// M.11 (2026-06-02): `translate_applied_to_composed` deleted —
2764// edits now apply to the local composed_doc, so AppliedEdit
2765// is already in composed coords. No translation needed.
2766
2767impl std::fmt::Debug for MultibufferDocumentHandle {
2768    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2769        let state = self.lock_state();
2770        f.debug_struct("MultibufferDocumentHandle")
2771            .field("id", &self.inner.id)
2772            .field("buffer_id", &self.inner.buffer_id)
2773            .field("sources", &state.sources.len())
2774            .field("excerpts", &state.excerpts.len())
2775            .finish()
2776    }
2777}
2778
2779#[derive(Debug, thiserror::Error)]
2780pub enum MultibufferError {
2781    /// An excerpt referenced a `source` BufferId not present in
2782    /// the sources map. M.2.b.2 (2026-06-01) relaxed
2783    /// `EmptyExcerpts` (the async-provider pattern needs empty
2784    /// views).
2785    #[error("excerpt {excerpt:?} references unknown source buffer {source_buffer:?}")]
2786    UnknownSource {
2787        excerpt: ExcerptId,
2788        source_buffer: BufferId,
2789    },
2790}
2791
2792// ─────────────────────────────────────────────────────────────────
2793// Header provider (VirtualRowProvider impl) — moved from
2794// `lattice-host::multibuffer` in M.2.b.1.
2795// ─────────────────────────────────────────────────────────────────
2796
2797/// Namespace prefix for multibuffer header provider ids.
2798/// Distinct from the diff filler / overlay namespaces (`0xD1FF_*`).
2799const MULTIBUFFER_EXCERPT_HEADER_NAMESPACE: u64 = 0xBBBB_0001_0000_0000;
2800
2801pub fn multibuffer_excerpt_header_provider_id(buffer_id: BufferId) -> ProviderId {
2802    MULTIBUFFER_EXCERPT_HEADER_NAMESPACE | u64::from(buffer_id.0)
2803}
2804
2805// ─────────────────────────────────────────────────────────────────
2806// T.7 (2026-06-18): mode-owned theme elements for the excerpt header.
2807//
2808// The multibuffer MODE owns these elements + their defaults — they
2809// are NOT core builtins ([[feedback_mode_owns_its_surface]]). The
2810// mode registers them (idempotent by name) so the excerpt-header
2811// provider can resolve them into BAKED `u32` colors at row-build
2812// time (off the UI thread, the established cell/virtual-row pattern).
2813//
2814// This is the extensibility acid test: a provider crate adds ZERO
2815// host `Theme`/style fields and ZERO renderer match arms — it
2816// registers elements + references them, and the renderer paints
2817// `VirtualRow.bg` / `Cell.fg` generically.
2818// ─────────────────────────────────────────────────────────────────
2819
2820/// Element name: the excerpt-header row's backdrop (a neutral
2821/// surface tint, distinct from the diff-deletion-block red the
2822/// renderer would otherwise fall a `bg: None` Generic row through to).
2823pub const ELEM_EXCERPT_HEADER: &str = "multibuffer.excerpt_header";
2824/// Element name: the excerpt-header file-path / title foreground.
2825pub const ELEM_EXCERPT_HEADER_PATH: &str = "multibuffer.excerpt_header.path";
2826/// Element name: the excerpt-header match-count foreground.
2827pub const ELEM_EXCERPT_HEADER_COUNT: &str = "multibuffer.excerpt_header.count";
2828
2829// MH.A6: the emphasised header's own backdrop + title foreground.
2830//
2831// Two elements rather than a "make the normal one brighter" rule,
2832// because brightness is not portable: a light colourscheme emphasises
2833// by going DARKER, and a theme that expressed prominence as
2834// `header_fg + 0x202020` would invert on half the themes people use.
2835// Naming the elements hands that decision to the colourscheme, which
2836// is the only layer that knows which direction is louder.
2837/// Element name: the emphasised excerpt-header row's backdrop.
2838pub const ELEM_EXCERPT_HEADER_EMPHASIS: &str = "multibuffer.excerpt_header.emphasis";
2839/// Element name: the emphasised excerpt-header's title foreground.
2840pub const ELEM_EXCERPT_HEADER_EMPHASIS_TITLE: &str = "multibuffer.excerpt_header.emphasis.title";
2841
2842// MH.A4 (2026-06-20): view-status headerline foreground elements.
2843// The status row (` ⟳ Searching … ` / ` ◆ N hits ` / ` ■ reason `)
2844// previously hardcoded its fg as ad-hoc hex (0x999999 / 0x44cc88 /
2845// 0xff4444); these map the three states onto palette role-keys so a
2846// `:colorscheme` swap recolors the status line.
2847/// Element name: the in-progress status row foreground (neutral grey).
2848pub const ELEM_STATUS_IN_PROGRESS: &str = "multibuffer.status.in_progress";
2849/// Element name: the completed status row foreground (green).
2850pub const ELEM_STATUS_COMPLETE: &str = "multibuffer.status.complete";
2851/// Element name: the failed status row foreground (red).
2852pub const ELEM_STATUS_FAILED: &str = "multibuffer.status.failed";
2853/// Element name: the emphasised-term foreground (accent) — e.g. the
2854/// project-search query woven into the status label.
2855pub const ELEM_STATUS_QUERY: &str = "multibuffer.status.query";
2856
2857/// Register the multibuffer mode's theme elements against `reg`.
2858/// Idempotent by name (safe to call on every mode activation /
2859/// every view creation). Returns the interned [`ElementId`]s the
2860/// excerpt-header provider bakes from.
2861///
2862/// `owner` is the mode's id string ([`MultibufferMode::mode_id`] →
2863/// `as_str`), so `:describe-element` attributes these to the mode
2864/// rather than core. `lattice-theme` is a leaf crate that can't
2865/// depend on `lattice-mode`, hence the string owner.
2866pub fn register_multibuffer_theme_elements(
2867    reg: &dyn lattice_theme::ThemeRegistry,
2868    owner: ElementOwner,
2869) -> MultibufferHeaderElementIds {
2870    let backdrop = reg.register(
2871        ElementName::from(ELEM_EXCERPT_HEADER.to_string()),
2872        owner.clone(),
2873        // Neutral surface backdrop (Catppuccin "surface0"-ish), NOT
2874        // the diff-deletion red — that was the smell this slice fixes.
2875        StyleSpec::new().bg(Color::Rgb(0x31, 0x32, 0x44)),
2876        "Multibuffer excerpt header backdrop.",
2877    );
2878    let path = reg.register(
2879        ElementName::from(ELEM_EXCERPT_HEADER_PATH.to_string()),
2880        owner.clone(),
2881        StyleSpec::new().fg(ColorRef::Palette("blue".into())),
2882        "Excerpt header file path.",
2883    );
2884    let count = reg.register(
2885        ElementName::from(ELEM_EXCERPT_HEADER_COUNT.to_string()),
2886        owner.clone(),
2887        StyleSpec::new().fg(ColorRef::Palette("overlay2".into())),
2888        "Excerpt header match count.",
2889    );
2890    // MH.A6: the emphasised header — `surface2` behind, `text` in
2891    // front, against the ordinary header's `surface0` + palette-blue.
2892    //
2893    // `surface2` rather than an accent hue (`purple`, `blue`) because
2894    // it is the SAME LADDER the normal backdrop sits on, two rungs up:
2895    // every shipped palette defines `surface0/1/2` as a lift, so the
2896    // emphasis reads as "this band is raised" in a light colourscheme
2897    // exactly as it does in a dark one. An accent backdrop would have
2898    // to be re-chosen per theme to avoid clashing with whatever that
2899    // theme already spends its purple on.
2900    //
2901    // Both are palette role-keys rather than literal RGB (unlike the
2902    // ordinary backdrop above, which predates the palette), so a
2903    // `:colorscheme` swap carries the emphasis with it.
2904    let emphasis = reg.register(
2905        ElementName::from(ELEM_EXCERPT_HEADER_EMPHASIS.to_string()),
2906        owner.clone(),
2907        StyleSpec::new().bg(ColorRef::Palette("surface2".into())),
2908        "Multibuffer emphasised excerpt header backdrop (e.g. the agenda's today block).",
2909    );
2910    let emphasis_title = reg.register(
2911        ElementName::from(ELEM_EXCERPT_HEADER_EMPHASIS_TITLE.to_string()),
2912        owner.clone(),
2913        StyleSpec::new().fg(ColorRef::Palette("text".into())),
2914        "Multibuffer emphasised excerpt header title foreground.",
2915    );
2916    // MH.A4: status-row state colors. `in_progress` maps to the
2917    // muted `subtext` grey (the old 0x999999), `complete` to `green`
2918    // (old 0x44cc88), `failed` to `red` (old 0xff4444) — the nearest
2919    // semantic role-keys in the registered palette.
2920    let status_in_progress = reg.register(
2921        ElementName::from(ELEM_STATUS_IN_PROGRESS.to_string()),
2922        owner.clone(),
2923        StyleSpec::new().fg(ColorRef::Palette("subtext".into())),
2924        "Multibuffer in-progress status foreground.",
2925    );
2926    let status_complete = reg.register(
2927        ElementName::from(ELEM_STATUS_COMPLETE.to_string()),
2928        owner.clone(),
2929        StyleSpec::new().fg(ColorRef::Palette("green".into())),
2930        "Multibuffer completed status foreground.",
2931    );
2932    let status_failed = reg.register(
2933        ElementName::from(ELEM_STATUS_FAILED.to_string()),
2934        owner.clone(),
2935        StyleSpec::new().fg(ColorRef::Palette("red".into())),
2936        "Multibuffer failed status foreground.",
2937    );
2938    // The emphasised-term accent (e.g. the search query) maps to the
2939    // palette `yellow` — the conventional search-highlight hue,
2940    // distinct from the green/red/blue state colors above.
2941    let status_query = reg.register(
2942        ElementName::from(ELEM_STATUS_QUERY.to_string()),
2943        owner,
2944        StyleSpec::new().fg(ColorRef::Palette("yellow".into())),
2945        "Multibuffer emphasised-term (query) status foreground.",
2946    );
2947    MultibufferHeaderElementIds {
2948        backdrop,
2949        path,
2950        count,
2951        emphasis,
2952        emphasis_title,
2953        status_in_progress,
2954        status_complete,
2955        status_failed,
2956        status_query,
2957    }
2958}
2959
2960/// The interned [`ElementId`]s for the excerpt-header elements,
2961/// captured once at view-creation and held by the provider so each
2962/// `collect()` is an array-index resolve (`resolved.get(id)`), never
2963/// a per-row name lookup. `Copy`; cheap to thread through.
2964#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2965pub struct MultibufferHeaderElementIds {
2966    pub backdrop: ElementId,
2967    pub path: ElementId,
2968    pub count: ElementId,
2969    /// MH.A6: the emphasised header's backdrop + title, interned with
2970    /// the rest so an emphasised row costs the same array-index
2971    /// resolve as an ordinary one.
2972    pub emphasis: ElementId,
2973    pub emphasis_title: ElementId,
2974    /// MH.A4: view-status row foregrounds, interned alongside the
2975    /// header elements so the status provider's `collect()` resolves
2976    /// by array index (never a per-row name lookup).
2977    pub status_in_progress: ElementId,
2978    pub status_complete: ElementId,
2979    pub status_failed: ElementId,
2980    /// The accent foreground for an emphasised term (e.g. the search
2981    /// query) woven into the status label.
2982    pub status_query: ElementId,
2983}
2984
2985impl Default for MultibufferHeaderElementIds {
2986    /// All-INVALID placeholder for test paths that build the provider
2987    /// without a theme registry. Reads against an empty/default table
2988    /// return `Style::empty()` (no bg/fg baked) — never a panic.
2989    fn default() -> Self {
2990        Self {
2991            backdrop: ElementId::INVALID,
2992            path: ElementId::INVALID,
2993            count: ElementId::INVALID,
2994            emphasis: ElementId::INVALID,
2995            emphasis_title: ElementId::INVALID,
2996            status_in_progress: ElementId::INVALID,
2997            status_complete: ElementId::INVALID,
2998            status_failed: ElementId::INVALID,
2999            status_query: ElementId::INVALID,
3000        }
3001    }
3002}
3003
3004/// Emits one virtual row per excerpt header, anchored above the
3005/// excerpt's first composed row. Cheap-clone reference to the
3006/// multibuffer handle; re-reads excerpts on each `collect()`.
3007///
3008/// T.7: when a [`ThemeRegistryHandle`] + the header element ids are
3009/// supplied, `collect()` resolves the backdrop + path elements and
3010/// bakes them into the row's `bg` / cells' `fg` — resolution happens
3011/// off-thread at row-build time, never on a paint path. Without a
3012/// handle (test paths) the rows fall back to `bg: None` (the renderer
3013/// then picks its kind default).
3014///
3015/// `Debug` is hand-rolled because `ThemeRegistryHandle` (a
3016/// `dyn ThemeRegistry` trait object) is not `Debug`; the
3017/// `VirtualRowProvider` trait requires `Debug`.
3018pub struct MultibufferExcerptHeaderProvider {
3019    multibuffer: MultibufferDocumentHandle,
3020    /// T.7: `None` for test paths that don't wire a theme registry.
3021    theme: Option<ThemeRegistryHandle>,
3022    elements: MultibufferHeaderElementIds,
3023    /// MH.A3: `ui.nerd_fonts`, captured at view construction so the
3024    /// leading file-type icon picks the nerd-font glyph vs the BMP
3025    /// fallback. Folded into `version()` so a global toggle re-runs
3026    /// `collect()`.
3027    ///
3028    /// MH.A3 follow-on: this is the GLOBAL default captured once at
3029    /// view creation — a live per-buffer `ui.nerd_fonts` toggle does
3030    /// not yet re-render the header. Reading per-buffer
3031    /// `ui.nerd_fonts` here needs the `FrameView::for_buffer` seam
3032    /// plumbed into the provider, deferred to avoid over-building.
3033    nerd_fonts: bool,
3034    /// OA.2: how consecutive excerpts share a header row. The same
3035    /// declaration that decides folding, because the two MUST agree — a fold
3036    /// spanning a different range than a header run would swallow a visible
3037    /// header, or leave one stranded above a closed fold.
3038    grouping: FoldGrouping,
3039}
3040
3041impl std::fmt::Debug for MultibufferExcerptHeaderProvider {
3042    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3043        f.debug_struct("MultibufferExcerptHeaderProvider")
3044            .field("buffer_id", &self.multibuffer.buffer_id())
3045            .field("has_theme", &self.theme.is_some())
3046            .field("elements", &self.elements)
3047            .field("nerd_fonts", &self.nerd_fonts)
3048            .finish()
3049    }
3050}
3051
3052impl MultibufferExcerptHeaderProvider {
3053    /// Construct without theme wiring — headers render with no baked
3054    /// bg/fg (the renderer's kind default applies). Test convenience;
3055    /// production uses [`Self::with_theme`]. Defaults `nerd_fonts` to
3056    /// `false` (BMP-fallback palette).
3057    pub fn new(multibuffer: MultibufferDocumentHandle) -> Self {
3058        Self {
3059            multibuffer,
3060            theme: None,
3061            elements: MultibufferHeaderElementIds::default(),
3062            nerd_fonts: false,
3063            grouping: FoldGrouping::SourceFile,
3064        }
3065    }
3066
3067    /// OA.2: group header rows the way this view folds. Defaults to
3068    /// `SourceFile`, which is what every provider but the agenda wants.
3069    pub fn with_grouping(mut self, grouping: FoldGrouping) -> Self {
3070        self.grouping = grouping;
3071        self
3072    }
3073
3074    /// T.7: construct with the resolved theme handle + the header
3075    /// element ids so `collect()` bakes the registered backdrop / path
3076    /// colors into the header rows. MH.A3: `nerd_fonts` selects the
3077    /// icon palette (captured at view creation; see the struct field
3078    /// doc for the per-buffer-toggle follow-on note).
3079    pub fn with_theme(
3080        multibuffer: MultibufferDocumentHandle,
3081        theme: ThemeRegistryHandle,
3082        elements: MultibufferHeaderElementIds,
3083        nerd_fonts: bool,
3084    ) -> Self {
3085        Self {
3086            multibuffer,
3087            theme: Some(theme),
3088            elements,
3089            nerd_fonts,
3090            grouping: FoldGrouping::SourceFile,
3091        }
3092    }
3093}
3094
3095impl VirtualRowProvider for MultibufferExcerptHeaderProvider {
3096    fn id(&self) -> ProviderId {
3097        multibuffer_excerpt_header_provider_id(self.multibuffer.buffer_id())
3098    }
3099
3100    fn version(&self) -> u64 {
3101        // T.7: fold the resolved theme version into the provider
3102        // version so a `:colorscheme` / `:set ui.*` change re-runs
3103        // `collect()` and re-bakes the header colors. Mirrors how the
3104        // cell matrix invalidates via `MatrixVersion::theme =
3105        // resolved().version()` (dispatch.rs). The worker's
3106        // fingerprint is `hash[(id, version)]`, so a version bump on
3107        // any axis (excerpt content OR theme) triggers a rebuild.
3108        let content = self.multibuffer.snapshot().version;
3109        let theme = self
3110            .theme
3111            .as_ref()
3112            .map(|t| t.resolved().version())
3113            .unwrap_or(0);
3114        // MH.A3: fold the nerd_fonts term so a global toggle re-runs
3115        // `collect()` (the worker fingerprint is `hash[(id, version)]`).
3116        content
3117            .wrapping_add(theme)
3118            .wrapping_add(if self.nerd_fonts { 1 } else { 0 })
3119    }
3120
3121    fn collect(&self) -> Vec<VirtualRow> {
3122        // Resolve the backdrop bg + all three segment fgs ONCE per
3123        // collect (off the UI thread), then bake into each header
3124        // row. `0` (the Cell "transparent / use default" sentinel) is
3125        // the fg fallback when an element is unresolved.
3126        //
3127        // header_fg = `multibuffer.excerpt_header` (the base/backdrop
3128        //   element)'s `.fg` — currently unset in the default
3129        //   registration (only `.bg`), so this resolves to 0 until a
3130        //   future slice adds a base fg; the renderer then uses its
3131        //   default fg. path_fg / count_fg come from the dedicated
3132        //   `.path` / `.count` elements.
3133        let resolved = self.theme.as_ref().map(|t| t.resolved());
3134        let header_bg: Option<u32> = resolved
3135            .as_ref()
3136            .and_then(|r| r.get(self.elements.backdrop).bg)
3137            .map(|c| c.to_rgb_u32(0));
3138        let header_fg: u32 = resolved
3139            .as_ref()
3140            .and_then(|r| r.get(self.elements.backdrop).fg)
3141            .map(|c| c.to_rgb_u32(0))
3142            .unwrap_or(0);
3143        let path_fg: u32 = resolved
3144            .as_ref()
3145            .and_then(|r| r.get(self.elements.path).fg)
3146            .map(|c| c.to_rgb_u32(0))
3147            .unwrap_or(0);
3148        let count_fg: u32 = resolved
3149            .as_ref()
3150            .and_then(|r| r.get(self.elements.count).fg)
3151            .map(|c| c.to_rgb_u32(0))
3152            .unwrap_or(0);
3153        // MH.A6: the emphasised pair, resolved in the same pass. Both
3154        // fall back to the ordinary header's colours rather than to
3155        // `0`, so a colourscheme that does not define these elements
3156        // renders an emphasised header exactly like a normal one —
3157        // undistinguished, never invisible.
3158        let emphasis_bg: Option<u32> = resolved
3159            .as_ref()
3160            .and_then(|r| r.get(self.elements.emphasis).bg)
3161            .map(|c| c.to_rgb_u32(0))
3162            .or(header_bg);
3163        let emphasis_fg: u32 = resolved
3164            .as_ref()
3165            .and_then(|r| r.get(self.elements.emphasis_title).fg)
3166            .map(|c| c.to_rgb_u32(0))
3167            .unwrap_or(header_fg);
3168        let nerd_fonts = self.nerd_fonts;
3169        // The style has to be read TWICE — once for the cells and once
3170        // for the row's `bg` — and `compose_header_rows` hands the
3171        // closure the excerpt while the `map` below sees only the
3172        // finished row. Carried in a parallel vec keyed by position
3173        // rather than threaded through `VirtualRow`: the row type is
3174        // shared with every other provider and does not want a field
3175        // that only headers mean anything by.
3176        let mut emphasised: Vec<bool> = Vec::new();
3177        let rows = compose_header_rows(&self.multibuffer.excerpts(), self.grouping, |excerpt| {
3178            let emph = excerpt.header.style == ExcerptHeaderStyle::Emphasis;
3179            emphasised.push(emph);
3180            let (fg, path_fg) = if emph {
3181                // The title takes the emphasis foreground, and so does
3182                // the trailing `(today)` parenthetical — OA.7 dims that
3183                // against the normal backdrop, and dimming it against
3184                // the emphasised one would undo the emphasis on the
3185                // half of the header that says WHY it is emphasised.
3186                (emphasis_fg, emphasis_fg)
3187            } else {
3188                (header_fg, path_fg)
3189            };
3190            header_cells(&excerpt.header, nerd_fonts, fg, path_fg, count_fg)
3191        });
3192        rows.into_iter()
3193            .enumerate()
3194            .map(|(i, mut row)| {
3195                row.bg = if emphasised.get(i).copied().unwrap_or(false) {
3196                    emphasis_bg
3197                } else {
3198                    header_bg
3199                };
3200                row
3201            })
3202            .collect()
3203    }
3204}
3205
3206/// MH.A3: rich per-segment header builder. Replaces the old
3207/// single-fg `themed_header_cells`. Layout (per multibuffer-views.md
3208/// §3.8):
3209///
3210/// ```text
3211///   filename.rs  src/multibuffer/  ·  7 matches
3212/// ```
3213///
3214/// - leading file-type **icon** (resolved live from `header.path`'s
3215///   extension via [`lattice_core::ui::icons::glyph_for_entry`];
3216///   nerd glyph when `nerd_fonts`, BMP fallback otherwise — both the
3217///   same cell width) + a trailing space, fg `header_fg`.
3218/// - **basename** (`header.path.file_name()`, else `header.title`),
3219///   fg `header_fg`.
3220/// - a space + the **dir** path (dimmed) when `header.path` has a
3221///   parent, fg `path_fg`.
3222/// - ` · {n} matches` / ` · {n} match` when `header.match_count` is
3223///   `Some`, fg `count_fg`.
3224/// - empty title AND no path ⇒ `[untitled]` fallback in `header_fg`.
3225///
3226/// Colours are **resolved in `collect()` and passed in** — never
3227/// baked at excerpt-creation time — so a live `ui.nerd_fonts` /
3228/// `:colorscheme` toggle re-renders the whole surface. A fg of `0`
3229/// is the Cell "use renderer default" sentinel (test paths without a
3230/// theme).
3231pub(crate) fn header_cells(
3232    header: &ExcerptHeader,
3233    nerd_fonts: bool,
3234    header_fg: u32,
3235    path_fg: u32,
3236    count_fg: u32,
3237) -> Arc<[Cell]> {
3238    let mut cells: Vec<Cell> = Vec::new();
3239
3240    let push = |cells: &mut Vec<Cell>, s: &str, fg: u32| {
3241        for ch in s.chars() {
3242            cells.push(Cell::new(ch as u32, fg, 0, 0));
3243        }
3244    };
3245
3246    match &header.path {
3247        Some(path) => {
3248            // Leading file-type icon (already 2 cells wide: glyph +
3249            // trailing space in both palettes) — fg `header_fg`.
3250            let icon = lattice_core::ui::icons::glyph_for_entry(path, false, nerd_fonts);
3251            push(&mut cells, icon, header_fg);
3252
3253            // Basename (bright). Fall back to the whole path string,
3254            // then to the title, if `file_name()` is unavailable.
3255            let basename: std::borrow::Cow<'_, str> = path
3256                .file_name()
3257                .map(|n| n.to_string_lossy())
3258                .unwrap_or_else(|| path.to_string_lossy());
3259            if basename.is_empty() {
3260                push(&mut cells, header.title.as_str(), header_fg);
3261            } else {
3262                push(&mut cells, basename.as_ref(), header_fg);
3263            }
3264
3265            // Directory path (dimmed) when there's a parent.
3266            if let Some(parent) = path.parent() {
3267                let parent_str = parent.to_string_lossy();
3268                if !parent_str.is_empty() {
3269                    push(&mut cells, "  ", path_fg);
3270                    push(&mut cells, parent_str.as_ref(), path_fg);
3271                }
3272            }
3273        }
3274        None => {
3275            // No path → use the title; `[untitled]` when even that is
3276            // empty so the row never collapses to nothing.
3277            if header.title.is_empty() {
3278                push(&mut cells, "[untitled]", header_fg);
3279            } else {
3280                // OA.7: a trailing parenthetical is ANNOTATION, not identity,
3281                // and reads as such when it is dimmed. The agenda's labels are
3282                // `2026-09-01 Mon (today)`, `(overdue by 2 day(s))`,
3283                // `(in 3 day(s))` — the date names the block and the phrase
3284                // qualifies it, so rendering both at one weight makes the
3285                // header longer without making it say more.
3286                //
3287                // Generic rather than agenda-shaped: it keys on the SHAPE of
3288                // the title, and any provider whose header ends in a
3289                // parenthetical gets the same reading. A title with no
3290                // parenthetical is byte-identical to before.
3291                let (name, note) = split_trailing_parenthetical(header.title.as_str());
3292                push(&mut cells, name, header_fg);
3293                if !note.is_empty() {
3294                    push(&mut cells, note, path_fg);
3295                }
3296            }
3297        }
3298    }
3299
3300    // Match-count badge (` · N matches`), fg `count_fg`.
3301    if let Some(n) = header.match_count {
3302        let badge = if n == 1 {
3303            format!(" · {n} match")
3304        } else {
3305            format!(" · {n} matches")
3306        };
3307        push(&mut cells, badge.as_str(), count_fg);
3308    }
3309
3310    Arc::from(cells)
3311}
3312
3313/// OA.7: split `title` into its name and a trailing ` (…)` annotation.
3314///
3315/// The note keeps its leading space so the two concatenate back to the input
3316/// exactly — the dimming is the only difference, and a caller that ignores the
3317/// second half still renders the whole title.
3318///
3319/// Only a parenthetical that CLOSES the string counts, and only when there is
3320/// a name in front of it. `(overdue by 2 day(s))` therefore splits at the
3321/// first `(` of the trailing run rather than at the nested one, and a title
3322/// that is nothing but a parenthetical stays whole rather than becoming an
3323/// empty name and a dim remainder.
3324fn split_trailing_parenthetical(title: &str) -> (&str, &str) {
3325    let trimmed = title.trim_end();
3326    if !trimmed.ends_with(')') {
3327        return (title, "");
3328    }
3329    // Walk back to the `(` that opens the trailing run, counting nesting so
3330    // `day(s)` inside it does not close it early.
3331    let bytes = trimmed.as_bytes();
3332    let mut depth = 0i32;
3333    let mut open = None;
3334    for (i, b) in bytes.iter().enumerate().rev() {
3335        match b {
3336            b')' => depth += 1,
3337            b'(' => {
3338                depth -= 1;
3339                if depth == 0 {
3340                    open = Some(i);
3341                    break;
3342                }
3343            }
3344            _ => {}
3345        }
3346    }
3347    let Some(open) = open else {
3348        return (title, "");
3349    };
3350    // Require a name before it, and a space between: `(today)` alone is the
3351    // whole title, not an annotation on nothing.
3352    if open == 0 || !title.is_char_boundary(open) {
3353        return (title, "");
3354    }
3355    let name = title[..open].trim_end();
3356    if name.is_empty() {
3357        return (title, "");
3358    }
3359    (name, &title[open - 1..])
3360}
3361
3362/// Pure function from excerpt list → header virtual rows, under `grouping`.
3363///
3364/// [`FoldGrouping::SourceFile`] emits ONE row per distinct consecutive
3365/// source — when N consecutive excerpts share `excerpt.source`, only the first
3366/// contributes a header, anchored `Above` its first composed line.
3367///
3368/// K.4.6 follow-up (2026-06-02): pre-fix this emitted one row per excerpt
3369/// unconditionally, which broke "1 header per file" for providers like search
3370/// that emit multiple excerpts per file (one per hit cluster). The dedup
3371/// happens here in substrate, not in providers.
3372///
3373/// [`FoldGrouping::HeaderRuns`] groups by the header TITLE instead, with
3374/// exactly the rule [`HeaderGroupFoldProvider`] already folds by:
3375///
3376/// - a non-empty title differing from the current group's starts a group;
3377/// - a non-empty title equal to it continues it;
3378/// - an **empty** title continues it — that is what "I belong to the group
3379///   above" has always meant in this tree.
3380///
3381/// OA.2. The agenda needs this because it groups by DATE and its rows
3382/// interleave files by design, so a file boundary is not a group boundary
3383/// there. It gave the first row of a date group the label and the rest an
3384/// empty title, on the documented assumption that an empty title renders no
3385/// header — but source-run dedup emitted one at every file change, and
3386/// `header_cells` renders a titleless, pathless header as `[untitled]`. One
3387/// date group drawn from two files rendered
3388/// `["2026-08-31 Mon (today)", "[untitled]", "[untitled]"]`.
3389///
3390/// Not the default, and that is not timidity: `lattice-lsp`'s references view
3391/// titles each excerpt with its LINE NUMBER, so consecutive excerpts from one
3392/// file carry different titles and title-runs would give it a header per
3393/// reference instead of per file.
3394pub fn compose_header_rows(
3395    excerpts: &[Excerpt],
3396    grouping: FoldGrouping,
3397    mut render_cells: impl FnMut(&Excerpt) -> Arc<[Cell]>,
3398) -> Vec<VirtualRow> {
3399    let mut rows = Vec::with_capacity(excerpts.len());
3400    let mut composed_cursor: u32 = 0;
3401    let mut last_source: Option<BufferId> = None;
3402    let mut group_title: Option<String> = None;
3403    for excerpt in excerpts {
3404        let starts_group = match grouping {
3405            FoldGrouping::SourceFile => last_source != Some(excerpt.source),
3406            FoldGrouping::HeaderRuns => {
3407                let title = excerpt.header.title.as_str();
3408                // An empty title continues whatever is open. With nothing
3409                // open it starts nothing either — emitting here is what
3410                // produced `[untitled]`, and a header with no text is not a
3411                // header.
3412                !title.is_empty() && group_title.as_deref() != Some(title)
3413            }
3414        };
3415        if starts_group {
3416            group_title = Some(excerpt.header.title.clone());
3417            let cells = render_cells(excerpt);
3418            rows.push(VirtualRow {
3419                media: None,
3420                anchor_line: composed_cursor,
3421                position: AnchorPosition::Above,
3422                cells,
3423                height: 1,
3424                kind: VirtualRowKind::Generic,
3425                bg: None,
3426                scales: None,
3427                gutter_line: None,
3428                gutter_fg: None,
3429            });
3430        }
3431        last_source = Some(excerpt.source);
3432        composed_cursor = composed_cursor.saturating_add(excerpt.line_count());
3433    }
3434    rows
3435}
3436
3437// ─────────────────────────────────────────────────────────────────
3438// M.6.5 (2026-06-08): view-status sticky headerline
3439// ─────────────────────────────────────────────────────────────────
3440
3441/// Namespace prefix for the view-status headerline provider.
3442/// Distinct from the excerpt-header namespace (`0xBBBB_0001_*`).
3443const MULTIBUFFER_STATUS_NAMESPACE: u64 = 0xBBBB_0002_0000_0000;
3444
3445pub fn multibuffer_status_provider_id(buffer_id: BufferId) -> ProviderId {
3446    MULTIBUFFER_STATUS_NAMESPACE | u64::from(buffer_id.0)
3447}
3448
3449/// MH.A4: fallback hex used when no theme is wired (test paths). These
3450/// are the pre-MH.A4 hardcoded colors; production resolves the
3451/// `multibuffer.status.*` theme elements instead.
3452const STATUS_IN_PROGRESS_FALLBACK_FG: u32 = 0x999999;
3453const STATUS_COMPLETE_FALLBACK_FG: u32 = 0x44cc88;
3454const STATUS_FAILED_FALLBACK_FG: u32 = 0xff4444;
3455/// Fallback accent for the emphasised term when the theme (or the
3456/// `multibuffer.status.query` element) is unresolved — a warm yellow,
3457/// the conventional search-highlight hue.
3458const STATUS_QUERY_FALLBACK_FG: u32 = 0xf9e2af;
3459
3460/// Pure function: `HeaderlineStatus` → display row (or `None` when idle).
3461///
3462/// MH.A4: the per-state foregrounds are **resolved in `collect()` and
3463/// passed in** (from the `multibuffer.status.*` theme elements) — never
3464/// hardcoded here — so a `:colorscheme` swap recolors the status line.
3465/// Color legend:
3466///   InProgress — `in_progress_fg` (neutral grey), theme bg
3467///   Complete   — `complete_fg` (green), green `◆` prefix, theme bg
3468///   Failed     — `failed_fg` (red), red `■` prefix, theme bg
3469fn render_multibuffer_status(
3470    status: &HeaderlineStatus,
3471    in_progress_fg: u32,
3472    complete_fg: u32,
3473    failed_fg: u32,
3474    query_fg: u32,
3475) -> Option<HeaderlineRow> {
3476    let (text, emphasis): (String, Option<&str>) = match status {
3477        HeaderlineStatus::Idle => return None,
3478        HeaderlineStatus::InProgress {
3479            label,
3480            count: None,
3481            emphasis,
3482        } => (format!(" ⟳ {label} … "), emphasis.as_deref()),
3483        HeaderlineStatus::InProgress {
3484            label,
3485            count: Some(n),
3486            emphasis,
3487        } => (format!(" ⟳ {label} ({n}) … "), emphasis.as_deref()),
3488        HeaderlineStatus::Complete { summary, emphasis } => {
3489            (format!(" ◆ {summary} "), emphasis.as_deref())
3490        }
3491        HeaderlineStatus::Failed { reason } => (format!(" ■ {reason} "), None),
3492    };
3493    let fg: u32 = match status {
3494        HeaderlineStatus::InProgress { .. } => in_progress_fg,
3495        HeaderlineStatus::Complete { .. } => complete_fg,
3496        HeaderlineStatus::Failed { .. } => failed_fg,
3497        HeaderlineStatus::Idle => unreachable!(),
3498    };
3499    // Char-index range of the emphasised term within `text` (first
3500    // occurrence). Only the query cells get `query_fg`; everything else
3501    // keeps the state fg. Non-empty emphasis that isn't found → no accent.
3502    let emphasis_range: Option<(usize, usize)> = emphasis.filter(|e| !e.is_empty()).and_then(|e| {
3503        text.find(e).map(|byte_start| {
3504            let char_start = text[..byte_start].chars().count();
3505            (char_start, char_start + e.chars().count())
3506        })
3507    });
3508    let cells: Arc<[Cell]> = text
3509        .chars()
3510        .enumerate()
3511        .map(|(i, c)| {
3512            let cell_fg = match emphasis_range {
3513                Some((start, end)) if i >= start && i < end => query_fg,
3514                _ => fg,
3515            };
3516            Cell::new(c as u32, cell_fg, 0, 0)
3517        })
3518        .collect::<Vec<_>>()
3519        .into();
3520    Some(HeaderlineRow { cells, bg: None })
3521}
3522
3523/// Sticky headerline provider that surfaces the view's
3524/// [`HeaderlineStatus`] as a pinned row above line 0.
3525///
3526/// Implements [`Headerline`] directly — the status lives in
3527/// `MultibufferInner` (behind an `ArcSwap`); no extra dedicated
3528/// state allocation is needed.
3529pub struct MultibufferStatusProvider {
3530    multibuffer: MultibufferDocumentHandle,
3531    /// MH.A4: `None` for test paths that don't wire a theme registry —
3532    /// `render()` then falls back to the pre-MH.A4 hardcoded hex.
3533    theme: Option<ThemeRegistryHandle>,
3534    /// MH.A4: the interned status element ids (mirrors the
3535    /// excerpt-header provider's `elements`), captured once at
3536    /// view-creation so `render()` resolves by array index.
3537    elements: MultibufferHeaderElementIds,
3538}
3539
3540impl std::fmt::Debug for MultibufferStatusProvider {
3541    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3542        f.debug_struct("MultibufferStatusProvider")
3543            .field("buffer_id", &self.multibuffer.buffer_id())
3544            .field("has_theme", &self.theme.is_some())
3545            .field("elements", &self.elements)
3546            .finish()
3547    }
3548}
3549
3550impl MultibufferStatusProvider {
3551    /// Construct without theme wiring — the status row renders with the
3552    /// pre-MH.A4 fallback hex. Test convenience; production uses
3553    /// [`Self::with_theme`].
3554    pub fn new(multibuffer: MultibufferDocumentHandle) -> Self {
3555        Self {
3556            multibuffer,
3557            theme: None,
3558            elements: MultibufferHeaderElementIds::default(),
3559        }
3560    }
3561
3562    /// MH.A4: construct with the resolved theme handle + the interned
3563    /// status element ids so `render()` resolves the
3564    /// `multibuffer.status.*` foregrounds (mirrors the excerpt-header
3565    /// provider's `with_theme`).
3566    pub fn with_theme(
3567        multibuffer: MultibufferDocumentHandle,
3568        theme: ThemeRegistryHandle,
3569        elements: MultibufferHeaderElementIds,
3570    ) -> Self {
3571        Self {
3572            multibuffer,
3573            theme: Some(theme),
3574            elements,
3575        }
3576    }
3577
3578    pub fn into_provider(self, buffer_id: BufferId) -> HeaderlineProvider {
3579        HeaderlineProvider::new(multibuffer_status_provider_id(buffer_id), Arc::new(self))
3580    }
3581
3582    /// Resolve the three status foregrounds from the wired theme,
3583    /// falling back to the pre-MH.A4 hex per-state when the theme or an
3584    /// element is unresolved (test paths).
3585    fn status_fgs(&self) -> (u32, u32, u32, u32) {
3586        let resolved = self.theme.as_ref().map(|t| t.resolved());
3587        let resolve = |id: ElementId, fallback: u32| -> u32 {
3588            resolved
3589                .as_ref()
3590                .and_then(|r| r.get(id).fg)
3591                .map(|c| c.to_rgb_u32(fallback))
3592                .unwrap_or(fallback)
3593        };
3594        (
3595            resolve(
3596                self.elements.status_in_progress,
3597                STATUS_IN_PROGRESS_FALLBACK_FG,
3598            ),
3599            resolve(self.elements.status_complete, STATUS_COMPLETE_FALLBACK_FG),
3600            resolve(self.elements.status_failed, STATUS_FAILED_FALLBACK_FG),
3601            resolve(self.elements.status_query, STATUS_QUERY_FALLBACK_FG),
3602        )
3603    }
3604}
3605
3606impl Headerline for MultibufferStatusProvider {
3607    fn version(&self) -> u64 {
3608        // MH.A4: fold the resolved theme version into the status
3609        // version so a `:colorscheme` / `:set ui.*` change re-runs
3610        // `render()` and re-resolves the status foregrounds. Mirrors
3611        // the excerpt-header provider's version composition.
3612        let content = self
3613            .multibuffer
3614            .inner
3615            .headerline_version
3616            .load(Ordering::Acquire);
3617        let theme = self
3618            .theme
3619            .as_ref()
3620            .map(|t| t.resolved().version())
3621            .unwrap_or(0);
3622        content.wrapping_add(theme)
3623    }
3624
3625    fn render(&self) -> Option<HeaderlineRow> {
3626        let (in_progress_fg, complete_fg, failed_fg, query_fg) = self.status_fgs();
3627        render_multibuffer_status(
3628            &self.multibuffer.inner.headerline.load(),
3629            in_progress_fg,
3630            complete_fg,
3631            failed_fg,
3632            query_fg,
3633        )
3634    }
3635}
3636
3637/// M.10.4 (2026-06-03): host glue for the
3638/// `:multibuffer-expand [n]` / `:multibuffer-contract [n]`
3639/// ex-commands. Looks up the active buffer's typed
3640/// `MultibufferDocumentHandle` via the
3641/// `MultibufferRegistryHandle` service and calls
3642/// `expand_excerpt_at(cursor_row, delta)`. No-op when the
3643/// active buffer isn't a multibuffer view, the service isn't
3644/// registered (test harness), or the cursor is out of range.
3645///
3646/// Replaces `Editor::do_multibuffer_expand` which used to live
3647/// in `lattice-host::dispatch`. Per
3648/// [[feedback_mode_owns_its_surface]] + `mode-architecture.md`
3649/// §5.3.4: this helper is the substrate-side counterpart to
3650/// the ex-command registration in `MultibufferMode`. Host's
3651/// `apply_effect` arm calls this directly — no longer
3652/// trampolines through `Action::MultibufferExpand` +
3653/// `Editor::do_multibuffer_expand`.
3654pub fn multibuffer_expand_excerpt_at(
3655    services: &lattice_mode::services::ServiceRegistry,
3656    buffer_id: BufferId,
3657    cursor_row: u32,
3658    delta: i32,
3659) {
3660    let Some(mb_registry) = services.get::<crate::registry::MultibufferRegistryHandle>() else {
3661        return;
3662    };
3663    let Some(view) = mb_registry.handle(buffer_id) else {
3664        return;
3665    };
3666    view.expand_excerpt_at(cursor_row, delta);
3667}
3668
3669/// Default header-rendering: `── <title> ──` (box-drawing
3670/// rules). Empty title yields a row of box rules only.
3671pub fn default_header_cells(excerpt: &Excerpt) -> Arc<[Cell]> {
3672    let title = &excerpt.header.title;
3673    let mut cells = Vec::new();
3674    for _ in 0..2 {
3675        cells.push(Cell::with_codepoint('─' as u32));
3676    }
3677    if !title.is_empty() {
3678        cells.push(Cell::with_codepoint(' ' as u32));
3679        for ch in title.chars() {
3680            cells.push(Cell::with_codepoint(ch as u32));
3681        }
3682        cells.push(Cell::with_codepoint(' ' as u32));
3683    }
3684    for _ in 0..2 {
3685        cells.push(Cell::with_codepoint('─' as u32));
3686    }
3687    Arc::from(cells)
3688}
3689
3690// ─────────────────────────────────────────────────────────────────
3691// Internals
3692// ─────────────────────────────────────────────────────────────────
3693
3694fn next_multibuffer_document_id() -> DocumentId {
3695    static NEXT: AtomicU64 = AtomicU64::new(0x1000_0000_0000_0000);
3696    DocumentId::new(NEXT.fetch_add(1, Ordering::Relaxed))
3697}
3698
3699/// M.11 (2026-06-02): build the composed text from sources at
3700/// initialization time. The result feeds `Document::from_text`
3701/// so the composed_doc starts in sync with the sources. After
3702/// this point, the composed_doc evolves through `apply_edit` —
3703/// sources catch up via the forwarder.
3704fn compose_text_from_sources(
3705    sources: &HashMap<BufferId, Arc<dyn Document>>,
3706    excerpts: &[Excerpt],
3707) -> String {
3708    let mut composed_text = String::new();
3709    for excerpt in excerpts {
3710        let Some(source) = sources.get(&excerpt.source) else {
3711            continue;
3712        };
3713        let snap = source.snapshot();
3714        for row in excerpt.start_line..=excerpt.end_line {
3715            if let Some(line) = snap.buffer.line(row) {
3716                composed_text.push_str(&line);
3717                if !composed_text.ends_with('\n') {
3718                    composed_text.push('\n');
3719                }
3720            }
3721        }
3722    }
3723    composed_text
3724}
3725
3726/// M.11 (2026-06-02): build a `DocumentSnapshot` from the
3727/// composed_doc plus identity metadata. The composed_doc IS the
3728/// source of truth — this just wraps its current state in the
3729/// shape the renderer reads.
3730fn snapshot_from_composed_doc(
3731    doc: &lattice_core::Document,
3732    id: DocumentId,
3733    selections: Arc<SelectionSet>,
3734) -> DocumentSnapshot {
3735    DocumentSnapshot {
3736        id,
3737        version: doc.version(),
3738        text_version: doc.text_version(),
3739        buffer: doc.buffer().clone(),
3740        path: None,
3741        dirty: doc.dirty(),
3742        selections,
3743    }
3744}
3745
3746fn compose_snapshot(
3747    id: DocumentId,
3748    sources: &HashMap<BufferId, Arc<dyn Document>>,
3749    excerpts: &[Excerpt],
3750    selections: Arc<SelectionSet>,
3751) -> DocumentSnapshot {
3752    let mut composed_text = String::new();
3753    let mut composed_version: u64 = 0;
3754    let mut composed_text_version: u64 = 0;
3755
3756    for excerpt in excerpts {
3757        let Some(source) = sources.get(&excerpt.source) else {
3758            continue;
3759        };
3760        let snap = source.snapshot();
3761        composed_version = composed_version.saturating_add(snap.version);
3762        composed_text_version = composed_text_version.saturating_add(snap.text_version);
3763        for row in excerpt.start_line..=excerpt.end_line {
3764            if let Some(line) = snap.buffer.line(row) {
3765                composed_text.push_str(&line);
3766                if !composed_text.ends_with('\n') {
3767                    composed_text.push('\n');
3768                }
3769            }
3770        }
3771    }
3772
3773    DocumentSnapshot {
3774        id,
3775        version: composed_version,
3776        text_version: composed_text_version,
3777        buffer: Buffer::from_text(&composed_text),
3778        path: None,
3779        dirty: false,
3780        // K.4.5 (2026-06-02): selections come from
3781        // `MultibufferState`, preserved across recomposes so
3782        // excerpt mutations (append / replace / clip) don't
3783        // clobber the user's Visual selection. Updated via
3784        // `set_selections` (Document trait).
3785        selections,
3786    }
3787}
3788
3789#[cfg(test)]
3790mod tests {
3791    #![allow(clippy::unwrap_used, clippy::panic)]
3792    use super::*;
3793    use lattice_core::Document as CoreDocument;
3794    use lattice_grammar::CommandRegistry;
3795    use lattice_runtime::spawn_document;
3796
3797    fn empty_registry() -> lattice_grammar::CommandRegistryHandle {
3798        Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()))
3799    }
3800
3801    fn make_sources(texts: &[&str]) -> (HashMap<BufferId, Arc<dyn Document>>, Vec<BufferId>) {
3802        let mut map: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
3803        let mut ids = Vec::new();
3804        for text in texts {
3805            let id = BufferId::next();
3806            let handle = spawn_document(id, CoreDocument::from_text(*text), empty_registry());
3807            map.insert(id, Arc::new(handle));
3808            ids.push(id);
3809        }
3810        (map, ids)
3811    }
3812
3813    #[tokio::test(flavor = "multi_thread")]
3814    async fn single_source_single_excerpt_composes() {
3815        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\ndelta\nepsilon\n"]);
3816        let excerpts = vec![Excerpt::new(ids[0], 1, 3)];
3817        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3818        let snap = mb.snapshot();
3819        assert_eq!(snap.buffer.as_string(), "beta\ngamma\ndelta\n");
3820        assert!(!snap.dirty);
3821        assert!(snap.path.is_none());
3822        assert_eq!(snap.selections.all().len(), 1);
3823    }
3824
3825    #[tokio::test(flavor = "multi_thread")]
3826    async fn multi_source_multi_excerpt_composes_in_order() {
3827        let (sources, ids) = make_sources(&["a1\na2\na3\n", "b1\nb2\nb3\n"]);
3828        let excerpts = vec![Excerpt::new(ids[0], 0, 1), Excerpt::new(ids[1], 2, 2)];
3829        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3830        let snap = mb.snapshot();
3831        assert_eq!(snap.buffer.as_string(), "a1\na2\nb3\n");
3832    }
3833
3834    #[tokio::test(flavor = "multi_thread")]
3835    async fn save_is_readonly_for_a_pathless_view() {
3836        // A path-less in-memory source has nothing to persist on
3837        // disk, so `:w` on such a view is `ReadOnly`. Real
3838        // file-backed sources DO save now — see
3839        // `save_persists_view_edits_to_the_source_file`.
3840        let (sources, ids) = make_sources(&["x"]);
3841        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
3842        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3843
3844        assert!(matches!(mb.save().await, Err(RuntimeError::ReadOnly)));
3845    }
3846
3847    /// 2026-06-10: editing a multibuffer view and calling `save()`
3848    /// flushes the source-forwarder and persists the edit to the
3849    /// source FILE on disk — the generic `:w`-saves-sources path that
3850    /// narrow + project-search both rely on. The flush is load-
3851    /// bearing: without it `save()` would race the async forwarder
3852    /// and write the pre-edit source.
3853    #[tokio::test(flavor = "multi_thread")]
3854    async fn save_persists_view_edits_to_the_source_file() {
3855        let unique = std::time::SystemTime::now()
3856            .duration_since(std::time::UNIX_EPOCH)
3857            .unwrap()
3858            .as_nanos();
3859        let path = std::env::temp_dir().join(format!("lattice-mb-save-{unique}.txt"));
3860        std::fs::write(&path, "alpha\nbeta\ngamma\n").unwrap();
3861
3862        let id = BufferId::next();
3863        let doc = lattice_core::DocumentBuilder::default()
3864            .with_text("alpha\nbeta\ngamma\n")
3865            .with_path(path.clone())
3866            .build();
3867        let handle = spawn_document(id, doc, empty_registry());
3868        let source: Arc<dyn Document> = Arc::new(handle);
3869        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
3870        sources.insert(id, source);
3871        let excerpts = vec![Excerpt::new(id, 0, 2)];
3872        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3873
3874        // Insert "X" at the start of row 1 in the composed view.
3875        mb.apply_edit(Edit::insert(Position::new(1, 0), "X"))
3876            .await
3877            .expect("edit applies");
3878
3879        // save() flushes the forwarder (so the source has the edit)
3880        // then writes the source file back to disk.
3881        let saved = mb.save().await.expect("save ok");
3882        assert_eq!(saved, path);
3883
3884        let on_disk = std::fs::read_to_string(&path).unwrap();
3885        assert_eq!(on_disk, "alpha\nXbeta\ngamma\n");
3886        std::fs::remove_file(&path).ok();
3887    }
3888
3889    #[tokio::test(flavor = "multi_thread")]
3890    async fn set_selections_stores_composed_selections_post_k_4_5() {
3891        // K.4.5 (2026-06-02): selections are view-owned in
3892        // composed coordinate space. set_selections now
3893        // stores the SelectionSet on `MultibufferState` and
3894        // republishes the snapshot, so
3895        // `Editor::visual_selection_range` reading
3896        // `self.document.selections().primary()` sees the
3897        // updated anchor / head — Visual-mode highlights
3898        // paint uniformly across BufferKinds.
3899        use lattice_protocol::position::Position;
3900        use lattice_protocol::selection::{Selection, VisualMode};
3901
3902        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
3903        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
3904        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3905
3906        // Initial snapshot: default empty selection set.
3907        let initial = mb.snapshot();
3908        assert_eq!(initial.selections.all().len(), 1);
3909        assert_eq!(initial.selections.primary().anchor, Position::new(0, 0));
3910        assert_eq!(initial.selections.primary().head, Position::new(0, 0));
3911
3912        // Set a Visual-mode selection spanning the composed view.
3913        let sel = Selection {
3914            anchor: Position::new(0, 0),
3915            head: Position::new(1, 3),
3916            visual: Some(VisualMode::Charwise),
3917        };
3918        let set = SelectionSet::single(sel);
3919        mb.set_selections(set.clone()).await.expect("ok");
3920
3921        // Snapshot now reflects the new selection.
3922        let after = mb.snapshot();
3923        assert_eq!(after.selections.primary().anchor, Position::new(0, 0));
3924        assert_eq!(after.selections.primary().head, Position::new(1, 3));
3925        assert_eq!(
3926            after.selections.primary().visual,
3927            Some(VisualMode::Charwise)
3928        );
3929
3930        // Recompose preserves the selection (excerpt-mutation
3931        // paths read state.selections through compose_snapshot).
3932        mb.recompose();
3933        let recomposed = mb.snapshot();
3934        assert_eq!(
3935            recomposed.selections.primary().head,
3936            Position::new(1, 3),
3937            "recompose must preserve composed-coordinate selections"
3938        );
3939    }
3940
3941    #[tokio::test(flavor = "multi_thread")]
3942    async fn m3_insert_translates_and_forwards_to_source() {
3943        // M.11 (2026-06-02): under the local-rope architecture
3944        // the composed snapshot reflects the edit IMMEDIATELY
3945        // (synchronous local mutation). The source rope catches
3946        // up async via the source-forwarder task.
3947        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
3948        let source_handle = sources.get(&ids[0]).expect("source present").clone();
3949        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
3950        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3951
3952        let applied = mb
3953            .apply_edit(Edit::insert(Position::new(1, 0), "X-"))
3954            .await
3955            .expect("insert should land locally");
3956        assert_eq!(applied.inserted_text, "X-");
3957        // Composed snapshot reflects the edit synchronously.
3958        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\nX-beta\ngamma\n");
3959
3960        // Source catches up async via the forwarder task running
3961        // on shared_runtime (cross-runtime from the test's
3962        // multi_thread runtime). Poll with sleep.
3963        for _ in 0..40 {
3964            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3965            if source_handle.text() == "alpha\nX-beta\ngamma\n" {
3966                return;
3967            }
3968        }
3969        panic!(
3970            "source did not converge to multibuffer edit; got: {:?}",
3971            source_handle.text()
3972        );
3973    }
3974
3975    // ── OA.23b: writing at a line the view does not compose ───
3976
3977    /// The agenda's `s`: the row is the headline, and `SCHEDULED:` goes on a
3978    /// line BELOW it that no excerpt contains. There is no composed coordinate
3979    /// for it, so `apply_edit` cannot reach it.
3980    #[tokio::test(flavor = "multi_thread")]
3981    async fn apply_to_source_writes_outside_every_excerpt() {
3982        let (sources, ids) = make_sources(&["* TODO write it\n* TODO and it\n"]);
3983        let source_handle = sources.get(&ids[0]).expect("source present").clone();
3984        // Only the FIRST line is excerpted — an agenda row.
3985        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
3986        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
3987        assert_eq!(mb.snapshot().buffer.as_string(), "* TODO write it\n");
3988
3989        mb.apply_to_source(
3990            ids[0],
3991            Edit::insert(Position::new(1, 0), "  SCHEDULED: <2026-09-03 Thu>\n"),
3992        )
3993        .expect("the view owns this source")
3994        .await
3995        .expect("the edit lands");
3996
3997        assert_eq!(
3998            source_handle.text(),
3999            "* TODO write it\n  SCHEDULED: <2026-09-03 Thu>\n* TODO and it\n"
4000        );
4001        // The view shows the headline and only the headline: the planning line
4002        // is outside it, which is the whole reason it needed this door.
4003        assert_eq!(mb.snapshot().buffer.as_string(), "* TODO write it\n");
4004        assert!(source_handle.dirty(), "`:w` on the view must write this");
4005    }
4006
4007    /// A source id no view owns gets `None` rather than a panic or a write
4008    /// somewhere else — the caller is acting on an id it was handed, and a
4009    /// view can close between the two.
4010    #[tokio::test(flavor = "multi_thread")]
4011    async fn apply_to_source_declines_a_source_it_does_not_own() {
4012        let (sources, ids) = make_sources(&["a\n"]);
4013        let mb = MultibufferDocumentHandle::new(
4014            sources,
4015            vec![Excerpt::new(ids[0], 0, 0)],
4016            empty_registry(),
4017        )
4018        .unwrap();
4019        assert!(
4020            mb.apply_to_source(BufferId::next(), Edit::insert(Position::new(0, 0), "x"))
4021                .is_none()
4022        );
4023    }
4024
4025    /// The ordering the `DirectEdit` variant exists for.
4026    ///
4027    /// A composed edit reaches its source ASYNC, through the forwarder. A
4028    /// direct source write that went straight at the document could overtake
4029    /// one still in flight — and that queued edit's source coordinates were
4030    /// computed BEFORE this insert shifted the lines below it, so it would
4031    /// then land in the wrong place. Riding the same FIFO is what prevents it.
4032    ///
4033    /// Written as: type into the composed line, then immediately insert a line
4034    /// ABOVE everything through the direct door. If the direct write overtook,
4035    /// the composed edit's `X-` would land on the wrong row.
4036    #[tokio::test(flavor = "multi_thread")]
4037    async fn a_direct_write_queues_behind_composed_edits_in_flight() {
4038        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
4039        let source_handle = sources.get(&ids[0]).expect("source present").clone();
4040        let excerpts = vec![Excerpt::new(ids[0], 1, 2)];
4041        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4042
4043        // Composed line 0 is source line 1 (`beta`). Queued, not yet applied.
4044        mb.apply_edit(Edit::insert(Position::new(0, 0), "X-"))
4045            .await
4046            .expect("insert lands locally");
4047        // Straight after, a write at the top of the FILE.
4048        mb.apply_to_source(ids[0], Edit::insert(Position::new(0, 0), "header\n"))
4049            .expect("the view owns this source")
4050            .await
4051            .expect("the edit lands");
4052
4053        assert_eq!(
4054            source_handle.text(),
4055            "header\nalpha\nX-beta\ngamma\n",
4056            "the composed edit must have landed on `beta` before the header shifted it"
4057        );
4058    }
4059
4060    // ── K.4.11-fix: an operator's edits have to LAND ──────────
4061    //
4062    // Grammar dispatch runs against a scratch clone of the composed
4063    // rope, so an operator reported edits it had made to a throwaway.
4064    // The host publishes those deltas assuming the document already
4065    // applied them — true for a real `Document` actor, false here — so
4066    // `x` / `dd` / `cw` were inert in every multibuffer view while
4067    // insert-mode typing worked, because typing does not go through
4068    // dispatch.
4069
4070    /// Build a one-source view and run `invocation` through the real
4071    /// dispatch path, returning the source handle so the test can watch
4072    /// it converge.
4073    fn view_for_dispatch(
4074        text: &str,
4075    ) -> (
4076        MultibufferDocumentHandle,
4077        std::sync::Arc<dyn Document>,
4078        lattice_grammar::CommandRegistryHandle,
4079        lattice_grammar::builtins::Builtins,
4080    ) {
4081        let (sources, ids) = make_sources(&[text]);
4082        let source_handle = sources.get(&ids[0]).expect("source present").clone();
4083        let mut registry = lattice_grammar::CommandRegistry::new();
4084        let builtins = lattice_grammar::builtins::populate(&mut registry);
4085        let handle: lattice_grammar::CommandRegistryHandle =
4086            Arc::new(arc_swap::ArcSwap::from_pointee(registry));
4087        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
4088        let mb = MultibufferDocumentHandle::new(sources, excerpts, handle.clone()).unwrap();
4089        (mb, source_handle, handle, builtins)
4090    }
4091
4092    #[tokio::test(flavor = "multi_thread")]
4093    async fn an_operator_edits_the_composed_view() {
4094        let (mb, _source, _reg, builtins) = view_for_dispatch("alpha\nbeta\ngamma\n");
4095        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\nbeta\ngamma\n");
4096
4097        // `x` at (0,0): delete-char-forward over one character.
4098        let inv = lattice_grammar::CommandInvocation::of(builtins.delete.0).with_target(
4099            lattice_grammar::Target::Motion(builtins.char_right, lattice_grammar::args::Args::None),
4100        );
4101        mb.dispatch(inv, Position::new(0, 0))
4102            .await
4103            .expect("dispatch succeeds");
4104
4105        assert_eq!(
4106            mb.snapshot().buffer.as_string(),
4107            "lpha\nbeta\ngamma\n",
4108            "the operator's edit must land on the composed rope, not on the scratch"
4109        );
4110    }
4111
4112    /// The half that would still be missing if the fix only mutated the
4113    /// composed rope: a multibuffer edit is an edit to a FILE, and the
4114    /// view exists to propagate it. Replaying through
4115    /// `apply_edit_batch_sync` is what buys this — it is the M.3 path,
4116    /// so the source forward comes with it.
4117    #[tokio::test(flavor = "multi_thread")]
4118    async fn an_operator_edit_reaches_the_source_document() {
4119        let (mb, source, _reg, builtins) = view_for_dispatch("alpha\nbeta\ngamma\n");
4120        let inv = lattice_grammar::CommandInvocation::of(builtins.delete.0).with_target(
4121            lattice_grammar::Target::Motion(builtins.char_right, lattice_grammar::args::Args::None),
4122        );
4123        mb.dispatch(inv, Position::new(0, 0))
4124            .await
4125            .expect("dispatch succeeds");
4126
4127        for _ in 0..40 {
4128            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4129            if source.text() == "lpha\nbeta\ngamma\n" {
4130                return;
4131            }
4132        }
4133        panic!(
4134            "source never saw the operator's edit; got {:?}",
4135            source.text()
4136        );
4137    }
4138
4139    /// A motion reports a cursor and no edits, so it must pass through
4140    /// the landing step untouched. Without this, a fix that blindly
4141    /// re-applied everything would turn every `j` into a rope mutation.
4142    #[tokio::test(flavor = "multi_thread")]
4143    async fn a_motion_changes_no_text() {
4144        let (mb, _source, _reg, builtins) = view_for_dispatch("alpha\nbeta\ngamma\n");
4145        let before = mb.snapshot().buffer.as_string();
4146        let inv = lattice_grammar::CommandInvocation::of(builtins.line_down.0);
4147        mb.dispatch(inv, Position::new(0, 0))
4148            .await
4149            .expect("dispatch succeeds");
4150        assert_eq!(mb.snapshot().buffer.as_string(), before);
4151    }
4152
4153    #[tokio::test(flavor = "multi_thread")]
4154    async fn m3_insert_translates_when_excerpt_starts_off_zero() {
4155        // M.11: composed snapshot reflects edit synchronously.
4156        let (sources, ids) = make_sources(&["zero\none\ntwo\nthree\nfour\n"]);
4157        let excerpts = vec![Excerpt::new(ids[0], 2, 4)];
4158        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4159        assert_eq!(mb.snapshot().buffer.as_string(), "two\nthree\nfour\n");
4160
4161        mb.apply_edit(Edit::insert(Position::new(0, 0), "Z "))
4162            .await
4163            .expect("insert should land locally");
4164        assert_eq!(mb.snapshot().buffer.as_string(), "Z two\nthree\nfour\n");
4165    }
4166
4167    #[tokio::test(flavor = "multi_thread")]
4168    async fn m3_delete_within_excerpt_translates() {
4169        // M.11: composed snapshot reflects edit synchronously.
4170        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
4171        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
4172        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4173
4174        use lattice_protocol::position::Range;
4175        let _ = mb
4176            .apply_edit(Edit::delete(Range::new(
4177                Position::new(1, 0),
4178                Position::new(2, 0),
4179            )))
4180            .await
4181            .expect("delete should land locally");
4182        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\ngamma\n");
4183    }
4184
4185    #[tokio::test(flavor = "multi_thread")]
4186    async fn m3_out_of_range_edit_returns_read_only() {
4187        // M.11: the local composed_doc is a regular `Document`,
4188        // so its `apply_edit` rejects out-of-range edits via
4189        // `CoreError`. Any Err variant is acceptable — the
4190        // contract is "out-of-range fails, doesn't panic."
4191        let (sources, ids) = make_sources(&["a\nb\n"]);
4192        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
4193        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4194
4195        assert!(
4196            mb.apply_edit(Edit::insert(Position::new(50, 0), "x"))
4197                .await
4198                .is_err(),
4199            "out-of-range edit must fail (any Err variant)"
4200        );
4201    }
4202
4203    #[tokio::test(flavor = "multi_thread")]
4204    async fn m3_boundary_clip_drops_cross_excerpt_tail() {
4205        // Two excerpts from two different sources.
4206        let (mut sources, ids) = make_sources(&["AA\nBB\nCC\n", "11\n22\n33\n"]);
4207        // sources contains both; ids[0] = A-source, ids[1] = B-source.
4208        let excerpts = vec![
4209            // composed rows 0..=2 — A
4210            Excerpt::new(ids[0], 0, 2),
4211            // composed rows 3..=5 — B
4212            Excerpt::new(ids[1], 0, 2),
4213        ];
4214        // Snapshot the original B-source text for the post-edit assertion.
4215        let b_handle = sources.remove(&ids[1]).expect("B source present");
4216        sources.insert(ids[1], b_handle.clone());
4217        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4218        let original_b_text = b_handle.text();
4219
4220        // Cross-excerpt delete: range (0,0)..(5,0) — spans into B.
4221        use lattice_protocol::position::Range;
4222        let _ = mb
4223            .apply_edit(Edit::delete(Range::new(
4224                Position::new(0, 0),
4225                Position::new(5, 0),
4226            )))
4227            .await;
4228
4229        // A was edited (boundary-clipped to A's last row).
4230        // B was NOT edited (boundary clip dropped the tail).
4231        assert_eq!(
4232            b_handle.text(),
4233            original_b_text,
4234            "B source must be untouched"
4235        );
4236    }
4237
4238    #[tokio::test(flavor = "multi_thread")]
4239    async fn m3_apply_edit_batch_serialises_inserts() {
4240        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
4241        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
4242        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4243
4244        // Two inserts in row order. Batch dispatches them
4245        // sequentially; second insert sees the buffer state
4246        // after the first.
4247        let edits = vec![
4248            Edit::insert(Position::new(0, 0), "<"),
4249            Edit::insert(Position::new(2, 5), ">"),
4250        ];
4251        let results = mb.apply_edit_batch(edits).await.expect("batch ok");
4252        assert_eq!(results.len(), 2);
4253        // M.11: composed snapshot reflects edits synchronously.
4254        // After "<" at (0,0): "<alpha\nbeta\ngamma\n"
4255        // After ">" at composed (2,5) = source (2,5): "<alpha\nbeta\ngamma>\n"
4256        assert_eq!(mb.snapshot().buffer.as_string(), "<alpha\nbeta\ngamma>\n");
4257    }
4258
4259    /// 2026-06-02 cursor-jump regression: an excerpt that
4260    /// covers SOURCE rows 5..=7 maps to COMPOSED rows 0..=2.
4261    /// An insert at composed (0, 5) hits source (5, 5). The
4262    /// host's insert-mode path
4263    /// (`lattice-host::dispatch::do_insert_str_blocking`)
4264    /// reads `applied.inserted_range.end.line` and sets the
4265    /// cursor — if `apply_edit` returned the source's
4266    /// inserted_range.end (line 5) instead of the composed
4267    /// equivalent (line 0), the cursor would jump to line 5
4268    /// of the composed view, which renders the wrong text and
4269    /// breaks every subsequent insert. Verify the translation
4270    /// happens.
4271    #[tokio::test(flavor = "multi_thread")]
4272    async fn m3_apply_edit_returns_composed_coords() {
4273        let (sources, ids) = make_sources(&["a\nb\nc\nd\ne\nf\ng\nh\n"]);
4274        // Excerpt covers source rows 5..=7 → composed rows
4275        // 0..=2.
4276        let excerpts = vec![Excerpt::new(ids[0], 5, 7)];
4277        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4278
4279        // Insert "X" at composed (0, 0). In source coords
4280        // that's (5, 0). The host's cursor advance reads
4281        // `applied.inserted_range.end`; pre-fix that returned
4282        // `Position { line: 5, byte: 1 }` (source coords),
4283        // jumping the cursor to composed row 5 — past the
4284        // multibuffer's three composed rows.
4285        let applied = mb
4286            .apply_edit(Edit::insert(Position::new(0, 0), "X"))
4287            .await
4288            .expect("edit ok");
4289
4290        assert_eq!(
4291            applied.inserted_range.start,
4292            Position::new(0, 0),
4293            "start must be composed (0,0), not source (5,0)"
4294        );
4295        assert_eq!(
4296            applied.inserted_range.end,
4297            Position::new(0, 1),
4298            "end must be composed (0,1), not source (5,1) — \
4299             this is the cursor-jump bug"
4300        );
4301        assert_eq!(applied.original_range.start, Position::new(0, 0));
4302        assert_eq!(applied.original_range.end, Position::new(0, 0));
4303        // EditDelta positions also translated.
4304        assert_eq!(applied.delta.start_position, Position::new(0, 0));
4305        assert_eq!(applied.delta.new_end_position, Position::new(0, 1));
4306    }
4307
4308    /// Same property for `apply_edit_batch` — each result in
4309    /// the batch must carry composed coords for that edit's
4310    /// excerpt.
4311    #[tokio::test(flavor = "multi_thread")]
4312    async fn m3_apply_edit_batch_returns_composed_coords() {
4313        // Two excerpts: source rows 5..=5 (composed 0..=0) and
4314        // source rows 10..=10 (composed 1..=1).
4315        let (sources, ids) = make_sources(&["0\n1\n2\n3\n4\n5\n6\n7\n8\n9\nA\nB\n"]);
4316        let excerpts = vec![Excerpt::new(ids[0], 5, 5), Excerpt::new(ids[0], 10, 10)];
4317        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4318
4319        let results = mb
4320            .apply_edit_batch(vec![
4321                Edit::insert(Position::new(0, 0), "X"),
4322                Edit::insert(Position::new(1, 0), "Y"),
4323            ])
4324            .await
4325            .expect("batch ok");
4326
4327        assert_eq!(results.len(), 2);
4328        // First result: composed row 0 (was source row 5).
4329        assert_eq!(
4330            results[0].inserted_range.end,
4331            Position::new(0, 1),
4332            "first batch result must be composed (0,1)"
4333        );
4334        // Second result: composed row 1 (was source row 10).
4335        // Note: the second edit's actual source row after the
4336        // first edit lands is 10 (the first insert was at
4337        // source col 0 of row 5, only widening that row's
4338        // bytes — row indices unchanged). Composed row 1.
4339        assert_eq!(
4340            results[1].inserted_range.end,
4341            Position::new(1, 1),
4342            "second batch result must be composed (1,1)"
4343        );
4344    }
4345
4346    /// 2026-06-02 stale-snapshot regression: typing a character
4347    /// in a multibuffer must update the composed snapshot the
4348    /// renderer reads on the next frame. Pre-fix the host's
4349    /// `publish_document_changed` fired under the multibuffer's
4350    /// id (not the source's), so the M.4 forwarder ignored it
4351    /// and the composed snapshot stayed pre-edit. Cursor would
4352    /// advance correctly (translate_applied_to_composed) but the
4353    /// rendered text never changed. Verify the snapshot updates
4354    /// synchronously after apply_edit returns.
4355    #[tokio::test(flavor = "multi_thread")]
4356    async fn m3_apply_edit_updates_composed_snapshot_without_forwarder() {
4357        let (sources, ids) = make_sources(&["hello\n"]);
4358        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
4359        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4360        // No attach_event_subscriptions call — production path
4361        // does it in create_multibuffer_view, but the forwarder
4362        // wouldn't fire here anyway because no event bus is
4363        // wired. Verify apply_edit's own recompose lands.
4364        assert_eq!(mb.snapshot().buffer.as_string(), "hello\n");
4365
4366        let _ = mb
4367            .apply_edit(Edit::insert(Position::new(0, 5), "!"))
4368            .await
4369            .expect("edit ok");
4370
4371        assert_eq!(
4372            mb.snapshot().buffer.as_string(),
4373            "hello!\n",
4374            "composed snapshot must reflect the new content; \
4375             the renderer reads this on the next frame"
4376        );
4377    }
4378
4379    /// M.10.2 (2026-06-03): cursor at composed (0, 5) on an
4380    /// excerpt covering source rows 5..=7 maps to source
4381    /// (5, 5). Byte column preserved (each composed row is a
4382    /// verbatim copy of its source line).
4383    #[tokio::test(flavor = "multi_thread")]
4384    async fn m10_2_translate_composed_to_source_single_excerpt() {
4385        let (sources, ids) = make_sources(&["0\n1\n2\n3\n4\n5\n6\n7\n8\n"]);
4386        let excerpts = vec![Excerpt::new(ids[0], 5, 7)];
4387        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4388
4389        let target = mb
4390            .translate_composed_to_source(Position::new(0, 5))
4391            .expect("composed (0,5) must translate");
4392        assert_eq!(target.0, ids[0], "source buffer id");
4393        assert_eq!(target.1, Position::new(5, 5), "source position");
4394
4395        // Composed (2, 0) → source (5 + 2, 0) = (7, 0).
4396        let target = mb
4397            .translate_composed_to_source(Position::new(2, 0))
4398            .expect("composed (2,0) must translate");
4399        assert_eq!(target.1, Position::new(7, 0));
4400    }
4401
4402    // ---- N.1.5: ComposedScopeResolver (composed↔source text objects) ----
4403
4404    use lattice_grammar::ScopeResolver as _;
4405
4406    fn rust_snapshot(src: &str) -> Arc<SyntaxSnapshot> {
4407        let lr = LangRegistry::standard().unwrap();
4408        let mut syntax = Syntax::for_language_with_registry(Lang::Rust, lr)
4409            .unwrap()
4410            .unwrap();
4411        syntax.parse(src);
4412        SyntaxHandle::seeded(syntax).snapshot()
4413    }
4414
4415    #[test]
4416    fn composed_resolver_maps_function_outer_to_source() {
4417        // Source: a `target` fn on rows 1..=3, narrowed into a one-excerpt
4418        // view (composed rows 0..=2 == source rows 1..=3).
4419        let src = "fn keep_a() {}\nfn target() {\n    let x = 1;\n}\nfn keep_b() {}\n";
4420        let snap = rust_snapshot(src);
4421        let resolver = ComposedScopeResolver {
4422            excerpts: vec![ComposedExcerptSpan {
4423                source: BufferId(1),
4424                start_line: 1,
4425                line_count: 3,
4426                composed_offset: 0,
4427            }],
4428            snapshots: HashMap::from([(BufferId(1), snap)]),
4429        };
4430        // Cursor at composed (1,4) == source (2,4), inside `target`'s body.
4431        // `af` resolves the whole function (source rows 1..=3) and maps
4432        // back to composed rows 0..=2.
4433        let r = resolver.scope_at(1, 4, "function.outer");
4434        assert_eq!(
4435            r,
4436            Some(lattice_protocol::position::Range::new(
4437                lattice_protocol::position::Position::new(0, 0),
4438                lattice_protocol::position::Position::new(2, 1),
4439            )),
4440            "af inside a narrow view resolves the source function, mapped to composed rows"
4441        );
4442    }
4443
4444    #[test]
4445    fn composed_resolver_clamps_scope_to_excerpt() {
4446        // Excerpt covers only source row 2 (the body line). `af` resolves
4447        // the function (source 1..=3) but the result is CLAMPED to the
4448        // single visible row — never out-of-bounds composed rows.
4449        let src = "fn keep_a() {}\nfn target() {\n    let x = 1;\n}\nfn keep_b() {}\n";
4450        let snap = rust_snapshot(src);
4451        let resolver = ComposedScopeResolver {
4452            excerpts: vec![ComposedExcerptSpan {
4453                source: BufferId(1),
4454                start_line: 2,
4455                line_count: 1,
4456                composed_offset: 0,
4457            }],
4458            snapshots: HashMap::from([(BufferId(1), snap)]),
4459        };
4460        let r = resolver.scope_at(0, 4, "function.outer");
4461        assert_eq!(
4462            r,
4463            Some(lattice_protocol::position::Range::new(
4464                lattice_protocol::position::Position::new(0, 0),
4465                lattice_protocol::position::Position::new(0, 0),
4466            )),
4467            "a scope extending past the excerpt is clipped to the visible rows"
4468        );
4469    }
4470
4471    #[test]
4472    fn composed_resolver_applies_composed_offset_for_second_excerpt() {
4473        // Two excerpts from the same source: [0,0] then [1,3]. The second
4474        // sits at composed_offset 1, so resolving inside it must add the
4475        // offset back when mapping the source range to composed rows.
4476        let src = "fn keep_a() {}\nfn target() {\n    let x = 1;\n}\nfn keep_b() {}\n";
4477        let snap = rust_snapshot(src);
4478        let resolver = ComposedScopeResolver {
4479            excerpts: vec![
4480                ComposedExcerptSpan {
4481                    source: BufferId(1),
4482                    start_line: 0,
4483                    line_count: 1,
4484                    composed_offset: 0,
4485                },
4486                ComposedExcerptSpan {
4487                    source: BufferId(1),
4488                    start_line: 1,
4489                    line_count: 3,
4490                    composed_offset: 1,
4491                },
4492            ],
4493            snapshots: HashMap::from([(BufferId(1), snap)]),
4494        };
4495        // Composed row 2 lives in the second excerpt → source row 2.
4496        let r = resolver.scope_at(2, 4, "function.outer");
4497        assert_eq!(
4498            r,
4499            Some(lattice_protocol::position::Range::new(
4500                lattice_protocol::position::Position::new(1, 0),
4501                lattice_protocol::position::Position::new(3, 1),
4502            )),
4503            "the second excerpt's composed_offset (1) is added to the mapped rows"
4504        );
4505    }
4506
4507    /// M.10.2 (2026-06-03): out-of-range composed cursor
4508    /// returns None (no excerpt covers that row).
4509    #[tokio::test(flavor = "multi_thread")]
4510    async fn m10_2_translate_composed_to_source_out_of_range() {
4511        let (sources, ids) = make_sources(&["a\nb\n"]);
4512        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
4513        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4514
4515        // Excerpt covers composed rows 0..=1; row 5 is past.
4516        assert!(
4517            mb.translate_composed_to_source(Position::new(5, 0))
4518                .is_none()
4519        );
4520    }
4521
4522    /// M.10.2 (2026-06-03): multi-excerpt walk — cursor on the
4523    /// second excerpt maps to its source.
4524    #[tokio::test(flavor = "multi_thread")]
4525    async fn m10_2_translate_composed_to_source_multi_excerpt() {
4526        let (sources, ids) = make_sources(&["AA\nBB\n", "11\n22\n"]);
4527        let excerpts = vec![
4528            Excerpt::new(ids[0], 0, 1), // composed 0..=1
4529            Excerpt::new(ids[1], 0, 1), // composed 2..=3
4530        ];
4531        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4532
4533        // Composed (0, 1) → source A (0, 1).
4534        let t = mb
4535            .translate_composed_to_source(Position::new(0, 1))
4536            .unwrap();
4537        assert_eq!(t.0, ids[0]);
4538        assert_eq!(t.1, Position::new(0, 1));
4539
4540        // Composed (3, 1) → source B (1, 1).
4541        let t = mb
4542            .translate_composed_to_source(Position::new(3, 1))
4543            .unwrap();
4544        assert_eq!(t.0, ids[1]);
4545        assert_eq!(t.1, Position::new(1, 1));
4546    }
4547
4548    /// M.11 (2026-06-02): the search provider's exact flow —
4549    /// construct an empty multibuffer, then stream excerpts via
4550    /// `append_excerpts`, then type. Pre-fix the composed_doc
4551    /// stayed empty (only state.excerpts + the published
4552    /// snapshot reflected the stream), so user inserts hit an
4553    /// empty rope and silently no-op'd through
4554    /// do_insert_text's `let Ok(applied) = … else { return };`.
4555    /// This was the root cause of "typing does nothing" the
4556    /// user reported after M.11 first landed.
4557    #[tokio::test(flavor = "multi_thread")]
4558    async fn m11_streamed_excerpts_keep_composed_doc_in_sync_for_insert() {
4559        // Empty construction — exactly what
4560        // `project_search → create_multibuffer_view` does for
4561        // an in-progress scan.
4562        let (sources, ids) = make_sources(&["zero\none\ntwo\nthree\nfour\n"]);
4563        let mb = MultibufferDocumentHandle::new(sources, vec![], empty_registry()).unwrap();
4564        assert_eq!(mb.snapshot().buffer.as_string(), "");
4565
4566        // Stream an excerpt (the search hit).
4567        mb.append_excerpts(vec![Excerpt::new(ids[0], 2, 2)]);
4568        assert_eq!(
4569            mb.snapshot().buffer.as_string(),
4570            "two\n",
4571            "snapshot must reflect the streamed excerpt"
4572        );
4573
4574        // Now type — insert at the end of the line.
4575        let applied = mb
4576            .apply_edit(Edit::insert(Position::new(0, 3), "X"))
4577            .await
4578            .expect("post-stream insert must succeed (pre-fix this Err'd silently)");
4579        assert_eq!(applied.inserted_range.end, Position::new(0, 4));
4580        assert_eq!(
4581            mb.snapshot().buffer.as_string(),
4582            "twoX\n",
4583            "composed snapshot must reflect the insert; \
4584             pre-fix the snapshot stayed at \"two\\n\" because \
4585             composed_doc was empty and apply_edit no-op'd"
4586        );
4587    }
4588
4589    /// 2026-06-02: simulate the user's insert-mode flow — two
4590    /// consecutive single-char apply_edit calls. After each, the
4591    /// composed snapshot must reflect the cumulative text. The
4592    /// user reported the first char landed visibly but the second
4593    /// didn't ("switched to how it was before, every character in
4594    /// insert mode just moves the cursor along").
4595    #[tokio::test(flavor = "multi_thread")]
4596    async fn m3_consecutive_inserts_accumulate_in_composed_snapshot() {
4597        // Source with 8 lines; excerpt covers source row 5 only.
4598        let (sources, ids) = make_sources(&["0\n1\n2\n3\n4\nfive\n6\n7\n"]);
4599        let excerpts = vec![Excerpt::new(ids[0], 5, 5)];
4600        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4601        assert_eq!(mb.snapshot().buffer.as_string(), "five\n");
4602
4603        // First insert at end-of-line (composed (0, 4)) — vim
4604        // `a` at end-of-line lands here. Append 'x'.
4605        let r1 = mb
4606            .apply_edit(Edit::insert(Position::new(0, 4), "x"))
4607            .await
4608            .expect("first edit ok");
4609        assert_eq!(
4610            r1.inserted_range.end,
4611            Position::new(0, 5),
4612            "first cursor must be composed (0,5)"
4613        );
4614        assert_eq!(
4615            mb.snapshot().buffer.as_string(),
4616            "fivex\n",
4617            "first char must land in composed snapshot"
4618        );
4619
4620        // Second insert at composed (0, 5) — append 'y'.
4621        let r2 = mb
4622            .apply_edit(Edit::insert(Position::new(0, 5), "y"))
4623            .await
4624            .expect("second edit ok");
4625        assert_eq!(
4626            r2.inserted_range.end,
4627            Position::new(0, 6),
4628            "second cursor must be composed (0,6)"
4629        );
4630        assert_eq!(
4631            mb.snapshot().buffer.as_string(),
4632            "fivexy\n",
4633            "second char must accumulate; not revert to original"
4634        );
4635
4636        // Third insert at composed (0, 6) — append 'z'.
4637        let r3 = mb
4638            .apply_edit(Edit::insert(Position::new(0, 6), "z"))
4639            .await
4640            .expect("third edit ok");
4641        assert_eq!(r3.inserted_range.end, Position::new(0, 7));
4642        assert_eq!(mb.snapshot().buffer.as_string(), "fivexyz\n");
4643    }
4644
4645    // ─────────────────────────────────────────────────────────────
4646    // M.4 tests
4647    // ─────────────────────────────────────────────────────────────
4648
4649    #[tokio::test(flavor = "multi_thread")]
4650    async fn m4_headerline_starts_idle_and_can_be_set() {
4651        let (sources, ids) = make_sources(&["x\n"]);
4652        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
4653        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4654
4655        assert!(matches!(*mb.headerline(), HeaderlineStatus::Idle));
4656
4657        mb.set_headerline(HeaderlineStatus::InProgress {
4658            label: "Searching".into(),
4659            count: Some(42),
4660            emphasis: None,
4661        });
4662        match &*mb.headerline() {
4663            HeaderlineStatus::InProgress { label, count, .. } => {
4664                assert_eq!(label, "Searching");
4665                assert_eq!(*count, Some(42));
4666            }
4667            other => panic!("expected InProgress, got {other:?}"),
4668        }
4669
4670        mb.set_headerline(HeaderlineStatus::Complete {
4671            summary: "87 hits".into(),
4672            emphasis: None,
4673        });
4674        match &*mb.headerline() {
4675            HeaderlineStatus::Complete { summary, .. } => assert_eq!(summary, "87 hits"),
4676            other => panic!("expected Complete, got {other:?}"),
4677        }
4678    }
4679
4680    #[tokio::test(flavor = "multi_thread")]
4681    async fn m4_set_headerline_publishes_changed_event_when_attached() {
4682        let bus = Arc::new(lattice_runtime::EventBus::new());
4683        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<MultibufferHeaderlineChanged>();
4684        bus.subscribe_typed::<MultibufferHeaderlineChanged>(tx);
4685
4686        let (sources, ids) = make_sources(&["y\n"]);
4687        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
4688        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4689        let view_id = mb.buffer_id();
4690        mb.attach_event_subscriptions(&bus);
4691
4692        mb.set_headerline(HeaderlineStatus::Complete {
4693            summary: "done".into(),
4694            emphasis: None,
4695        });
4696
4697        let evt = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
4698            .await
4699            .expect("event should arrive")
4700            .expect("channel open");
4701        assert_eq!(evt.view, view_id);
4702        assert!(matches!(evt.status, HeaderlineStatus::Complete { .. }));
4703    }
4704
4705    #[tokio::test(flavor = "multi_thread")]
4706    async fn m4_source_change_auto_recomposes_view() {
4707        let bus = Arc::new(lattice_runtime::EventBus::new());
4708        let (sources, ids) = make_sources(&["alpha\nbeta\n"]);
4709        let source_handle = sources.get(&ids[0]).unwrap().clone();
4710        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
4711        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4712        mb.attach_event_subscriptions(&bus);
4713        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\nbeta\n");
4714
4715        // Source edit publishes DocumentChanged on the bus the
4716        // multibuffer subscribed to. After a brief yield, the
4717        // forwarder task should have recomposed.
4718        // The mock setup above doesn't wire the source handle to
4719        // PUBLISH on the bus — `spawn_document` publishes events
4720        // only when given a bus. So this test verifies the
4721        // SUBSCRIBE path: directly publish a DocumentChanged
4722        // event with the source's DocumentId and confirm the
4723        // multibuffer recomposes.
4724        source_handle
4725            .apply_edit(Edit::insert(Position::new(0, 0), "<"))
4726            .await
4727            .unwrap();
4728        // Simulate the source's DocumentChanged publish.
4729        bus.publish(lattice_protocol::Event::DocumentChanged {
4730            id: source_handle.id(),
4731            path: None,
4732            version: source_handle.version(),
4733            edits: Vec::new(),
4734        });
4735
4736        // Wait for the spawned forwarder to process. Longer
4737        // budget than yield_now because tokio's multi-thread
4738        // runtime may park the task briefly.
4739        for _ in 0..50 {
4740            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4741            if mb.snapshot().buffer.as_string() != "alpha\nbeta\n" {
4742                break;
4743            }
4744        }
4745        assert_eq!(mb.snapshot().buffer.as_string(), "<alpha\nbeta\n");
4746    }
4747
4748    /// A source edit must reparse that source's syntax, not just recompose.
4749    ///
4750    /// The reported bug: a task toggled to DONE in the agenda kept painting in
4751    /// TODO's colour, and `<C-l>` did not fix it either. `add_source` built
4752    /// handles with `SyntaxHandle::seeded` (`on_publish: None`), and nothing
4753    /// anywhere called `request_reparse` on one — so a source handle parsed
4754    /// once at creation and its snapshot was frozen for the life of the view.
4755    /// The `DocumentChanged` arm recomposed the TEXT beside it, which is
4756    /// exactly why the symptom reads as "new text, old colours" rather than
4757    /// "nothing updates".
4758    ///
4759    /// Asserted through `excerpt_syntax_version`, because that is the value
4760    /// the host folds into `MatrixVersion::syntax`: a reparse the cells worker
4761    /// cannot see is a reparse that changes nothing on screen.
4762    ///
4763    /// **No second edit, no keypress.** The wake is the other half of the fix
4764    /// and a test that nudged the view first would pass on the frozen build.
4765    #[tokio::test(flavor = "multi_thread")]
4766    async fn a_source_edit_reparses_that_sources_syntax() {
4767        let bus = Arc::new(lattice_runtime::EventBus::new());
4768        let unique = std::time::SystemTime::now()
4769            .duration_since(std::time::UNIX_EPOCH)
4770            .unwrap()
4771            .as_nanos();
4772        let path = std::env::temp_dir().join(format!("lattice-mb-reparse-{unique}.rs"));
4773        let text = "fn main() { let x = 1; }\n";
4774        std::fs::write(&path, text).unwrap();
4775
4776        let id = BufferId::next();
4777        let doc = lattice_core::DocumentBuilder::default()
4778            .with_text(text)
4779            .with_path(path.clone())
4780            .build();
4781        let source_handle = spawn_document(id, doc, empty_registry());
4782        let source: Arc<dyn Document> = Arc::new(source_handle.clone());
4783        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
4784        sources.insert(id, source);
4785        let excerpts = vec![Excerpt::new(id, 0, 0)];
4786        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4787        mb.attach_event_subscriptions(&bus);
4788
4789        // Highlighting is only wired once a language registry exists; without
4790        // it there is no handle and this test would pass vacuously.
4791        let Ok(live) = lattice_syntax::registry::live() else {
4792            eprintln!("skipping: no live language registry in this process");
4793            return;
4794        };
4795        mb.set_lang_registry(live);
4796        if mb.excerpt_highlights().is_empty() {
4797            eprintln!("skipping: no grammar registered for .rs in this process");
4798            return;
4799        }
4800        let before = mb.excerpt_syntax_version();
4801
4802        source_handle
4803            .apply_edit(Edit::insert(Position::new(0, 0), "//"))
4804            .await
4805            .unwrap();
4806        bus.publish(lattice_protocol::Event::DocumentChanged {
4807            id: source_handle.id(),
4808            path: Some(path.clone()),
4809            version: source_handle.version(),
4810            edits: Vec::new(),
4811        });
4812
4813        let mut moved = false;
4814        for _ in 0..100 {
4815            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4816            if mb.excerpt_syntax_version() != before {
4817                moved = true;
4818                break;
4819            }
4820        }
4821        assert!(
4822            moved,
4823            "the source's syntax must reparse and bump the version the cells \
4824             worker invalidates on; frozen at {before} means the view keeps \
4825             painting the old spans forever"
4826        );
4827    }
4828
4829    /// An edit made THROUGH the view must reparse its source too.
4830    ///
4831    /// This is the path an agenda `DONE` toggle takes: org's `rewrite_headline`
4832    /// reads composed rows and targets the VIEW, so the edit lands in the
4833    /// composed rope and is forwarded to the source. That forward used to
4834    /// apply the edit and publish nothing — "the multibuffer's local
4835    /// composed_doc is already authoritative" — so the source changed
4836    /// silently and NOTHING downstream learned of it: not this view's syntax,
4837    /// not a second view on the same file, not the diff subsystem.
4838    ///
4839    /// The sibling test above drives the same repair from an OUTSIDE change
4840    /// (a synthetic `DocumentChanged`). It passes with the forwarder still
4841    /// silent, which is exactly why this one has to exist.
4842    #[tokio::test(flavor = "multi_thread")]
4843    async fn an_edit_through_the_view_reparses_its_source() {
4844        let bus = Arc::new(lattice_runtime::EventBus::new());
4845        let unique = std::time::SystemTime::now()
4846            .duration_since(std::time::UNIX_EPOCH)
4847            .unwrap()
4848            .as_nanos();
4849        let path = std::env::temp_dir().join(format!("lattice-mb-through-{unique}.rs"));
4850        let text = "fn main() { let x = 1; }\nfn other() {}\n";
4851        std::fs::write(&path, text).unwrap();
4852
4853        let id = BufferId::next();
4854        let doc = lattice_core::DocumentBuilder::default()
4855            .with_text(text)
4856            .with_path(path.clone())
4857            .build();
4858        let source: Arc<dyn Document> = Arc::new(spawn_document(id, doc, empty_registry()));
4859        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
4860        sources.insert(id, source);
4861        let excerpts = vec![Excerpt::new(id, 0, 1)];
4862        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4863        mb.attach_event_subscriptions(&bus);
4864
4865        let Ok(live) = lattice_syntax::registry::live() else {
4866            eprintln!("skipping: no live language registry in this process");
4867            return;
4868        };
4869        mb.set_lang_registry(live);
4870        if mb.excerpt_highlights().is_empty() {
4871            eprintln!("skipping: no grammar registered for .rs in this process");
4872            return;
4873        }
4874        let before_syntax = mb.excerpt_syntax_version();
4875        let before_rows = mb.snapshot().buffer.as_string();
4876
4877        // Through the VIEW, at composed coordinates — no synthetic event.
4878        mb.apply_edit(Edit::insert(Position::new(0, 0), "//"))
4879            .await
4880            .expect("composed edit applies");
4881
4882        // Nothing else is dispatched: the forwarder's announce is the only
4883        // thing that can carry this to the source's syntax.
4884        let mut moved = false;
4885        for _ in 0..100 {
4886            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
4887            if mb.excerpt_syntax_version() != before_syntax {
4888                moved = true;
4889                break;
4890            }
4891        }
4892        assert!(
4893            moved,
4894            "an edit through the view must reach the source's syntax; frozen \
4895             at {before_syntax} is the DONE-painted-as-TODO bug"
4896        );
4897
4898        // …and the echo must not be mistaken for an outside change. Sliding
4899        // anchors for an edit the composed rope already accounted for would
4900        // move every row below the edited one.
4901        assert_eq!(
4902            mb.snapshot().buffer.as_string(),
4903            format!("//{before_rows}"),
4904            "the composed rows must be the edit and nothing else — a second \
4905             anchor slide shows up here as duplicated or dropped rows"
4906        );
4907    }
4908
4909    /// Regression (2026-09-28): the back-fill path — `new(sources)` then
4910    /// `set_lang_registry` — must resolve grammars against the LIVE registry,
4911    /// not the `lr` it is handed. `*problems*` and the references view take
4912    /// this path, and production wires it with the host's BOOT-SNAPSHOT
4913    /// registry (bundled-only, and for a plugin grammar or a stale capture it
4914    /// lacks the language entirely), so every excerpt painted uncoloured while
4915    /// project-search — which streams through `add_source` on the live
4916    /// registry — highlighted fine. That asymmetry was the whole bug: "the
4917    /// *problems* buffer should have syntax highlighting like any other
4918    /// multibuffer view."
4919    ///
4920    /// The test hands `set_lang_registry` a DELIBERATELY-EMPTY registry as the
4921    /// snapshot. Pre-fix that empty registry was what `.rs` resolved against,
4922    /// so `excerpt_highlights()` came back empty; post-fix resolution goes
4923    /// through `registry::live()` and the excerpt highlights regardless of
4924    /// what the snapshot held. A test that passed `live()` here (as the two
4925    /// above do) would pass on the broken build too — the empty snapshot is
4926    /// the point.
4927    #[test]
4928    fn back_fill_resolves_against_live_not_the_passed_registry() {
4929        // Skip only when this process has no bundled `.rs` grammar at all —
4930        // then even the correct behaviour cannot produce a handle, so an empty
4931        // result would be a false negative rather than the regression.
4932        let Ok(live) = lattice_syntax::registry::live() else {
4933            eprintln!("skipping: no live language registry in this process");
4934            return;
4935        };
4936        if !matches!(
4937            Syntax::for_language_with_registry(Lang::Rust, live.clone()),
4938            Ok(Some(_))
4939        ) {
4940            eprintln!("skipping: no .rs grammar registered in this process");
4941            return;
4942        }
4943
4944        let unique = std::time::SystemTime::now()
4945            .duration_since(std::time::UNIX_EPOCH)
4946            .unwrap()
4947            .as_nanos();
4948        let path = std::env::temp_dir().join(format!("lattice-mb-backfill-{unique}.rs"));
4949        let text = "fn main() { let x = 1; }\n";
4950        std::fs::write(&path, text).unwrap();
4951
4952        let id = BufferId::next();
4953        let doc = lattice_core::DocumentBuilder::default()
4954            .with_text(text)
4955            .with_path(path.clone())
4956            .build();
4957        let source: Arc<dyn Document> = Arc::new(spawn_document(id, doc, empty_registry()));
4958        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
4959        sources.insert(id, source);
4960        let excerpts = vec![Excerpt::new(id, 0, 0)];
4961        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4962
4963        // The boot-snapshot analogue: a registry that contains NO grammars.
4964        // Pre-fix, this is what `.rs` would (fail to) resolve against.
4965        let empty_snapshot = Arc::new(LangRegistry::default());
4966        mb.set_lang_registry(empty_snapshot);
4967
4968        assert!(
4969            !mb.excerpt_highlights().is_empty(),
4970            "the back-fill must resolve grammars against the LIVE registry, not \
4971             the (here empty) snapshot it was handed; an empty result means \
4972             *problems* / references excerpts paint uncoloured while search does not"
4973        );
4974
4975        let _ = std::fs::remove_file(&path);
4976    }
4977
4978    #[tokio::test(flavor = "multi_thread")]
4979    async fn m4_source_close_publishes_typed_event_and_prunes() {
4980        let bus = Arc::new(lattice_runtime::EventBus::new());
4981        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<MultibufferSourceClosed>();
4982        bus.subscribe_typed::<MultibufferSourceClosed>(tx);
4983
4984        let (sources, ids) = make_sources(&["a\n", "b\n"]);
4985        let source_a_handle = sources.get(&ids[0]).unwrap().clone();
4986        let excerpts = vec![Excerpt::new(ids[0], 0, 0), Excerpt::new(ids[1], 0, 0)];
4987        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
4988        let view_id = mb.buffer_id();
4989        mb.attach_event_subscriptions(&bus);
4990        assert_eq!(mb.source_buffer_ids().len(), 2);
4991
4992        // Publish DocumentClosed for source A.
4993        bus.publish(lattice_protocol::Event::DocumentClosed {
4994            id: source_a_handle.id(),
4995        });
4996
4997        let evt = tokio::time::timeout(std::time::Duration::from_millis(200), rx.recv())
4998            .await
4999            .expect("event should arrive")
5000            .expect("channel open");
5001        assert_eq!(evt.view, view_id);
5002        assert_eq!(evt.source, ids[0]);
5003
5004        // Source A pruned from the map.
5005        for _ in 0..10 {
5006            tokio::task::yield_now().await;
5007            if mb.source_buffer_ids().len() == 1 {
5008                break;
5009            }
5010        }
5011        assert_eq!(mb.source_buffer_ids(), vec![ids[1]]);
5012    }
5013
5014    // ─────────────────────────────────────────────────────────────
5015    // M.4.1 tests — anchor sliding
5016    // ─────────────────────────────────────────────────────────────
5017
5018    fn applied_edit(
5019        old_start: (u32, u32),
5020        old_end: (u32, u32),
5021        new_end: (u32, u32),
5022        replaced: &str,
5023        inserted: &str,
5024    ) -> lattice_protocol::event::AppliedEdit {
5025        use lattice_protocol::position::Range;
5026        lattice_protocol::event::AppliedEdit {
5027            original_range: Range::new(
5028                Position::new(old_start.0, old_start.1),
5029                Position::new(old_end.0, old_end.1),
5030            ),
5031            inserted_range: Range::new(
5032                Position::new(old_start.0, old_start.1),
5033                Position::new(new_end.0, new_end.1),
5034            ),
5035            replaced_text: replaced.into(),
5036            inserted_text: inserted.into(),
5037        }
5038    }
5039
5040    async fn pump_forwarder() {
5041        for _ in 0..30 {
5042            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
5043        }
5044    }
5045
5046    #[tokio::test(flavor = "multi_thread")]
5047    async fn m41_insert_above_excerpt_slides_down() {
5048        let bus = Arc::new(lattice_runtime::EventBus::new());
5049        let (sources, ids) = make_sources(&["aa\nbb\ncc\ndd\nee\n"]);
5050        let src = sources.get(&ids[0]).unwrap().clone();
5051        // Excerpt covers source rows 2-3 (cc, dd).
5052        let excerpts = vec![Excerpt::new(ids[0], 2, 3)];
5053        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5054        mb.attach_event_subscriptions(&bus);
5055
5056        // Synthesise: edit at line 0 byte 0 → line 0 byte 0
5057        // inserts 2 lines of content (row_delta = +2).
5058        // original_range end = (0, 0); inserted_range end = (2, 0).
5059        let edit = applied_edit((0, 0), (0, 0), (2, 0), "", "X\nY\n");
5060        bus.publish(lattice_protocol::Event::DocumentChanged {
5061            id: src.id(),
5062            path: None,
5063            version: 1,
5064            edits: vec![edit],
5065        });
5066
5067        pump_forwarder().await;
5068        let excerpts_after = mb.excerpts();
5069        assert_eq!(
5070            excerpts_after[0].start_line, 4,
5071            "excerpt should slide to row 4"
5072        );
5073        assert_eq!(excerpts_after[0].end_line, 5);
5074    }
5075
5076    #[tokio::test(flavor = "multi_thread")]
5077    async fn m41_delete_above_excerpt_slides_up() {
5078        let bus = Arc::new(lattice_runtime::EventBus::new());
5079        let (sources, ids) = make_sources(&["aa\nbb\ncc\ndd\nee\nff\n"]);
5080        let src = sources.get(&ids[0]).unwrap().clone();
5081        // Excerpt covers rows 4-5 (ee, ff).
5082        let excerpts = vec![Excerpt::new(ids[0], 4, 5)];
5083        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5084        mb.attach_event_subscriptions(&bus);
5085
5086        // Delete rows 0-1 (aa\nbb\n). original_range end = (2, 0);
5087        // inserted_range end = (0, 0). row_delta = -2.
5088        let edit = applied_edit((0, 0), (2, 0), (0, 0), "aa\nbb\n", "");
5089        bus.publish(lattice_protocol::Event::DocumentChanged {
5090            id: src.id(),
5091            path: None,
5092            version: 1,
5093            edits: vec![edit],
5094        });
5095
5096        pump_forwarder().await;
5097        let excerpts_after = mb.excerpts();
5098        assert_eq!(
5099            excerpts_after[0].start_line, 2,
5100            "excerpt should slide up to row 2"
5101        );
5102        assert_eq!(excerpts_after[0].end_line, 3);
5103    }
5104
5105    #[tokio::test(flavor = "multi_thread")]
5106    async fn m41_edit_below_excerpt_does_not_slide() {
5107        let bus = Arc::new(lattice_runtime::EventBus::new());
5108        let (sources, ids) = make_sources(&["aa\nbb\ncc\ndd\nee\n"]);
5109        let src = sources.get(&ids[0]).unwrap().clone();
5110        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
5111        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5112        mb.attach_event_subscriptions(&bus);
5113
5114        // Edit at row 3: original_range end = (3, 0).
5115        // excerpt.start_line = 0; condition is `old_end < start_line`
5116        // → `3 < 0` false → no slide.
5117        let edit = applied_edit((3, 0), (3, 0), (4, 0), "", "X\n");
5118        bus.publish(lattice_protocol::Event::DocumentChanged {
5119            id: src.id(),
5120            path: None,
5121            version: 1,
5122            edits: vec![edit],
5123        });
5124
5125        pump_forwarder().await;
5126        let excerpts_after = mb.excerpts();
5127        assert_eq!(excerpts_after[0].start_line, 0);
5128        assert_eq!(excerpts_after[0].end_line, 1);
5129    }
5130
5131    #[tokio::test(flavor = "multi_thread")]
5132    async fn m41_overlapping_edit_does_not_slide_excerpt() {
5133        let bus = Arc::new(lattice_runtime::EventBus::new());
5134        let (sources, ids) = make_sources(&["aa\nbb\ncc\ndd\n"]);
5135        let src = sources.get(&ids[0]).unwrap().clone();
5136        // Excerpt covers rows 1-2.
5137        let excerpts = vec![Excerpt::new(ids[0], 1, 2)];
5138        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5139        mb.attach_event_subscriptions(&bus);
5140
5141        // Edit that ends inside the excerpt (rows 0..=1):
5142        // original_range end = (2, 0). `2 < 1` false → no slide.
5143        let edit = applied_edit((0, 0), (2, 0), (1, 0), "aa\nbb\n", "X\n");
5144        bus.publish(lattice_protocol::Event::DocumentChanged {
5145            id: src.id(),
5146            path: None,
5147            version: 1,
5148            edits: vec![edit],
5149        });
5150
5151        pump_forwarder().await;
5152        // Conservative slide: excerpt stays put. Recompose
5153        // picks up new content for the now-overlapped rows.
5154        let excerpts_after = mb.excerpts();
5155        assert_eq!(excerpts_after[0].start_line, 1);
5156        assert_eq!(excerpts_after[0].end_line, 2);
5157    }
5158
5159    #[tokio::test(flavor = "multi_thread")]
5160    async fn m41_other_source_edits_dont_slide_this_source() {
5161        let bus = Arc::new(lattice_runtime::EventBus::new());
5162        let (sources, ids) = make_sources(&["aa\nbb\n", "11\n22\n"]);
5163        let src_b = sources.get(&ids[1]).unwrap().clone();
5164        // Excerpt of source A at rows 0-1; source B has its own.
5165        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
5166        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5167        mb.attach_event_subscriptions(&bus);
5168
5169        // Insert 5 rows in source B above row 0. Should NOT
5170        // slide source A's excerpt.
5171        let edit = applied_edit((0, 0), (0, 0), (5, 0), "", "x\nx\nx\nx\nx\n");
5172        bus.publish(lattice_protocol::Event::DocumentChanged {
5173            id: src_b.id(),
5174            path: None,
5175            version: 1,
5176            edits: vec![edit],
5177        });
5178
5179        pump_forwarder().await;
5180        let excerpts_after = mb.excerpts();
5181        assert_eq!(excerpts_after[0].start_line, 0);
5182        assert_eq!(excerpts_after[0].end_line, 1);
5183    }
5184
5185    // ─────────────────────────────────────────────────────────────
5186    // M.5 tests — expand-context
5187    // ─────────────────────────────────────────────────────────────
5188
5189    #[tokio::test(flavor = "multi_thread")]
5190    async fn m5_expand_grows_symmetrically() {
5191        // Source has 10 rows (line 0..9); excerpt covers rows 4-5.
5192        let mut text = String::new();
5193        for i in 0..10 {
5194            text.push_str(&format!("L{i}\n"));
5195        }
5196        let (sources, ids) = make_sources(&[&text]);
5197        let excerpts = vec![Excerpt::new(ids[0], 4, 5)];
5198        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5199
5200        // Cursor on composed row 0 (= source row 4). Expand by 4
5201        // rows: 2 above + 2 below → new range 2..7.
5202        mb.expand_excerpt_at(0, 4);
5203        let excerpts = mb.excerpts();
5204        assert_eq!(excerpts[0].start_line, 2);
5205        assert_eq!(excerpts[0].end_line, 7);
5206    }
5207
5208    #[tokio::test(flavor = "multi_thread")]
5209    async fn m5_expand_clips_to_source_start() {
5210        // Excerpt at rows 1-2; expand by 6 should clip top to 0.
5211        let mut text = String::new();
5212        for i in 0..10 {
5213            text.push_str(&format!("L{i}\n"));
5214        }
5215        let (sources, ids) = make_sources(&[&text]);
5216        let excerpts = vec![Excerpt::new(ids[0], 1, 2)];
5217        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5218
5219        // delta=6 → above=3, below=3. start = 1-3 = -2 → clipped to 0.
5220        // end = 2+3 = 5.
5221        mb.expand_excerpt_at(0, 6);
5222        let excerpts = mb.excerpts();
5223        assert_eq!(excerpts[0].start_line, 0);
5224        assert_eq!(excerpts[0].end_line, 5);
5225    }
5226
5227    #[tokio::test(flavor = "multi_thread")]
5228    async fn m5_expand_clips_to_source_end() {
5229        // Source text "L0\n...L9\n" has 10 content lines, plus the
5230        // phantom row ropey reports after the terminating newline.
5231        // CV.3 clips expansion in CONTENT space, so the last row an
5232        // excerpt may reach is index 9 — the phantom row is not a line
5233        // the source has, and an excerpt ending on it would render a
5234        // blank tail row that is not in the file.
5235        let mut text = String::new();
5236        for i in 0..10 {
5237            text.push_str(&format!("L{i}\n"));
5238        }
5239        let (sources, ids) = make_sources(&[&text]);
5240        let excerpts = vec![Excerpt::new(ids[0], 7, 8)];
5241        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5242
5243        // delta=6 → above=3, below=3. start = 7-3 = 4.
5244        // end = 8+3 = 11 → clipped to content_line_count - 1 = 9.
5245        mb.expand_excerpt_at(0, 6);
5246        let excerpts = mb.excerpts();
5247        assert_eq!(excerpts[0].start_line, 4);
5248        assert_eq!(excerpts[0].end_line, 9);
5249    }
5250
5251    #[tokio::test(flavor = "multi_thread")]
5252    async fn m5_contract_shrinks_symmetrically() {
5253        let mut text = String::new();
5254        for i in 0..20 {
5255            text.push_str(&format!("L{i}\n"));
5256        }
5257        let (sources, ids) = make_sources(&[&text]);
5258        // Excerpt at rows 5-15 (11 rows).
5259        let excerpts = vec![Excerpt::new(ids[0], 5, 15)];
5260        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5261
5262        // delta=-4 → above=-2, below=-2. start = 5+2 = 7. end = 15-2 = 13.
5263        mb.expand_excerpt_at(0, -4);
5264        let excerpts = mb.excerpts();
5265        assert_eq!(excerpts[0].start_line, 7);
5266        assert_eq!(excerpts[0].end_line, 13);
5267    }
5268
5269    #[tokio::test(flavor = "multi_thread")]
5270    async fn m5_contract_below_one_row_is_noop() {
5271        let (sources, ids) = make_sources(&["a\nb\n"]);
5272        // Excerpt at rows 0-0 (single row).
5273        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
5274        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5275
5276        // Contract by 4 → new start = 2, new end = -2 (clipped to 0).
5277        // 0 > 2 inverted → no-op.
5278        mb.expand_excerpt_at(0, -4);
5279        let excerpts = mb.excerpts();
5280        assert_eq!(excerpts[0].start_line, 0);
5281        assert_eq!(excerpts[0].end_line, 0);
5282    }
5283
5284    #[tokio::test(flavor = "multi_thread")]
5285    async fn m5_zero_delta_is_noop() {
5286        let (sources, ids) = make_sources(&["a\nb\n"]);
5287        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
5288        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5289        mb.expand_excerpt_at(0, 0);
5290        let excerpts = mb.excerpts();
5291        assert_eq!(excerpts[0].start_line, 0);
5292        assert_eq!(excerpts[0].end_line, 1);
5293    }
5294
5295    #[tokio::test(flavor = "multi_thread")]
5296    async fn m5_no_excerpt_at_cursor_is_noop() {
5297        let (sources, ids) = make_sources(&["a\nb\n"]);
5298        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
5299        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5300        // Cursor at composed row 50 — well past the single excerpt.
5301        mb.expand_excerpt_at(50, 4);
5302        let excerpts = mb.excerpts();
5303        assert_eq!(excerpts[0].start_line, 0);
5304        assert_eq!(excerpts[0].end_line, 0);
5305    }
5306
5307    #[tokio::test(flavor = "multi_thread")]
5308    async fn m5_expand_then_recompose_reflects_new_content() {
5309        let mut text = String::new();
5310        for i in 0..10 {
5311            text.push_str(&format!("L{i}\n"));
5312        }
5313        let (sources, ids) = make_sources(&[&text]);
5314        let excerpts = vec![Excerpt::new(ids[0], 4, 5)];
5315        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5316        assert_eq!(mb.snapshot().buffer.as_string(), "L4\nL5\n");
5317
5318        mb.expand_excerpt_at(0, 4);
5319        // After expand_excerpt_at recomposes, the snapshot
5320        // should already reflect the new rows.
5321        assert_eq!(mb.snapshot().buffer.as_string(), "L2\nL3\nL4\nL5\nL6\nL7\n");
5322    }
5323
5324    #[tokio::test(flavor = "multi_thread")]
5325    async fn m4_attach_is_idempotent() {
5326        let bus = Arc::new(lattice_runtime::EventBus::new());
5327        let (sources, ids) = make_sources(&["x\n"]);
5328        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
5329        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5330        mb.attach_event_subscriptions(&bus);
5331        // Second call returns immediately; no second subscription
5332        // ID is recorded (verifiable via the unique-ID set count
5333        // staying at 1, but our internal bookkeeping isn't
5334        // public — instead we verify no panic + behaviour stays
5335        // correct).
5336        mb.attach_event_subscriptions(&bus);
5337        mb.set_headerline(HeaderlineStatus::Complete {
5338            summary: "x".into(),
5339            emphasis: None,
5340        });
5341    }
5342
5343    /// M.11 (2026-06-02): undo operates on the LOCAL composed_doc,
5344    /// not via fan-out to source actors. The composed_doc has its
5345    /// own undo stack populated by `apply_edit`; `undo()` pops
5346    /// the most recent entry and applies its inverse. Sources
5347    /// are NOT reverted (they retain their forwarded edits) —
5348    /// richer multi-source transaction tracking is a future
5349    /// slice. Replaces the pre-M.11 `m3_undo_fans_out_to_each_source`
5350    /// test whose semantics no longer apply.
5351    #[tokio::test(flavor = "multi_thread")]
5352    async fn m11_undo_reverses_local_composed_doc_edit() {
5353        let (sources, ids) = make_sources(&["aaa\nbbb\n"]);
5354        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
5355        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5356        let pre_text = mb.snapshot().buffer.as_string();
5357        assert_eq!(pre_text, "aaa\nbbb\n");
5358
5359        // Apply an edit via the multibuffer (lands in composed_doc).
5360        mb.apply_edit(Edit::insert(Position::new(0, 3), "X"))
5361            .await
5362            .expect("insert ok");
5363        assert_eq!(mb.snapshot().buffer.as_string(), "aaaX\nbbb\n");
5364
5365        // Undo reverses on composed_doc — synchronous, no
5366        // Pending::spawn deadlock (the user-reported freeze on
5367        // `u` after an insert was the pre-M.11 fan-out path).
5368        let applied = mb.undo().await.expect("undo ok");
5369        assert!(
5370            !applied.is_empty(),
5371            "undo should return the inverse edits applied to composed_doc"
5372        );
5373        assert_eq!(
5374            mb.snapshot().buffer.as_string(),
5375            "aaa\nbbb\n",
5376            "composed_doc must roll back to pre-edit state"
5377        );
5378
5379        // Redo replays the insert.
5380        let _ = mb.redo().await.expect("redo ok");
5381        assert_eq!(mb.snapshot().buffer.as_string(), "aaaX\nbbb\n");
5382    }
5383
5384    #[tokio::test(flavor = "multi_thread")]
5385    async fn empty_excerpts_is_valid_for_async_providers() {
5386        // M.2.b.2 (2026-06-01): empty inputs are valid. Async
5387        // providers open an empty view and stream excerpts in
5388        // as their scan progresses.
5389        let mb = MultibufferDocumentHandle::empty(empty_registry());
5390        assert_eq!(mb.excerpt_count(), 0);
5391        assert_eq!(mb.snapshot().buffer.as_string(), "");
5392        let (sources, _ids) = make_sources(&["x"]);
5393        let mb = MultibufferDocumentHandle::new(sources, Vec::new(), empty_registry()).unwrap();
5394        assert_eq!(mb.excerpt_count(), 0);
5395    }
5396
5397    #[tokio::test(flavor = "multi_thread")]
5398    async fn append_excerpts_extends_the_view() {
5399        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
5400        let mb =
5401            MultibufferDocumentHandle::new(sources.clone(), Vec::new(), empty_registry()).unwrap();
5402        assert_eq!(mb.excerpt_count(), 0);
5403
5404        mb.append_excerpts(vec![Excerpt::new(ids[0], 0, 0)]);
5405        assert_eq!(mb.excerpt_count(), 1);
5406        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\n");
5407
5408        mb.append_excerpts(vec![Excerpt::new(ids[0], 2, 2)]);
5409        assert_eq!(mb.excerpt_count(), 2);
5410        assert_eq!(mb.snapshot().buffer.as_string(), "alpha\ngamma\n");
5411    }
5412
5413    #[tokio::test(flavor = "multi_thread")]
5414    async fn append_excerpts_drops_unknown_source_silently() {
5415        let (sources, ids) = make_sources(&["alpha\nbeta\n"]);
5416        let mb = MultibufferDocumentHandle::new(sources, Vec::new(), empty_registry()).unwrap();
5417        let bogus = BufferId(0xDEAD_BEEF);
5418        mb.append_excerpts(vec![Excerpt::new(ids[0], 0, 0), Excerpt::new(bogus, 0, 0)]);
5419        assert_eq!(
5420            mb.excerpt_count(),
5421            1,
5422            "unknown-source excerpt should be silently dropped",
5423        );
5424    }
5425
5426    // MH.B2 (2026-06-19): the correctness gate for the incremental
5427    // `append_excerpts`. Build one view by streaming the excerpt
5428    // list in N batches; build a second view with the identical
5429    // full excerpt list in ONE shot. The composed rope TEXT and
5430    // the row-translation entries MUST be identical between the
5431    // two — this proves incremental append == full build, both for
5432    // the rope (insert-at-end == from_text(old + batch)) and for
5433    // the translation (concatenation == build-from-all).
5434    #[tokio::test(flavor = "multi_thread")]
5435    async fn incremental_append_matches_full_build() {
5436        // Six sources, each multi-line, so each batch contributes
5437        // several composed rows.
5438        let texts = [
5439            "a1\na2\na3\n",
5440            "b1\nb2\nb3\n",
5441            "c1\nc2\nc3\n",
5442            "d1\nd2\nd3\n",
5443            "e1\ne2\ne3\n",
5444            "f1\nf2\nf3\n",
5445        ];
5446        let (sources, ids) = make_sources(&texts);
5447
5448        // The full excerpt list, varied start/end so spans differ.
5449        let all_excerpts = vec![
5450            Excerpt::new(ids[0], 0, 1),
5451            Excerpt::new(ids[1], 1, 2),
5452            Excerpt::new(ids[2], 0, 2),
5453            Excerpt::new(ids[3], 2, 2),
5454            Excerpt::new(ids[4], 0, 0),
5455            Excerpt::new(ids[5], 1, 2),
5456        ];
5457
5458        // INCREMENTAL: empty view, stream in 3 batches of 2.
5459        let incremental =
5460            MultibufferDocumentHandle::new(sources.clone(), Vec::new(), empty_registry()).unwrap();
5461        for batch in all_excerpts.chunks(2) {
5462            incremental.append_excerpts(batch.to_vec());
5463        }
5464
5465        // FULL: same source map + the entire excerpt list at once,
5466        // via the constructor's full build.
5467        let full = MultibufferDocumentHandle::new(sources, all_excerpts.clone(), empty_registry())
5468            .unwrap();
5469
5470        // (a) composed rope text byte-identical.
5471        assert_eq!(
5472            incremental.snapshot().buffer.as_string(),
5473            full.snapshot().buffer.as_string(),
5474            "incremental append must produce a byte-identical composed rope",
5475        );
5476
5477        // (b) row_translation entries identical (same Vec<RowEntry>),
5478        // modulo the ExcerptId values — the two views allocate
5479        // distinct ExcerptIds, so compare structurally on
5480        // (source_row) ordering within the same shape. We assert
5481        // the entry COUNT and source_row sequence match; the
5482        // excerpt_id mapping is per-view by construction.
5483        let inc_entries = incremental.row_translation().entries.clone();
5484        let full_entries = full.row_translation().entries.clone();
5485        assert_eq!(
5486            inc_entries.len(),
5487            full_entries.len(),
5488            "row translation length must match the full build",
5489        );
5490        let inc_rows: Vec<u32> = inc_entries
5491            .iter()
5492            .map(|RowEntry::Excerpt { source_row, .. }| *source_row)
5493            .collect();
5494        let full_rows: Vec<u32> = full_entries
5495            .iter()
5496            .map(|RowEntry::Excerpt { source_row, .. }| *source_row)
5497            .collect();
5498        assert_eq!(
5499            inc_rows, full_rows,
5500            "row translation source_row sequence must match the full build",
5501        );
5502    }
5503
5504    // MH.B2 (2026-06-19): edge case — a batch containing an excerpt
5505    // whose source was never added is dropped identically by both
5506    // the incremental and full paths, leaving the composed text +
5507    // translation in lock-step.
5508    #[tokio::test(flavor = "multi_thread")]
5509    async fn incremental_append_skips_unknown_source_like_full_build() {
5510        let (sources, ids) = make_sources(&["a1\na2\n", "b1\nb2\n"]);
5511        let bogus = BufferId(0x0BAD_C0DE);
5512
5513        let valid = vec![Excerpt::new(ids[0], 0, 0), Excerpt::new(ids[1], 1, 1)];
5514
5515        // INCREMENTAL: stream one valid excerpt, then a batch that
5516        // mixes a valid + a bogus-source excerpt (bogus dropped).
5517        let incremental =
5518            MultibufferDocumentHandle::new(sources.clone(), Vec::new(), empty_registry()).unwrap();
5519        incremental.append_excerpts(vec![valid[0].clone()]);
5520        incremental.append_excerpts(vec![Excerpt::new(bogus, 0, 0), valid[1].clone()]);
5521
5522        // FULL: only the two valid excerpts (the constructor would
5523        // reject an unknown source, so the full build's coherent
5524        // input is the valid set — the same set the incremental
5525        // path retains after dropping the bogus excerpt).
5526        let full =
5527            MultibufferDocumentHandle::new(sources, valid.clone(), empty_registry()).unwrap();
5528
5529        assert_eq!(incremental.excerpt_count(), 2);
5530        assert_eq!(
5531            incremental.snapshot().buffer.as_string(),
5532            full.snapshot().buffer.as_string(),
5533            "dropping an unknown-source excerpt must leave the rope identical to the full build",
5534        );
5535        assert_eq!(
5536            incremental.row_translation().entries.len(),
5537            full.row_translation().entries.len(),
5538        );
5539    }
5540
5541    // MH.B2 (2026-06-19): structural pin on the O(batch) guarantee.
5542    // Appending a 1-excerpt batch (covering R source rows) to an
5543    // N-excerpt view must grow the row translation by EXACTLY R
5544    // entries and grow the composed rope by EXACTLY the batch
5545    // text's byte length — i.e. no full recompute. Together with
5546    // `incremental_append_matches_full_build` this pins both the
5547    // correctness AND the incremental nature of `append_excerpts`.
5548    #[tokio::test(flavor = "multi_thread")]
5549    async fn append_excerpts_grows_by_exactly_the_batch() {
5550        let (sources, ids) = make_sources(&["l0\nl1\nl2\nl3\nl4\n"]);
5551        let mb = MultibufferDocumentHandle::new(sources, Vec::new(), empty_registry()).unwrap();
5552
5553        // Seed with a 2-row excerpt.
5554        mb.append_excerpts(vec![Excerpt::new(ids[0], 0, 1)]);
5555        let rows_before = mb.row_translation().entries.len();
5556        let bytes_before = mb.snapshot().buffer.byte_len();
5557        assert_eq!(rows_before, 2);
5558
5559        // Append a 3-row excerpt (rows 2..=4 → "l2\nl3\nl4\n" = 9 bytes).
5560        mb.append_excerpts(vec![Excerpt::new(ids[0], 2, 4)]);
5561        let rows_after = mb.row_translation().entries.len();
5562        let bytes_after = mb.snapshot().buffer.byte_len();
5563
5564        assert_eq!(
5565            rows_after - rows_before,
5566            3,
5567            "translation must grow by exactly the batch's source-row count (O(batch))",
5568        );
5569        assert_eq!(
5570            bytes_after - bytes_before,
5571            "l2\nl3\nl4\n".len() as u64,
5572            "composed rope must grow by exactly the batch text length (insert-at-end, no recompute)",
5573        );
5574    }
5575
5576    #[tokio::test(flavor = "multi_thread")]
5577    async fn replace_excerpts_swaps_atomically() {
5578        let (sources_a, ids_a) = make_sources(&["a-1\na-2\n"]);
5579        let excerpts_a = vec![Excerpt::new(ids_a[0], 0, 0)];
5580        let mb = MultibufferDocumentHandle::new(sources_a, excerpts_a, empty_registry()).unwrap();
5581        assert_eq!(mb.snapshot().buffer.as_string(), "a-1\n");
5582
5583        let (sources_b, ids_b) = make_sources(&["b-1\nb-2\n"]);
5584        let excerpts_b = vec![Excerpt::new(ids_b[0], 1, 1)];
5585        mb.replace_excerpts(sources_b, excerpts_b);
5586        assert_eq!(mb.snapshot().buffer.as_string(), "b-2\n");
5587        assert_eq!(mb.excerpt_count(), 1);
5588    }
5589
5590    #[tokio::test(flavor = "multi_thread")]
5591    async fn unknown_source_returns_error() {
5592        let (sources, _ids) = make_sources(&["x"]);
5593        let bogus = BufferId(99_999);
5594        let excerpts = vec![Excerpt::new(bogus, 0, 0)];
5595        let err = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap_err();
5596        assert!(matches!(
5597            err,
5598            MultibufferError::UnknownSource { source_buffer, .. } if source_buffer == bogus
5599        ));
5600    }
5601
5602    #[tokio::test(flavor = "multi_thread")]
5603    async fn dispatches_via_dyn_document() {
5604        let (sources, ids) = make_sources(&["foo\nbar\nbaz\n"]);
5605        let excerpts = vec![Excerpt::new(ids[0], 0, 1)];
5606        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5607
5608        let dyn_doc: Arc<dyn Document> = Arc::new(mb);
5609        assert_eq!(dyn_doc.text(), "foo\nbar\n");
5610        assert!(!dyn_doc.dirty());
5611        // M.3 (2026-06-01): apply_edit now translates and
5612        // forwards rather than returning ReadOnly.
5613        let applied = dyn_doc
5614            .apply_edit(Edit::insert(Position::ZERO, "x"))
5615            .await
5616            .expect("apply_edit should propagate");
5617        assert_eq!(applied.inserted_text, "x");
5618    }
5619
5620    #[tokio::test(flavor = "multi_thread")]
5621    async fn source_edit_propagates_after_recompose() {
5622        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
5623        let source_handle = sources.get(&ids[0]).expect("source present").clone();
5624        let excerpts = vec![Excerpt::new(ids[0], 0, 2)];
5625        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
5626        assert_eq!(mb.snapshot().text(), "alpha\nbeta\ngamma\n");
5627
5628        source_handle
5629            .apply_edit(Edit::insert(Position::new(1, 0), "BB-"))
5630            .await
5631            .unwrap();
5632        assert_eq!(mb.snapshot().text(), "alpha\nbeta\ngamma\n");
5633        mb.recompose();
5634        assert_eq!(mb.snapshot().text(), "alpha\nBB-beta\ngamma\n");
5635    }
5636
5637    // ─────────────────────────────────────────────────────────────
5638    // Header provider tests (moved from `lattice-host::multibuffer`)
5639    // ─────────────────────────────────────────────────────────────
5640
5641    #[test]
5642    fn header_rows_dedupe_consecutive_same_source() {
5643        // K.4.6 follow-up (2026-06-02): three excerpts sharing
5644        // the same source BufferId emit ONE header row (anchored
5645        // at the first excerpt's composed start). The remaining
5646        // two excerpts advance the composed cursor without
5647        // emitting headers. Closes the "1 header per file" UX
5648        // for grep-style search results.
5649        let mb_source = BufferId::next();
5650        let excerpts = vec![
5651            Excerpt::new(mb_source, 0, 2).with_header(ExcerptHeader::new("a")),
5652            Excerpt::new(mb_source, 0, 1).with_header(ExcerptHeader::new("b")),
5653            Excerpt::new(mb_source, 0, 0).with_header(ExcerptHeader::new("c")),
5654        ];
5655        let rows = compose_header_rows(&excerpts, FoldGrouping::SourceFile, |_| {
5656            Arc::from(Vec::<Cell>::new())
5657        });
5658
5659        assert_eq!(
5660            rows.len(),
5661            1,
5662            "consecutive same-source excerpts dedup to one header"
5663        );
5664        assert_eq!(rows[0].anchor_line, 0);
5665        assert_eq!(rows[0].position, AnchorPosition::Above);
5666        assert_eq!(rows[0].height, 1);
5667        assert_eq!(rows[0].kind, VirtualRowKind::Generic);
5668    }
5669
5670    #[test]
5671    fn header_rows_distinct_sources_each_emit_header() {
5672        // K.4.6 follow-up (2026-06-02): excerpts with distinct
5673        // source BufferIds each emit their own header at the
5674        // correct composed offset. Three sources → three headers
5675        // at 0, 3, 5. Mirrors the production scenario where
5676        // search-provider clusters from three different files
5677        // appear in the composed view.
5678        let src_a = BufferId::next();
5679        let src_b = BufferId::next();
5680        let src_c = BufferId::next();
5681        let excerpts = vec![
5682            Excerpt::new(src_a, 0, 2).with_header(ExcerptHeader::new("a")),
5683            Excerpt::new(src_b, 0, 1).with_header(ExcerptHeader::new("b")),
5684            Excerpt::new(src_c, 0, 0).with_header(ExcerptHeader::new("c")),
5685        ];
5686        let rows = compose_header_rows(&excerpts, FoldGrouping::SourceFile, |_| {
5687            Arc::from(Vec::<Cell>::new())
5688        });
5689
5690        assert_eq!(rows.len(), 3);
5691        assert_eq!(rows[0].anchor_line, 0);
5692        assert_eq!(rows[1].anchor_line, 3);
5693        assert_eq!(rows[2].anchor_line, 5);
5694        for row in &rows {
5695            assert_eq!(row.position, AnchorPosition::Above);
5696            assert_eq!(row.height, 1);
5697            assert_eq!(row.kind, VirtualRowKind::Generic);
5698        }
5699    }
5700
5701    #[test]
5702    fn header_rows_interleaved_sources_each_get_header() {
5703        // K.4.6 follow-up (2026-06-02): when the same source
5704        // re-appears after a different source, it gets its own
5705        // header (the dedup is on *consecutive* same-source, not
5706        // on "has this source ever been seen"). Models a
5707        // pathological search ordering where hits from file A
5708        // and file B are interleaved.
5709        let src_a = BufferId::next();
5710        let src_b = BufferId::next();
5711        let excerpts = vec![
5712            Excerpt::new(src_a, 0, 0).with_header(ExcerptHeader::new("a")),
5713            Excerpt::new(src_b, 0, 0).with_header(ExcerptHeader::new("b")),
5714            Excerpt::new(src_a, 1, 1).with_header(ExcerptHeader::new("a-again")),
5715        ];
5716        let rows = compose_header_rows(&excerpts, FoldGrouping::SourceFile, |_| {
5717            Arc::from(Vec::<Cell>::new())
5718        });
5719
5720        assert_eq!(
5721            rows.len(),
5722            3,
5723            "non-consecutive same source still emits its own header"
5724        );
5725    }
5726
5727    /// The agenda's grouping contract vs. what the substrate does.
5728    ///
5729    /// `providers/agenda.rs::build_excerpts` gives the FIRST row of a
5730    /// date group the label and every later row `String::new()`, on the
5731    /// documented assumption that "an empty `ExcerptHeader.title`
5732    /// renders no header row". The dedup is on `excerpt.source`, not on
5733    /// the title — and a date group interleaves files by design — so
5734    /// each file change inside one group emits another header, and an
5735    /// empty title with no path renders `[untitled]`.
5736    /// The other half of the title-run rule, and why one rule serves both
5737    /// header conventions: search titles every excerpt of a file with the
5738    /// same path, so equal titles must CONTINUE a group rather than start one.
5739    /// If they did not, `HeaderRuns` would be agenda-only.
5740    /// OA.7: the split that dims a header's trailing annotation. Pure, so the
5741    /// nesting and the refusals are pinned without rendering anything.
5742    #[test]
5743    fn a_trailing_parenthetical_splits_off_as_annotation() {
5744        let cases: &[(&str, &str, &str)] = &[
5745            // The agenda's three label shapes.
5746            ("2026-09-01 Mon (today)", "2026-09-01 Mon", " (today)"),
5747            (
5748                "2026-08-30 Sat (overdue by 2 day(s))",
5749                "2026-08-30 Sat",
5750                " (overdue by 2 day(s))",
5751            ),
5752            (
5753                "2026-09-04 Fri (in 3 day(s))",
5754                "2026-09-04 Fri",
5755                " (in 3 day(s))",
5756            ),
5757            // No parenthetical: unchanged, byte for byte.
5758            ("Unscheduled", "Unscheduled", ""),
5759            ("src/lib.rs", "src/lib.rs", ""),
5760            // A parenthetical with no name in front is the whole title, not
5761            // an annotation on nothing.
5762            ("(today)", "(today)", ""),
5763            // Unbalanced: left alone rather than guessed at.
5764            ("Overdue )", "Overdue )", ""),
5765            ("Overdue (", "Overdue (", ""),
5766        ];
5767        for (input, name, note) in cases {
5768            assert_eq!(
5769                split_trailing_parenthetical(input),
5770                (*name, *note),
5771                "on {input:?}"
5772            );
5773        }
5774    }
5775
5776    /// The two halves must reconstruct the input exactly — dimming is the only
5777    /// difference, so a header can never lose text to this.
5778    #[test]
5779    fn the_split_halves_reconstruct_the_title() {
5780        for title in [
5781            "2026-09-01 Mon (today)",
5782            "Unscheduled",
5783            "(today)",
5784            "a (b) c (d)",
5785            "café (naïve)",
5786        ] {
5787            let (name, note) = split_trailing_parenthetical(title);
5788            assert_eq!(format!("{name}{note}"), title, "on {title:?}");
5789        }
5790    }
5791
5792    #[test]
5793    fn header_runs_continue_across_equal_titles() {
5794        let a = BufferId::next();
5795        let b = BufferId::next();
5796        let excerpts = vec![
5797            Excerpt::new(a, 0, 0).with_header(ExcerptHeader::new("src/lib.rs")),
5798            Excerpt::new(a, 9, 9).with_header(ExcerptHeader::new("src/lib.rs")),
5799            Excerpt::new(b, 3, 3).with_header(ExcerptHeader::new("src/main.rs")),
5800        ];
5801        let rows = compose_header_rows(&excerpts, FoldGrouping::HeaderRuns, |e| {
5802            header_cells(&e.header, false, 0, 0, 0)
5803        });
5804        assert_eq!(rows.len(), 2, "one header per title run");
5805        assert_eq!(rows[0].anchor_line, 0);
5806        assert_eq!(rows[1].anchor_line, 2);
5807    }
5808
5809    /// A leading empty title emits nothing rather than `[untitled]`. A header
5810    /// with no text is not a header, and this is the shape that produced the
5811    /// bug — it must not merely be rarer now.
5812    #[test]
5813    fn header_runs_never_emit_an_untitled_row() {
5814        let a = BufferId::next();
5815        let excerpts = vec![
5816            Excerpt::new(a, 0, 0).with_header(ExcerptHeader::new("")),
5817            Excerpt::new(a, 1, 1).with_header(ExcerptHeader::new("")),
5818        ];
5819        let rows = compose_header_rows(&excerpts, FoldGrouping::HeaderRuns, |e| {
5820            header_cells(&e.header, false, 0, 0, 0)
5821        });
5822        assert!(rows.is_empty(), "got {} header row(s)", rows.len());
5823    }
5824
5825    #[test]
5826    fn agenda_group_of_interleaved_files_emits_one_header_not_untitled_rows() {
5827        let todo = BufferId::next();
5828        let plans = BufferId::next();
5829        // One date group, three rows, drawn from two files in sort order.
5830        let excerpts = vec![
5831            Excerpt::new(todo, 0, 0).with_header(ExcerptHeader::new("2026-08-31 Mon (today)")),
5832            Excerpt::new(plans, 4, 4).with_header(ExcerptHeader::new("")),
5833            Excerpt::new(todo, 9, 9).with_header(ExcerptHeader::new("")),
5834        ];
5835        let rows = compose_header_rows(&excerpts, FoldGrouping::HeaderRuns, |e| {
5836            header_cells(&e.header, false, 0, 0, 0)
5837        });
5838        let text = |r: &VirtualRow| {
5839            r.cells
5840                .iter()
5841                .filter_map(|c| char::from_u32(c.codepoint))
5842                .collect::<String>()
5843        };
5844
5845        assert_eq!(
5846            rows.len(),
5847            1,
5848            "one date group renders one header; got {:?}",
5849            rows.iter().map(text).collect::<Vec<_>>()
5850        );
5851        assert_eq!(text(&rows[0]), "2026-08-31 Mon (today)");
5852    }
5853
5854    #[test]
5855    fn default_header_paints_box_rules_around_title() {
5856        let mb_source = BufferId::next();
5857        let with_title = Excerpt::new(mb_source, 0, 0).with_header(ExcerptHeader::new("hi"));
5858        let cells = default_header_cells(&with_title);
5859        assert_eq!(cells.len(), 8);
5860        assert_eq!(cells[0].codepoint, '─' as u32);
5861        assert_eq!(cells[3].codepoint, 'h' as u32);
5862        assert_eq!(cells[4].codepoint, 'i' as u32);
5863
5864        let without_title = Excerpt::new(mb_source, 0, 0);
5865        let cells = default_header_cells(&without_title);
5866        assert_eq!(cells.len(), 4);
5867        for cell in cells.iter() {
5868            assert_eq!(cell.codepoint, '─' as u32);
5869        }
5870    }
5871
5872    // ── MH.A3 / MH.A5: rich per-segment header_cells ────────────
5873
5874    /// Collect the codepoints of every cell carrying `fg` into a String.
5875    fn cells_with_fg(cells: &[Cell], fg: u32) -> String {
5876        cells
5877            .iter()
5878            .filter(|c| c.fg == fg)
5879            .filter_map(|c| char::from_u32(c.codepoint))
5880            .collect()
5881    }
5882
5883    fn cells_to_string(cells: &[Cell]) -> String {
5884        cells
5885            .iter()
5886            .filter_map(|c| char::from_u32(c.codepoint))
5887            .collect()
5888    }
5889
5890    #[test]
5891    fn header_cells_with_path_splits_basename_and_dir() {
5892        // header_fg=0xAA, path_fg=0xBB, count_fg=0xCC (distinct).
5893        let header = ExcerptHeader {
5894            path: Some(std::path::PathBuf::from("src/multibuffer/view.rs")),
5895            ..ExcerptHeader::new("fallback-title")
5896        };
5897        let cells = header_cells(&header, false, 0xAA, 0xBB, 0xCC);
5898
5899        // First cell is the (BMP-fallback) file-type icon in header_fg.
5900        let expected_icon = lattice_core::ui::icons::glyph_for_entry(
5901            std::path::Path::new("src/multibuffer/view.rs"),
5902            false,
5903            false,
5904        );
5905        let first_icon_ch = expected_icon.chars().next().unwrap();
5906        assert_eq!(cells[0].codepoint, first_icon_ch as u32);
5907        assert_eq!(cells[0].fg, 0xAA, "icon carries header_fg");
5908
5909        // Basename present in header_fg.
5910        let header_seg = cells_with_fg(&cells, 0xAA);
5911        assert!(
5912            header_seg.contains("view.rs"),
5913            "basename rendered in header_fg; got {header_seg:?}"
5914        );
5915        assert!(
5916            !header_seg.contains("src/multibuffer"),
5917            "dir must NOT be in header_fg; got {header_seg:?}"
5918        );
5919
5920        // Directory path present in path_fg (dim).
5921        let path_seg = cells_with_fg(&cells, 0xBB);
5922        assert!(
5923            path_seg.contains("src/multibuffer"),
5924            "dir rendered in path_fg; got {path_seg:?}"
5925        );
5926    }
5927
5928    #[test]
5929    fn header_cells_match_count_badge_in_count_fg() {
5930        let header = ExcerptHeader {
5931            path: Some(std::path::PathBuf::from("a/b.rs")),
5932            match_count: Some(3),
5933            ..ExcerptHeader::default()
5934        };
5935        let cells = header_cells(&header, false, 0xAA, 0xBB, 0xCC);
5936        let count_seg = cells_with_fg(&cells, 0xCC);
5937        assert!(
5938            count_seg.contains("3 matches"),
5939            "plural badge in count_fg; got {count_seg:?}"
5940        );
5941
5942        // Singular form for n == 1.
5943        let header1 = ExcerptHeader {
5944            match_count: Some(1),
5945            ..header.clone()
5946        };
5947        let cells1 = header_cells(&header1, false, 0xAA, 0xBB, 0xCC);
5948        let count_seg1 = cells_with_fg(&cells1, 0xCC);
5949        assert!(
5950            count_seg1.contains("1 match") && !count_seg1.contains("matches"),
5951            "singular badge for n==1; got {count_seg1:?}"
5952        );
5953    }
5954
5955    #[test]
5956    fn header_cells_nerd_vs_bmp_same_width_different_icon() {
5957        let header = ExcerptHeader {
5958            path: Some(std::path::PathBuf::from("src/lib.rs")),
5959            ..ExcerptHeader::default()
5960        };
5961        let bmp = header_cells(&header, false, 0xAA, 0xBB, 0xCC);
5962        let nerd = header_cells(&header, true, 0xAA, 0xBB, 0xCC);
5963
5964        // Width parity: the icon helper emits a 2-cell glyph in both
5965        // palettes, so total cell count is identical.
5966        assert_eq!(
5967            bmp.len(),
5968            nerd.len(),
5969            "nerd vs BMP must occupy the same cell count (column geometry stable)"
5970        );
5971
5972        // Different leading icon codepoint between palettes.
5973        let bmp_icon = lattice_core::ui::icons::glyph_for_entry(
5974            std::path::Path::new("src/lib.rs"),
5975            false,
5976            false,
5977        );
5978        let nerd_icon = lattice_core::ui::icons::glyph_for_entry(
5979            std::path::Path::new("src/lib.rs"),
5980            false,
5981            true,
5982        );
5983        // (Guard: only assert "different" if the helper actually
5984        // returns distinct glyphs for this extension — it does for
5985        // `.rs`, but keep the test honest about its premise.)
5986        assert_ne!(
5987            bmp_icon.chars().next(),
5988            nerd_icon.chars().next(),
5989            "nerd and BMP icon glyphs differ for .rs"
5990        );
5991        assert_eq!(bmp[0].codepoint, bmp_icon.chars().next().unwrap() as u32);
5992        assert_eq!(nerd[0].codepoint, nerd_icon.chars().next().unwrap() as u32);
5993    }
5994
5995    #[test]
5996    fn header_cells_empty_title_no_path_falls_back() {
5997        let header = ExcerptHeader::default(); // empty title, no path
5998        let cells = header_cells(&header, false, 0xAA, 0xBB, 0xCC);
5999        let s = cells_to_string(&cells);
6000        assert_eq!(s, "[untitled]", "empty title + no path renders fallback");
6001        // Fallback rendered in header_fg.
6002        assert!(cells.iter().all(|c| c.fg == 0xAA));
6003    }
6004
6005    #[test]
6006    fn header_cells_no_path_uses_title() {
6007        let header = ExcerptHeader::new("my synthetic title");
6008        let cells = header_cells(&header, false, 0xAA, 0xBB, 0xCC);
6009        let s = cells_to_string(&cells);
6010        assert_eq!(s, "my synthetic title");
6011        assert!(cells.iter().all(|c| c.fg == 0xAA));
6012    }
6013
6014    #[tokio::test(flavor = "multi_thread")]
6015    async fn provider_collects_one_row_per_distinct_source() {
6016        // K.4.6 follow-up (2026-06-02): two excerpts from the
6017        // same source dedup to ONE header — the search-provider
6018        // "1 header per file, N excerpts per file (one per
6019        // cluster)" UX.
6020        let (sources, ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
6021        let excerpts = vec![
6022            Excerpt::new(ids[0], 0, 1).with_header(ExcerptHeader::new("first")),
6023            Excerpt::new(ids[0], 2, 2).with_header(ExcerptHeader::new("second")),
6024        ];
6025        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6026        let provider = MultibufferExcerptHeaderProvider::new(mb);
6027        let rows = provider.collect();
6028
6029        assert_eq!(rows.len(), 1);
6030        assert_eq!(rows[0].anchor_line, 0);
6031    }
6032
6033    #[test]
6034    fn provider_id_namespace_is_stable() {
6035        let buffer_id = BufferId(42);
6036        let id = multibuffer_excerpt_header_provider_id(buffer_id);
6037        assert_eq!(id & 0xFFFF_FFFF, 42);
6038        assert!(!(0xD1FF_0000_0000_0000..0xD200_0000_0000_0000).contains(&id));
6039    }
6040
6041    #[tokio::test(flavor = "multi_thread")]
6042    async fn provider_version_bumps_with_recompose() {
6043        let (sources, ids) = make_sources(&["alpha\nbeta\n"]);
6044        let source = sources.get(&ids[0]).unwrap().clone();
6045        let excerpts = vec![Excerpt::new(ids[0], 0, 0)];
6046        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6047        let provider = MultibufferExcerptHeaderProvider::new(mb.clone());
6048
6049        let v_before = provider.version();
6050        source
6051            .apply_edit(Edit::insert(Position::ZERO, "X"))
6052            .await
6053            .unwrap();
6054        mb.recompose();
6055        let v_after = provider.version();
6056        assert!(
6057            v_after > v_before,
6058            "version must bump after recompose; before={v_before} after={v_after}"
6059        );
6060    }
6061
6062    // ── T.7: mode-owned theme-element acid test ─────────────────
6063
6064    #[tokio::test(flavor = "multi_thread")]
6065    async fn excerpt_header_elements_register_and_bake_into_virtual_row() {
6066        // T.7 acid test: the multibuffer mode registers its OWN theme
6067        // elements (`multibuffer.excerpt_header[.path|.count]`) and the
6068        // header provider resolves+bakes them into the header
6069        // VirtualRow's `bg`. No host `Theme` field, no renderer match
6070        // arm — the renderer paints `VirtualRow.bg` generically.
6071        use lattice_theme::{
6072            ElementName, ElementOwner, InMemoryThemeRegistry, ThemeRegistry, ThemeRegistryHandle,
6073            default_palette,
6074        };
6075
6076        // A registry with the default palette (so `blue` / `overlay2`
6077        // palette keys resolve) but NO core builtins — proving the mode
6078        // is the sole registrant of these elements.
6079        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6080
6081        // The mode registers its elements (idempotent by name).
6082        let owner = ElementOwner::Mode(
6083            crate::MultibufferMode::mode_id()
6084                .as_str()
6085                .to_string()
6086                .into(),
6087        );
6088        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6089
6090        // 1) The element is registered + resolves to the neutral backdrop.
6091        let backdrop_name = ElementName::from(ELEM_EXCERPT_HEADER.to_string());
6092        let id = registry
6093            .id(&backdrop_name)
6094            .expect("multibuffer.excerpt_header registered after activation");
6095        assert_eq!(id, ids.backdrop);
6096        let resolved = registry.resolved();
6097        assert_eq!(
6098            resolved.get(id).bg,
6099            Some(Color::Rgb(0x31, 0x32, 0x44)),
6100            "excerpt_header backdrop resolves to the neutral surface tint, \
6101             NOT the diff-deletion red"
6102        );
6103
6104        // 2) The built header VirtualRow carries the BAKED bg —
6105        //    `Some(0x313244)`, not `None` (which would fall through to
6106        //    the renderer's diff-deletion-block tint).
6107        let theme: ThemeRegistryHandle = registry.clone();
6108        let (sources, src_ids) = make_sources(&["alpha\nbeta\ngamma\n"]);
6109        // MH.A3: supply a `path` so the rich header renders the dir
6110        // segment in the resolved `.path` (blue) fg — the basename is
6111        // in `header_fg` (the backdrop element's `.fg`, unset here).
6112        let header = ExcerptHeader {
6113            path: Some(std::path::PathBuf::from("src/file.rs")),
6114            ..ExcerptHeader::new("file.rs")
6115        };
6116        let excerpts = vec![Excerpt::new(src_ids[0], 0, 1).with_header(header)];
6117        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6118        let provider = MultibufferExcerptHeaderProvider::with_theme(mb, theme, ids, false);
6119        let rows = provider.collect();
6120        assert_eq!(rows.len(), 1);
6121        assert_eq!(
6122            rows[0].bg,
6123            Some(0x0031_3244),
6124            "header VirtualRow.bg is the baked backdrop, not None"
6125        );
6126        assert_eq!(rows[0].kind, VirtualRowKind::Generic);
6127        // The dir-path cells carry the baked `blue` fg (0x89b4fa).
6128        assert!(
6129            rows[0].cells.iter().any(|c| c.fg == 0x0089_b4fa),
6130            "dir-path cells carry the resolved path fg (blue)"
6131        );
6132    }
6133
6134    /// MH.A6: an emphasised header paints from its OWN elements, and its
6135    /// unemphasised neighbour is unchanged.
6136    ///
6137    /// Both rows in one `collect()` on purpose. The interesting failure is
6138    /// not "emphasis does nothing" — it is "emphasis leaked", a `collect()`
6139    /// that resolved the emphasis colours once and baked them into every
6140    /// header, which a single-row test cannot see.
6141    #[tokio::test(flavor = "multi_thread")]
6142    async fn an_emphasised_header_paints_from_its_own_elements() {
6143        use lattice_theme::{
6144            ElementOwner, InMemoryThemeRegistry, ThemeRegistryHandle, default_palette,
6145        };
6146
6147        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6148        let owner = ElementOwner::Mode(
6149            crate::MultibufferMode::mode_id()
6150                .as_str()
6151                .to_string()
6152                .into(),
6153        );
6154        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6155        let theme: ThemeRegistryHandle = registry.clone();
6156
6157        // Two sources so each excerpt starts its own header run.
6158        let (sources, src_ids) = make_sources(&["alpha\n", "beta\n"]);
6159        let plain = ExcerptHeader::new("Wednesday");
6160        let loud = ExcerptHeader {
6161            style: ExcerptHeaderStyle::Emphasis,
6162            ..ExcerptHeader::new("Tuesday (today)")
6163        };
6164        let excerpts = vec![
6165            Excerpt::new(src_ids[0], 0, 0).with_header(plain),
6166            Excerpt::new(src_ids[1], 0, 0).with_header(loud),
6167        ];
6168        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6169        let provider = MultibufferExcerptHeaderProvider::with_theme(mb, theme, ids, false);
6170        let rows = provider.collect();
6171        assert_eq!(rows.len(), 2, "one header per source: {rows:?}");
6172
6173        // `mauve` from the default palette, baked.
6174        let emphasis_bg = rows[1].bg.expect("the emphasised row has a backdrop");
6175        let plain_bg = rows[0].bg.expect("so does the ordinary one");
6176        assert_ne!(
6177            emphasis_bg, plain_bg,
6178            "the emphasised header must not paint on the ordinary backdrop — \
6179             that is the whole of what 'prominent' buys"
6180        );
6181        assert_eq!(
6182            plain_bg, 0x0031_3244,
6183            "and the ordinary header is untouched by its loud neighbour"
6184        );
6185
6186        // The title AND its trailing parenthetical take the emphasis fg. OA.7
6187        // dims `(today)` against the normal backdrop; dimming it here would
6188        // mute the half of the header that says why it is emphasised.
6189        let text_fg = 0x00cd_d6f4; // palette `text`
6190        let loud_title: String = rows[1]
6191            .cells
6192            .iter()
6193            .filter(|c| c.fg == text_fg)
6194            .filter_map(|c| char::from_u32(c.codepoint))
6195            .collect();
6196        assert_eq!(loud_title, "Tuesday (today)");
6197    }
6198
6199    /// A colourscheme with no emphasis elements renders an emphasised header
6200    /// like an ordinary one — undistinguished, never invisible.
6201    ///
6202    /// The failure this forbids is a header whose backdrop resolves to `None`
6203    /// and falls through to the renderer's diff-deletion tint, which is the
6204    /// exact bug the `.emphasis` element's `or(header_bg)` fallback exists to
6205    /// prevent and which T.7 already paid for once.
6206    #[tokio::test(flavor = "multi_thread")]
6207    async fn emphasis_without_theme_elements_degrades_to_the_ordinary_header() {
6208        let (sources, src_ids) = make_sources(&["alpha\n"]);
6209        let loud = ExcerptHeader {
6210            style: ExcerptHeaderStyle::Emphasis,
6211            ..ExcerptHeader::new("Tuesday (today)")
6212        };
6213        let excerpts = vec![Excerpt::new(src_ids[0], 0, 0).with_header(loud)];
6214        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6215        // No theme at all — every element id is INVALID.
6216        let provider = MultibufferExcerptHeaderProvider::new(mb);
6217        let rows = provider.collect();
6218
6219        assert_eq!(rows.len(), 1);
6220        let text: String = rows[0]
6221            .cells
6222            .iter()
6223            .filter_map(|c| char::from_u32(c.codepoint))
6224            .collect();
6225        assert_eq!(
6226            text, "Tuesday (today)",
6227            "the header still says what it says"
6228        );
6229    }
6230
6231    #[tokio::test(flavor = "multi_thread")]
6232    async fn excerpt_header_provider_version_folds_theme_version() {
6233        // T.7 invalidation: a theme change bumps the provider version
6234        // (the worker's fingerprint axis), so `collect()` re-runs and
6235        // re-bakes — mirrors `MatrixVersion::theme = resolved().version()`.
6236        use lattice_theme::{
6237            ElementName, ElementOwner, InMemoryThemeRegistry, StyleSpec, ThemeRegistry,
6238            ThemeRegistryHandle, default_palette,
6239        };
6240        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6241        let owner = ElementOwner::Mode(
6242            crate::MultibufferMode::mode_id()
6243                .as_str()
6244                .to_string()
6245                .into(),
6246        );
6247        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6248        let theme: ThemeRegistryHandle = registry.clone();
6249        let (sources, src_ids) = make_sources(&["a\nb\n"]);
6250        let excerpts = vec![Excerpt::new(src_ids[0], 0, 0)];
6251        let mb = MultibufferDocumentHandle::new(sources, excerpts, empty_registry()).unwrap();
6252        let provider = MultibufferExcerptHeaderProvider::with_theme(mb, theme, ids, false);
6253
6254        // Force the table to resolve so the version is established.
6255        let _ = registry.resolved();
6256        let v_before = provider.version();
6257        // A `:set ui.*`-style override dirties the table → next
6258        // `resolved()` bumps the version.
6259        registry.set_override(
6260            ElementName::from(ELEM_EXCERPT_HEADER.to_string()),
6261            StyleSpec::new().bg(Color::Rgb(1, 2, 3)),
6262        );
6263        let v_after = provider.version();
6264        assert!(
6265            v_after > v_before,
6266            "theme change must bump provider version; before={v_before} after={v_after}"
6267        );
6268    }
6269
6270    // ── M.6.5: MultibufferStatusProvider ────────────────────────
6271
6272    #[test]
6273    fn status_provider_hidden_when_idle() {
6274        let mb = MultibufferDocumentHandle::empty(empty_registry());
6275        let provider = MultibufferStatusProvider::new(mb.clone()).into_provider(mb.buffer_id());
6276        // Idle → no sticky row
6277        assert!(provider.collect().is_empty());
6278    }
6279
6280    #[test]
6281    fn status_provider_emits_sticky_row_when_in_progress() {
6282        let mb = MultibufferDocumentHandle::empty(empty_registry());
6283        mb.set_headerline(HeaderlineStatus::InProgress {
6284            label: "searching".into(),
6285            count: Some(3),
6286            emphasis: None,
6287        });
6288        let provider = MultibufferStatusProvider::new(mb.clone()).into_provider(mb.buffer_id());
6289        let rows = provider.collect();
6290        assert_eq!(rows.len(), 1);
6291        assert_eq!(rows[0].kind, VirtualRowKind::Sticky);
6292        assert_eq!(rows[0].anchor_line, 0);
6293        assert_eq!(rows[0].bg, None); // theme bg
6294    }
6295
6296    #[test]
6297    fn status_provider_version_bumps_on_set_headerline() {
6298        let mb = MultibufferDocumentHandle::empty(empty_registry());
6299        let buffer_id = mb.buffer_id();
6300        let provider = MultibufferStatusProvider::new(mb.clone()).into_provider(buffer_id);
6301        let v0 = provider.version();
6302        mb.set_headerline(HeaderlineStatus::Complete {
6303            summary: "done".into(),
6304            emphasis: None,
6305        });
6306        assert!(
6307            provider.version() > v0,
6308            "version must advance after set_headerline"
6309        );
6310    }
6311
6312    #[test]
6313    fn status_provider_namespace_does_not_collide_with_excerpt_header() {
6314        let buf = BufferId(7);
6315        let excerpt_id = multibuffer_excerpt_header_provider_id(buf);
6316        let status_id = multibuffer_status_provider_id(buf);
6317        assert_ne!(excerpt_id, status_id);
6318        assert_eq!(status_id & 0xFFFF_FFFF, 7);
6319    }
6320
6321    // ── MH.A4: status colors resolve from theme elements ────────
6322
6323    #[test]
6324    fn status_provider_no_theme_uses_fallback_hex() {
6325        // Test path (no theme service): the three states render with
6326        // the pre-MH.A4 fallback hex so nothing regresses where the
6327        // theme isn't wired.
6328        let cases = [
6329            (
6330                HeaderlineStatus::InProgress {
6331                    label: "x".into(),
6332                    count: None,
6333                    emphasis: None,
6334                },
6335                STATUS_IN_PROGRESS_FALLBACK_FG,
6336            ),
6337            (
6338                HeaderlineStatus::Complete {
6339                    summary: "done".into(),
6340                    emphasis: None,
6341                },
6342                STATUS_COMPLETE_FALLBACK_FG,
6343            ),
6344            (
6345                HeaderlineStatus::Failed {
6346                    reason: "boom".into(),
6347                },
6348                STATUS_FAILED_FALLBACK_FG,
6349            ),
6350        ];
6351        for (status, expected_fg) in cases {
6352            let mb = MultibufferDocumentHandle::empty(empty_registry());
6353            mb.set_headerline(status);
6354            let provider = MultibufferStatusProvider::new(mb);
6355            let row = provider.render().expect("non-idle status renders a row");
6356            assert!(
6357                row.cells.iter().all(|c| c.fg == expected_fg),
6358                "no-theme status uses fallback fg {expected_fg:#08x}"
6359            );
6360        }
6361    }
6362
6363    #[test]
6364    fn status_elements_register_and_bake_resolved_fg() {
6365        // MH.A4 acid test: the mode registers `multibuffer.status.*`
6366        // elements; the status provider resolves them and bakes the
6367        // per-state fg into the rendered row. Assert against the
6368        // RESOLVED palette role-key (green / red / subtext), NOT the old
6369        // ad-hoc hex.
6370        use lattice_theme::{
6371            ElementName, ElementOwner, InMemoryThemeRegistry, ThemeRegistry, ThemeRegistryHandle,
6372            default_palette,
6373        };
6374
6375        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6376        let owner = ElementOwner::Mode(
6377            crate::MultibufferMode::mode_id()
6378                .as_str()
6379                .to_string()
6380                .into(),
6381        );
6382        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6383
6384        // 1) The three elements are registered + resolve to the mapped
6385        //    palette role-keys.
6386        let resolved = registry.resolved();
6387        let palette = default_palette();
6388        let role = |key: &str| palette.get(&key.to_string().into()).unwrap();
6389        assert_eq!(
6390            resolved
6391                .get(
6392                    registry
6393                        .id(&ElementName::from(ELEM_STATUS_IN_PROGRESS.to_string()))
6394                        .unwrap()
6395                )
6396                .fg,
6397            Some(role("subtext")),
6398            "in_progress maps to the grey `subtext` role-key"
6399        );
6400        assert_eq!(
6401            resolved
6402                .get(
6403                    registry
6404                        .id(&ElementName::from(ELEM_STATUS_COMPLETE.to_string()))
6405                        .unwrap()
6406                )
6407                .fg,
6408            Some(role("green")),
6409            "complete maps to the `green` role-key"
6410        );
6411        assert_eq!(
6412            resolved
6413                .get(
6414                    registry
6415                        .id(&ElementName::from(ELEM_STATUS_FAILED.to_string()))
6416                        .unwrap()
6417                )
6418                .fg,
6419            Some(role("red")),
6420            "failed maps to the `red` role-key"
6421        );
6422
6423        // 2) The rendered status row bakes the resolved fg per state.
6424        let theme: ThemeRegistryHandle = registry.clone();
6425        let expect_fg = |status: HeaderlineStatus, key: &str| {
6426            let mb = MultibufferDocumentHandle::empty(empty_registry());
6427            mb.set_headerline(status);
6428            let provider = MultibufferStatusProvider::with_theme(mb, theme.clone(), ids);
6429            let row = provider.render().expect("non-idle status renders a row");
6430            let want = role(key).to_rgb_u32(0);
6431            assert!(
6432                row.cells.iter().all(|c| c.fg == want),
6433                "status row baked fg = resolved {key} ({want:#08x})"
6434            );
6435        };
6436        expect_fg(
6437            HeaderlineStatus::InProgress {
6438                label: "s".into(),
6439                count: Some(2),
6440                emphasis: None,
6441            },
6442            "subtext",
6443        );
6444        expect_fg(
6445            HeaderlineStatus::Complete {
6446                summary: "done".into(),
6447                emphasis: None,
6448            },
6449            "green",
6450        );
6451        expect_fg(
6452            HeaderlineStatus::Failed {
6453                reason: "err".into(),
6454            },
6455            "red",
6456        );
6457    }
6458
6459    #[test]
6460    fn status_emphasis_term_gets_query_accent_fg() {
6461        // The emphasised substring (e.g. the search query woven into the
6462        // status label) is painted with the resolved
6463        // `multibuffer.status.query` accent (yellow); the rest of the row
6464        // keeps the state fg. Only the term cells get the accent.
6465        use lattice_theme::{
6466            ElementName, ElementOwner, InMemoryThemeRegistry, ThemeRegistry, ThemeRegistryHandle,
6467            default_palette,
6468        };
6469        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6470        let owner = ElementOwner::Mode(
6471            crate::MultibufferMode::mode_id()
6472                .as_str()
6473                .to_string()
6474                .into(),
6475        );
6476        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6477
6478        // The query element resolves to the `yellow` role-key.
6479        let resolved = registry.resolved();
6480        let palette = default_palette();
6481        let role = |key: &str| palette.get(&key.to_string().into()).unwrap();
6482        assert_eq!(
6483            resolved
6484                .get(
6485                    registry
6486                        .id(&ElementName::from(ELEM_STATUS_QUERY.to_string()))
6487                        .unwrap()
6488                )
6489                .fg,
6490            Some(role("yellow")),
6491            "query accent maps to the `yellow` role-key"
6492        );
6493
6494        // Render a Complete status carrying an emphasised term.
6495        let theme: ThemeRegistryHandle = registry.clone();
6496        let mb = MultibufferDocumentHandle::empty(empty_registry());
6497        mb.set_headerline(HeaderlineStatus::Complete {
6498            summary: "\"needle\" — 3 hits in 2 files".into(),
6499            emphasis: Some("needle".into()),
6500        });
6501        let provider = MultibufferStatusProvider::with_theme(mb, theme, ids);
6502        let row = provider.render().expect("non-idle status renders a row");
6503
6504        let query_fg = role("yellow").to_rgb_u32(0);
6505        let complete_fg = role("green").to_rgb_u32(0);
6506        let text: String = row
6507            .cells
6508            .iter()
6509            .map(|c| char::from_u32(c.codepoint).unwrap_or(' '))
6510            .collect();
6511        let byte_start = text.find("needle").expect("term present in row");
6512        let char_start = text[..byte_start].chars().count();
6513        let char_end = char_start + "needle".chars().count();
6514        for (i, cell) in row.cells.iter().enumerate() {
6515            if i >= char_start && i < char_end {
6516                assert_eq!(cell.fg, query_fg, "emphasis cell {i} uses the query accent");
6517            } else {
6518                assert_eq!(
6519                    cell.fg, complete_fg,
6520                    "non-emphasis cell {i} keeps the state (complete) fg"
6521                );
6522            }
6523        }
6524    }
6525
6526    #[test]
6527    fn status_provider_version_folds_theme_version() {
6528        // MH.A4: a colorscheme swap (theme override) bumps the status
6529        // provider version so the headerline re-renders the recolored
6530        // status row.
6531        use lattice_theme::{
6532            ElementName, ElementOwner, InMemoryThemeRegistry, StyleSpec, ThemeRegistry,
6533            ThemeRegistryHandle, default_palette,
6534        };
6535        let registry = Arc::new(InMemoryThemeRegistry::new(default_palette()));
6536        let owner = ElementOwner::Mode(
6537            crate::MultibufferMode::mode_id()
6538                .as_str()
6539                .to_string()
6540                .into(),
6541        );
6542        let ids = crate::register_multibuffer_theme_elements(registry.as_ref(), owner);
6543        let theme: ThemeRegistryHandle = registry.clone();
6544        let mb = MultibufferDocumentHandle::empty(empty_registry());
6545        mb.set_headerline(HeaderlineStatus::Complete {
6546            summary: "done".into(),
6547            emphasis: None,
6548        });
6549        let provider = MultibufferStatusProvider::with_theme(mb, theme, ids);
6550
6551        let _ = registry.resolved();
6552        let v_before = provider.version();
6553        registry.set_override(
6554            ElementName::from(ELEM_STATUS_COMPLETE.to_string()),
6555            StyleSpec::new().fg(Color::Rgb(1, 2, 3)),
6556        );
6557        let v_after = provider.version();
6558        assert!(
6559            v_after > v_before,
6560            "theme change must bump status provider version; before={v_before} after={v_after}"
6561        );
6562    }
6563}
6564
6565/// AF.1: how a multibuffer view's rows group for folding.
6566///
6567/// A per-VIEW property because the answer is not a property of multibuffers —
6568/// it is a property of what a given provider's rows mean, and the three
6569/// shipped providers disagree:
6570///
6571/// - **search** headers every excerpt with its file path, and its excerpts
6572///   arrive grouped by file, so a file IS a contiguous run;
6573/// - **project-diff** headers each hunk with its LINE NUMBER, so titles are
6574///   neither unique nor a grouping key at all — only the source path is;
6575/// - **the agenda** headers the first row of each date/section run and blanks
6576///   the rest, and its rows deliberately INTERLEAVE across files (OM.A2), so
6577///   the source path is the one thing that is not a grouping.
6578///
6579/// Hence a declaration rather than a heuristic. A provider says how its rows
6580/// group; the mode registers the matching fold source. Guessing from the
6581/// excerpt shape would be right for two of the three and silently wrong for
6582/// the third, which is how `home.org`'s fold came to swallow `work.org`'s rows.
6583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6584#[repr(u8)]
6585pub enum FoldGrouping {
6586    /// One fold per source file, spanning that file's first excerpt to its
6587    /// last. The default, and correct wherever excerpts arrive grouped by file.
6588    SourceFile = 0,
6589    /// One fold per HEADER RUN — what the user actually sees as a group.
6590    HeaderRuns = 1,
6591}
6592
6593/// Namespace for header-group fold provider IDs.
6594const HEADER_GROUP_FOLD_NAMESPACE: u64 = 0xBBBB_0005_0000_0000;
6595
6596/// AF.1: one open [`lattice_core::Fold`] per **header run** — the rows a user
6597/// sees under one header.
6598///
6599/// The grouping rule handles both header conventions in the tree with one
6600/// pass, which is what lets this be a shared mechanism rather than an
6601/// agenda-shaped special case:
6602///
6603/// - a **non-empty** title that differs from the current group's starts a new
6604///   group (search's per-excerpt file headers; the agenda's run labels);
6605/// - a **non-empty** title equal to the current group's continues it (search's
6606///   second and later excerpts from one file);
6607/// - an **empty** title continues the current group (the agenda's rows after
6608///   the first of a run — an empty title renders no header row, which is
6609///   exactly what "I belong to the group above" means).
6610///
6611/// `identity` is the group's TITLE, so the closed/open state survives a `gr`
6612/// refresh — `recompute_folds` carries state by matching identity, and the
6613/// title is what the user was collapsing. That is the same reasoning PD.5a
6614/// applied to paths for [`FileBoundaryFoldProvider`], and it holds here for the
6615/// same reason: the hit-count badge lives in its own field rather than in
6616/// `title`, so a refresh that changes the count does not change the identity.
6617pub struct HeaderGroupFoldProvider {
6618    id: lattice_core::ProviderId,
6619    handle: MultibufferDocumentHandle,
6620}
6621
6622impl HeaderGroupFoldProvider {
6623    pub fn new(handle: MultibufferDocumentHandle, buffer_id: BufferId) -> Self {
6624        let id = lattice_core::ProviderId(HEADER_GROUP_FOLD_NAMESPACE | buffer_id.0 as u64);
6625        Self { id, handle }
6626    }
6627}
6628
6629impl lattice_core::FoldSource for HeaderGroupFoldProvider {
6630    fn id(&self) -> lattice_core::ProviderId {
6631        self.id
6632    }
6633
6634    fn compute_folds(&self) -> Vec<lattice_core::Fold> {
6635        let excerpts = self.handle.excerpts();
6636        let starts = crate::motions::excerpt_start_rows(&excerpts);
6637        let mut folds: Vec<lattice_core::Fold> = Vec::new();
6638        let mut current: Option<String> = None;
6639        for (excerpt, &start) in excerpts.iter().zip(starts.iter()) {
6640            let end = start.saturating_add(excerpt.line_count().saturating_sub(1));
6641            let title = excerpt.header.title.as_str();
6642            let starts_group = !title.is_empty() && current.as_deref() != Some(title);
6643            if starts_group {
6644                current = Some(title.to_string());
6645                let mut hasher = std::collections::hash_map::DefaultHasher::new();
6646                std::hash::Hash::hash(title, &mut hasher);
6647                folds.push(lattice_core::Fold {
6648                    start_line: start,
6649                    end_line: end,
6650                    closed: false,
6651                    identity: Some(std::hash::Hasher::finish(&hasher)),
6652                });
6653            } else if let Some(last) = folds.last_mut() {
6654                // Extend the open group. `max` rather than assignment because
6655                // an excerpt's rows are contiguous but a provider is not
6656                // required to emit them in row order.
6657                last.end_line = last.end_line.max(end);
6658            }
6659            // No `else`: rows before the first header belong to no group and
6660            // get no fold, rather than being swept into a group that has not
6661            // started. A view whose first excerpt has an empty title is
6662            // malformed rather than something to guess about.
6663        }
6664        folds
6665    }
6666}
6667
6668// ── M.7: ExcerptFoldProvider ────────────────────────────────────────────────
6669
6670/// Namespace for excerpt fold provider IDs: `0xBBBB_0003_0000_0000 | buffer_id`.
6671/// Distinct from the header provider (`0xBBBB_0001_*`) and status
6672/// provider (`0xBBBB_0002_*`) namespaces.
6673const EXCERPT_FOLD_NAMESPACE: u64 = 0xBBBB_0003_0000_0000;
6674const FILE_BOUNDARY_FOLD_NAMESPACE: u64 = 0xBBBB_0004_0000_0000;
6675
6676/// M.7: computes one open [`lattice_core::Fold`] per excerpt in the
6677/// composed multibuffer. Registered as a
6678/// [`lattice_core::FoldSource`] by `MultibufferMode::on_activate`
6679/// via `FoldOverlayService`; the `FoldSourceAdapter` in `lattice-host`
6680/// gates `compute_folds` to calls where `FoldContext::buffer_id`
6681/// matches this multibuffer's buffer ID.
6682pub struct ExcerptFoldProvider {
6683    id: lattice_core::ProviderId,
6684    handle: MultibufferDocumentHandle,
6685}
6686
6687impl ExcerptFoldProvider {
6688    pub fn new(handle: MultibufferDocumentHandle, buffer_id: BufferId) -> Self {
6689        let id = lattice_core::ProviderId(EXCERPT_FOLD_NAMESPACE | buffer_id.0 as u64);
6690        Self { id, handle }
6691    }
6692}
6693
6694impl lattice_core::FoldSource for ExcerptFoldProvider {
6695    fn id(&self) -> lattice_core::ProviderId {
6696        self.id
6697    }
6698
6699    /// One fold per excerpt — **except a single-line one, which is not a
6700    /// fold at all** (OA.4c).
6701    ///
6702    /// A fold whose `start_line == end_line` can never hide anything: closed,
6703    /// it shows its head, which is the whole of it. Emitting one is not merely
6704    /// wasteful, it is actively wrong, because `innermost_fold_idx` picks the
6705    /// fold with the greatest `start_line` — so a degenerate per-excerpt fold
6706    /// SHADOWS the group fold that actually contains the row, and `<Tab>`
6707    /// toggles something with no visible effect while the block stays closed.
6708    ///
6709    /// That is what one-line agenda rows (OA.1) turned this into: before them
6710    /// an agenda excerpt spanned its planning line, so every fold here was a
6711    /// real two-line one. The report was "`<Tab>` doesn't work cleanly, only
6712    /// `<S-Tab>` seems to work" — `<S-Tab>` sets every fold at once, so it was
6713    /// unaffected.
6714    ///
6715    /// Generic, not agenda-shaped: project search with `context_lines = 0`
6716    /// produces one-line excerpts too.
6717    fn compute_folds(&self) -> Vec<lattice_core::Fold> {
6718        let excerpts = self.handle.excerpts();
6719        let starts = crate::motions::excerpt_start_rows(&excerpts);
6720        excerpts
6721            .iter()
6722            .zip(starts.iter())
6723            .filter(|(excerpt, _)| excerpt.line_count() > 1)
6724            .map(|(excerpt, &start)| {
6725                let line_count = excerpt.line_count();
6726                let end = start.saturating_add(line_count.saturating_sub(1));
6727                lattice_core::Fold {
6728                    start_line: start,
6729                    end_line: end,
6730                    closed: false,
6731                    identity: Some(excerpt.id.0),
6732                }
6733            })
6734            .collect()
6735    }
6736}
6737
6738/// M.8: computes one open [`lattice_core::Fold`] per distinct source
6739/// [`BufferId`] (file), spanning the composed-row range from the first
6740/// to the last excerpt belonging to that file. Registered alongside
6741/// `ExcerptFoldProvider` by `MultibufferMode::on_activate`. Enables
6742/// collapsing all excerpts from a file to its header row with `za`.
6743///
6744/// PD.5a: the fold's `identity` is derived from the source **path**,
6745/// not its `BufferId`. `recompute_folds` carries closed/open state
6746/// across a rebuild by matching identity, and a refreshing provider
6747/// re-reads its files under fresh `BufferId::next()` values — so a
6748/// BufferId-derived identity changes on every refresh, the match
6749/// misses, and every file the user had collapsed springs back open.
6750/// The path is what the user was collapsing, so it is what the
6751/// identity should track.
6752pub struct FileBoundaryFoldProvider {
6753    id: lattice_core::ProviderId,
6754    handle: MultibufferDocumentHandle,
6755}
6756
6757impl FileBoundaryFoldProvider {
6758    pub fn new(handle: MultibufferDocumentHandle, buffer_id: BufferId) -> Self {
6759        let id = lattice_core::ProviderId(FILE_BOUNDARY_FOLD_NAMESPACE | buffer_id.0 as u64);
6760        Self { id, handle }
6761    }
6762}
6763
6764impl lattice_core::FoldSource for FileBoundaryFoldProvider {
6765    fn id(&self) -> lattice_core::ProviderId {
6766        self.id
6767    }
6768
6769    fn compute_folds(&self) -> Vec<lattice_core::Fold> {
6770        let excerpts = self.handle.excerpts();
6771        if excerpts.is_empty() {
6772            return Vec::new();
6773        }
6774        let starts = crate::motions::excerpt_start_rows(&excerpts);
6775        // Group by source BufferId: track (start_row, end_row) per file.
6776        // First appearance sets start_row; later excerpts from the same
6777        // file extend end_row (handles non-contiguous same-file excerpts).
6778        let mut by_source: std::collections::HashMap<BufferId, (u32, u32)> =
6779            std::collections::HashMap::new();
6780        for (excerpt, &start) in excerpts.iter().zip(starts.iter()) {
6781            let end = start.saturating_add(excerpt.line_count().saturating_sub(1));
6782            by_source
6783                .entry(excerpt.source)
6784                .and_modify(|(_, e)| *e = end)
6785                .or_insert((start, end));
6786        }
6787        // Sort by start_row for deterministic output order.
6788        let mut groups: Vec<(BufferId, (u32, u32))> = by_source.into_iter().collect();
6789        groups.sort_by_key(|(_, (start, _))| *start);
6790        groups
6791            .into_iter()
6792            .map(|(source, (start, end))| lattice_core::Fold {
6793                start_line: start,
6794                end_line: end,
6795                closed: false,
6796                identity: Some(self.source_identity(source)),
6797            })
6798            .collect()
6799    }
6800}
6801
6802impl FileBoundaryFoldProvider {
6803    /// PD.5a: stable identity for a source file's fold.
6804    ///
6805    /// Hashed from the source's path when it has one. A pathless source
6806    /// — a synthetic buffer, or a scratch source a provider composes in
6807    /// memory — falls back to its `BufferId`, which is as stable as such
6808    /// a source gets and at least keeps the identity unique.
6809    ///
6810    /// The two spaces are kept apart by a discriminant byte, so a path
6811    /// whose hash happens to equal some buffer id cannot be mistaken for
6812    /// that buffer's pathless fold.
6813    fn source_identity(&self, source: BufferId) -> u64 {
6814        use std::hash::{Hash, Hasher};
6815        match self.handle.source_path(source) {
6816            Some(path) => {
6817                let mut h = std::collections::hash_map::DefaultHasher::new();
6818                0u8.hash(&mut h);
6819                path.hash(&mut h);
6820                h.finish()
6821            }
6822            None => {
6823                let mut h = std::collections::hash_map::DefaultHasher::new();
6824                1u8.hash(&mut h);
6825                source.0.hash(&mut h);
6826                h.finish()
6827            }
6828        }
6829    }
6830}
6831
6832#[cfg(test)]
6833mod excerpt_fold_tests {
6834    #![allow(clippy::unwrap_used)]
6835    use std::sync::Arc;
6836    use std::sync::atomic::{AtomicBool, Ordering};
6837
6838    use lattice_core::Document as CoreDocument;
6839    use lattice_core::DocumentBuilder as CoreDocumentBuilder;
6840    use lattice_core::{FoldOverlayService, FoldOverlayServiceHandle, FoldSource};
6841    use lattice_grammar::CommandRegistry;
6842    use lattice_runtime::spawn_document;
6843
6844    use super::*;
6845
6846    fn reg() -> lattice_grammar::CommandRegistryHandle {
6847        Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()))
6848    }
6849
6850    #[tokio::test(flavor = "current_thread")]
6851    async fn compute_folds_returns_one_fold_per_excerpt() {
6852        let r = reg();
6853        let buf_a = BufferId::next();
6854        let buf_b = BufferId::next();
6855        let doc_a = spawn_document(buf_a, CoreDocument::from_text("a\nb\nc\n"), r.clone());
6856        let doc_b = spawn_document(buf_b, CoreDocument::from_text("x\ny\n"), r.clone());
6857        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
6858        sources.insert(buf_a, Arc::new(doc_a));
6859        sources.insert(buf_b, Arc::new(doc_b));
6860        let excerpts = vec![
6861            Excerpt::new(buf_a, 0, 2), // 3 lines → composed rows 0–2
6862            Excerpt::new(buf_b, 0, 1), // 2 lines → composed rows 3–4
6863        ];
6864        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
6865        let buf_id = mb.buffer_id();
6866        let provider = ExcerptFoldProvider::new(mb, buf_id);
6867        let folds = provider.compute_folds();
6868        assert_eq!(folds.len(), 2);
6869        assert_eq!(folds[0].start_line, 0);
6870        assert_eq!(folds[0].end_line, 2);
6871        assert!(!folds[0].closed);
6872        assert!(folds[0].identity.is_some());
6873        assert_eq!(folds[1].start_line, 3);
6874        assert_eq!(folds[1].end_line, 4);
6875        assert!(!folds[1].closed);
6876        assert!(folds[1].identity.is_some());
6877    }
6878
6879    #[test]
6880    fn excerpt_fold_namespace_distinct_from_header_and_status() {
6881        let buf = BufferId(42);
6882        let fold_id = ExcerptFoldProvider::new(MultibufferDocumentHandle::empty(reg()), buf).id;
6883        // header/status ProviderId uses lattice_cells::virtual_rows::ProviderId;
6884        // compare raw u64 values to verify no namespace collision.
6885        let header_raw: u64 = multibuffer_excerpt_header_provider_id(buf);
6886        let status_raw: u64 = multibuffer_status_provider_id(buf);
6887        assert_ne!(fold_id.0, header_raw);
6888        assert_ne!(fold_id.0, status_raw);
6889        assert_eq!(fold_id.0 >> 32, 0xBBBB_0003);
6890        assert_eq!(fold_id.0 & 0xFFFF_FFFF, 42);
6891    }
6892
6893    #[test]
6894    fn compute_folds_empty_when_no_excerpts() {
6895        let mb = MultibufferDocumentHandle::empty(reg());
6896        let buf_id = mb.buffer_id();
6897        let provider = ExcerptFoldProvider::new(mb, buf_id);
6898        assert!(provider.compute_folds().is_empty());
6899    }
6900
6901    // ── MultibufferModeGuard drop ───────────────────────────────────────
6902
6903    struct MockFoldService {
6904        removed: Arc<AtomicBool>,
6905    }
6906    impl FoldOverlayService for MockFoldService {
6907        fn add_source(
6908            &self,
6909            _source: Arc<dyn FoldSource>,
6910            _buffer_id: BufferId,
6911        ) -> lattice_core::ProviderId {
6912            lattice_core::ProviderId(1)
6913        }
6914        fn remove_source(&self, _id: lattice_core::ProviderId) {
6915            self.removed.store(true, Ordering::SeqCst);
6916        }
6917    }
6918
6919    #[test]
6920    fn mode_guard_drop_calls_remove_source() {
6921        let removed = Arc::new(AtomicBool::new(false));
6922        let svc: FoldOverlayServiceHandle = Arc::new(MockFoldService {
6923            removed: removed.clone(),
6924        });
6925        {
6926            let _guard = crate::mode::MultibufferModeGuard {
6927                fold_registrations: vec![(svc, lattice_core::ProviderId(1))],
6928                // Empty: this test covers the fold-registration half
6929                // of Drop only. The action-handler tokens unregister
6930                // themselves via their own Drop impl, which
6931                // `lattice-mode` tests separately.
6932                _action_handler_registrations: Vec::new(),
6933            };
6934        }
6935        assert!(
6936            removed.load(Ordering::SeqCst),
6937            "remove_source must fire on guard drop"
6938        );
6939    }
6940
6941    // ── AF.1: HeaderGroupFoldProvider ───────────────────────────────────
6942
6943    /// **The agenda's convention**: a label on the first row of a run, an
6944    /// empty title on the rest. An empty title renders no header row, which is
6945    /// exactly what "I belong to the group above" means — so it continues the
6946    /// group rather than starting one.
6947    #[tokio::test(flavor = "current_thread")]
6948    async fn header_groups_fold_a_run_that_interleaves_files() {
6949        let r = reg();
6950        let buf_a = BufferId::next();
6951        let buf_b = BufferId::next();
6952        let doc_a = spawn_document(buf_a, CoreDocument::from_text("a\nb\nc\n"), r.clone());
6953        let doc_b = spawn_document(buf_b, CoreDocument::from_text("x\ny\nz\n"), r.clone());
6954        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
6955        sources.insert(buf_a, Arc::new(doc_a));
6956        sources.insert(buf_b, Arc::new(doc_b));
6957        // Two groups, each drawing rows from BOTH files — the agenda's shape.
6958        let excerpts = vec![
6959            Excerpt::new(buf_a, 0, 0).with_header(ExcerptHeader::new("Overdue".to_string())),
6960            Excerpt::new(buf_b, 0, 0).with_header(ExcerptHeader::new(String::new())),
6961            Excerpt::new(buf_a, 1, 1).with_header(ExcerptHeader::new("Today".to_string())),
6962            Excerpt::new(buf_b, 1, 1).with_header(ExcerptHeader::new(String::new())),
6963        ];
6964        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
6965        let buf_id = mb.buffer_id();
6966        let folds = HeaderGroupFoldProvider::new(mb, buf_id).compute_folds();
6967
6968        assert_eq!(folds.len(), 2, "one fold per header run, got {folds:?}");
6969        assert_eq!((folds[0].start_line, folds[0].end_line), (0, 1), "Overdue");
6970        assert_eq!((folds[1].start_line, folds[1].end_line), (2, 3), "Today");
6971        // The property `FileBoundaryFoldProvider` cannot give the agenda: no
6972        // fold spans a row belonging to a different group.
6973        assert!(
6974            folds[0].end_line < folds[1].start_line,
6975            "groups must not overlap: {folds:?}"
6976        );
6977    }
6978
6979    /// **Search's convention**: EVERY excerpt carries its file's path as the
6980    /// title. A repeated title continues the run, so this still yields one
6981    /// fold per file — the same answer `FileBoundaryFoldProvider` gives on the
6982    /// layout it was built for. That equivalence is what makes this a shared
6983    /// mechanism rather than an agenda-shaped special case.
6984    #[tokio::test(flavor = "current_thread")]
6985    async fn header_groups_match_file_folds_on_the_search_layout() {
6986        let r = reg();
6987        let buf_a = BufferId::next();
6988        let buf_b = BufferId::next();
6989        let doc_a = spawn_document(buf_a, CoreDocument::from_text("a\nb\nc\n"), r.clone());
6990        let doc_b = spawn_document(buf_b, CoreDocument::from_text("x\ny\n"), r.clone());
6991        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
6992        sources.insert(buf_a, Arc::new(doc_a));
6993        sources.insert(buf_b, Arc::new(doc_b));
6994        let hdr = |p: &str| ExcerptHeader::new(p.to_string());
6995        let excerpts = vec![
6996            Excerpt::new(buf_a, 0, 0).with_header(hdr("/a.rs")),
6997            Excerpt::new(buf_a, 1, 1).with_header(hdr("/a.rs")),
6998            Excerpt::new(buf_b, 0, 0).with_header(hdr("/b.rs")),
6999        ];
7000        let mb = MultibufferDocumentHandle::new(sources, excerpts.clone(), r.clone()).unwrap();
7001        let buf_id = mb.buffer_id();
7002        let header_folds = HeaderGroupFoldProvider::new(mb.clone(), buf_id).compute_folds();
7003        let file_folds = FileBoundaryFoldProvider::new(mb, buf_id).compute_folds();
7004
7005        let bounds = |fs: &[lattice_core::Fold]| -> Vec<(u32, u32)> {
7006            let mut v: Vec<_> = fs.iter().map(|f| (f.start_line, f.end_line)).collect();
7007            v.sort_unstable();
7008            v
7009        };
7010        assert_eq!(
7011            bounds(&header_folds),
7012            bounds(&file_folds),
7013            "on a file-grouped layout the two providers must agree"
7014        );
7015        assert_eq!(bounds(&header_folds), vec![(0, 1), (2, 2)]);
7016    }
7017
7018    /// Identity is the group's TITLE, so a `gr` refresh that mints new
7019    /// `BufferId`s keeps the user's collapsed groups collapsed. Same reasoning
7020    /// as PD.5a's path identity, and the reason a row-position fallback is not
7021    /// enough: the rows themselves move.
7022    #[tokio::test(flavor = "current_thread")]
7023    async fn header_group_identity_is_the_title_not_the_rows() {
7024        let build = |extra: &str| {
7025            let r = reg();
7026            let id = BufferId::next();
7027            let doc = spawn_document(id, CoreDocument::from_text("a\nb\nc\n"), r.clone());
7028            let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7029            sources.insert(id, Arc::new(doc) as Arc<dyn Document>);
7030            // `extra` shifts the group DOWN a row between the two builds.
7031            let mut excerpts = Vec::new();
7032            if !extra.is_empty() {
7033                excerpts.push(
7034                    Excerpt::new(id, 0, 0).with_header(ExcerptHeader::new(extra.to_string())),
7035                );
7036            }
7037            excerpts
7038                .push(Excerpt::new(id, 1, 1).with_header(ExcerptHeader::new("Today".to_string())));
7039            let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
7040            let buf_id = mb.buffer_id();
7041            (id, HeaderGroupFoldProvider::new(mb, buf_id).compute_folds())
7042        };
7043        let (first, before) = build("");
7044        let (second, after) = build("Overdue");
7045        assert_ne!(first, second, "the fixture must mint a new BufferId");
7046
7047        let today_before = before.iter().find(|_| true).expect("a fold");
7048        let today_after = after.last().expect("the Today fold");
7049        assert_ne!(
7050            today_before.start_line, today_after.start_line,
7051            "the fixture must MOVE the group, or identity is not what is tested"
7052        );
7053        assert_eq!(
7054            today_before.identity, today_after.identity,
7055            "same title across a rebuild must keep its fold identity"
7056        );
7057    }
7058
7059    /// Rows before the first header belong to no group and get no fold, rather
7060    /// than being swept into a group that has not started.
7061    #[tokio::test(flavor = "current_thread")]
7062    async fn rows_before_the_first_header_are_not_folded() {
7063        let r = reg();
7064        let id = BufferId::next();
7065        let doc = spawn_document(id, CoreDocument::from_text("a\nb\nc\n"), r.clone());
7066        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7067        sources.insert(id, Arc::new(doc));
7068        let excerpts = vec![
7069            Excerpt::new(id, 0, 0).with_header(ExcerptHeader::new(String::new())),
7070            Excerpt::new(id, 1, 1).with_header(ExcerptHeader::new("Group".to_string())),
7071        ];
7072        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
7073        let buf_id = mb.buffer_id();
7074        let folds = HeaderGroupFoldProvider::new(mb, buf_id).compute_folds();
7075        assert_eq!(folds.len(), 1, "got {folds:?}");
7076        assert_eq!(folds[0].start_line, 1, "the fold starts at the header row");
7077    }
7078
7079    /// A view that declares nothing keeps the pre-AF.1 grouping.
7080    #[tokio::test(flavor = "current_thread")]
7081    async fn fold_grouping_defaults_to_source_file() {
7082        let mb = MultibufferDocumentHandle::empty(reg());
7083        assert_eq!(mb.fold_grouping(), FoldGrouping::SourceFile);
7084        mb.set_fold_grouping(FoldGrouping::HeaderRuns);
7085        assert_eq!(mb.fold_grouping(), FoldGrouping::HeaderRuns);
7086    }
7087
7088    // ── FileBoundaryFoldProvider ────────────────────────────────────────
7089
7090    #[tokio::test(flavor = "current_thread")]
7091    async fn file_boundary_folds_one_fold_per_source_file() {
7092        let r = reg();
7093        let buf_a = BufferId::next();
7094        let buf_b = BufferId::next();
7095        let doc_a = spawn_document(buf_a, CoreDocument::from_text("a\nb\nc\n"), r.clone());
7096        let doc_b = spawn_document(buf_b, CoreDocument::from_text("x\ny\n"), r.clone());
7097        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7098        sources.insert(buf_a, Arc::new(doc_a));
7099        sources.insert(buf_b, Arc::new(doc_b));
7100        // Two excerpts from buf_a, one from buf_b.
7101        // buf_a excerpt 1: rows 0-2 (3 lines); buf_a excerpt 2: rows 3-4 (2 lines);
7102        // buf_b excerpt:   rows 5-6 (2 lines)
7103        let excerpts = vec![
7104            Excerpt::new(buf_a, 0, 2),
7105            Excerpt::new(buf_a, 0, 1),
7106            Excerpt::new(buf_b, 0, 1),
7107        ];
7108        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
7109        let buf_id = mb.buffer_id();
7110        let provider = FileBoundaryFoldProvider::new(mb, buf_id);
7111        let folds = provider.compute_folds();
7112        assert_eq!(folds.len(), 2, "one fold per source file");
7113        // buf_a fold spans rows 0-4 (first excerpt start to second excerpt end).
7114        let a_fold = folds
7115            .iter()
7116            .find(|f| f.start_line == 0)
7117            .expect("buf_a fold");
7118        assert_eq!(a_fold.end_line, 4);
7119        // buf_b fold spans rows 5-6.
7120        let b_fold = folds
7121            .iter()
7122            .find(|f| f.start_line == 5)
7123            .expect("buf_b fold");
7124        assert_eq!(b_fold.end_line, 6);
7125        // PD.5a: identity used to be the raw `BufferId`, and this test
7126        // asserted exactly that. It is now derived (path when there is
7127        // one, hashed buffer id otherwise), so the raw value was an
7128        // implementation detail to stop pinning. What the fold engine
7129        // actually needs is that the two files do not collide — see
7130        // `file_boundary_fold_identity_survives_a_refresh` for the
7131        // stability half.
7132        assert!(a_fold.identity.is_some());
7133        assert_ne!(a_fold.identity, b_fold.identity);
7134    }
7135
7136    #[test]
7137    fn file_boundary_fold_namespace_distinct() {
7138        let buf = BufferId(42);
7139        let fold_id =
7140            FileBoundaryFoldProvider::new(MultibufferDocumentHandle::empty(reg()), buf).id;
7141        let excerpt_id = ExcerptFoldProvider::new(MultibufferDocumentHandle::empty(reg()), buf).id;
7142        let header_raw: u64 = multibuffer_excerpt_header_provider_id(buf);
7143        let status_raw: u64 = multibuffer_status_provider_id(buf);
7144        assert_ne!(fold_id.0, excerpt_id.0);
7145        assert_ne!(fold_id.0, header_raw);
7146        assert_ne!(fold_id.0, status_raw);
7147        assert_eq!(fold_id.0 >> 32, 0xBBBB_0004);
7148        assert_eq!(fold_id.0 & 0xFFFF_FFFF, 42);
7149    }
7150
7151    /// **A file-boundary fold spans every row between a file's first and last
7152    /// excerpt — including OTHER files' rows when they interleave.**
7153    ///
7154    /// Harmless for the search provider, whose excerpts arrive grouped by
7155    /// file. Wrong for the agenda, whose whole point is that rows interleave
7156    /// across files by date (OM.A2): collapsing `home.org` there would
7157    /// swallow the `work.org` rows sitting between its earliest and latest
7158    /// entry, and the fold's header would claim rows that are not its.
7159    ///
7160    /// Pinned as the CURRENT behaviour rather than as a bug to fix in place —
7161    /// it is correct for the provider it was built for. The agenda's answer
7162    /// is to group folds by what the user actually sees (the header runs),
7163    /// which is `HeaderGroupFoldProvider`.
7164    #[tokio::test(flavor = "current_thread")]
7165    async fn file_boundary_folds_swallow_interleaved_rows() {
7166        let r = reg();
7167        let buf_a = BufferId::next();
7168        let buf_b = BufferId::next();
7169        let doc_a = spawn_document(buf_a, CoreDocument::from_text("a\nb\nc\n"), r.clone());
7170        let doc_b = spawn_document(buf_b, CoreDocument::from_text("x\ny\n"), r.clone());
7171        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7172        sources.insert(buf_a, Arc::new(doc_a));
7173        sources.insert(buf_b, Arc::new(doc_b));
7174        // INTERLEAVED, the agenda's shape: a(row 0), b(row 1), a(row 2).
7175        let excerpts = vec![
7176            Excerpt::new(buf_a, 0, 0),
7177            Excerpt::new(buf_b, 0, 0),
7178            Excerpt::new(buf_a, 1, 1),
7179        ];
7180        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
7181        let buf_id = mb.buffer_id();
7182        let folds = FileBoundaryFoldProvider::new(mb, buf_id).compute_folds();
7183
7184        let a_fold = folds
7185            .iter()
7186            .find(|f| f.start_line == 0)
7187            .expect("buf_a fold");
7188        assert_eq!(
7189            (a_fold.start_line, a_fold.end_line),
7190            (0, 2),
7191            "buf_a's fold spans row 1, which belongs to buf_b"
7192        );
7193    }
7194
7195    #[test]
7196    fn file_boundary_folds_empty_when_no_excerpts() {
7197        let mb = MultibufferDocumentHandle::empty(reg());
7198        let buf_id = mb.buffer_id();
7199        let provider = FileBoundaryFoldProvider::new(mb, buf_id);
7200        assert!(provider.compute_folds().is_empty());
7201    }
7202
7203    // ── PD.5a: identity has to outlive the source buffer ──────
7204    //
7205    // `recompute_folds` carries the closed/open state across a rebuild
7206    // by matching `Fold::identity`, falling back to `(start_line,
7207    // end_line)`. A refreshing provider re-reads its files and calls
7208    // `BufferId::next()` for each one (`attach_batch` in project-diff,
7209    // and the search provider does the same), so a `BufferId`-derived
7210    // identity is a fresh number on every refresh: the match misses,
7211    // the fallback keys on rows that have themselves moved, and every
7212    // file the user collapsed springs back open. Deriving identity from
7213    // the source *path* fixes it, because the path is the thing the
7214    // user was collapsing.
7215
7216    /// Same file, same path, new `BufferId` — the shape a `gr` refresh
7217    /// produces.
7218    #[tokio::test]
7219    async fn file_boundary_fold_identity_survives_a_refresh() {
7220        let build = |text: &str| {
7221            let r = reg();
7222            let id = BufferId::next();
7223            let doc = CoreDocumentBuilder::default()
7224                .with_text(text)
7225                .with_path(std::path::PathBuf::from("/repo/src/main.rs"))
7226                .build();
7227            let handle = spawn_document(id, doc, r.clone());
7228            let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7229            sources.insert(id, Arc::new(handle) as Arc<dyn Document>);
7230            let mb =
7231                MultibufferDocumentHandle::new(sources, vec![Excerpt::new(id, 0, 1)], r).unwrap();
7232            let buf_id = mb.buffer_id();
7233            (
7234                id,
7235                FileBoundaryFoldProvider::new(mb, buf_id).compute_folds(),
7236            )
7237        };
7238
7239        let (first_id, before) = build("a\nb\n");
7240        let (second_id, after) = build("a\nchanged\n");
7241
7242        assert_ne!(
7243            first_id, second_id,
7244            "the fixture must mint a new BufferId, or this proves nothing"
7245        );
7246        assert_eq!(before.len(), 1);
7247        assert_eq!(after.len(), 1);
7248        assert_eq!(
7249            before[0].identity, after[0].identity,
7250            "same path across a refresh must keep its fold identity"
7251        );
7252    }
7253
7254    /// Two different files must not collide, or collapsing one would
7255    /// collapse the other.
7256    #[tokio::test]
7257    async fn file_boundary_fold_identity_differs_between_paths() {
7258        let r = reg();
7259        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7260        let mut ids = Vec::new();
7261        for path in ["/repo/src/a.rs", "/repo/src/b.rs"] {
7262            let id = BufferId::next();
7263            let doc = CoreDocumentBuilder::default()
7264                .with_text("x\ny\n")
7265                .with_path(std::path::PathBuf::from(path))
7266                .build();
7267            sources.insert(
7268                id,
7269                Arc::new(spawn_document(id, doc, r.clone())) as Arc<dyn Document>,
7270            );
7271            ids.push(id);
7272        }
7273        let excerpts = ids.iter().map(|id| Excerpt::new(*id, 0, 1)).collect();
7274        let mb = MultibufferDocumentHandle::new(sources, excerpts, r).unwrap();
7275        let buf_id = mb.buffer_id();
7276        let folds = FileBoundaryFoldProvider::new(mb, buf_id).compute_folds();
7277        assert_eq!(folds.len(), 2);
7278        assert_ne!(
7279            folds[0].identity, folds[1].identity,
7280            "distinct paths must get distinct fold identities"
7281        );
7282    }
7283
7284    /// A source with no path — a synthetic buffer, or a scratch source a
7285    /// future provider composes — still needs *an* identity. It falls
7286    /// back to the buffer id, which is as stable as such a source gets.
7287    #[tokio::test]
7288    async fn a_pathless_source_still_gets_an_identity() {
7289        let r = reg();
7290        let id = BufferId::next();
7291        let doc = spawn_document(id, CoreDocument::from_text("a\nb\n"), r.clone());
7292        let mut sources: HashMap<BufferId, Arc<dyn Document>> = HashMap::new();
7293        sources.insert(id, Arc::new(doc) as Arc<dyn Document>);
7294        let mb = MultibufferDocumentHandle::new(sources, vec![Excerpt::new(id, 0, 1)], r).unwrap();
7295        let buf_id = mb.buffer_id();
7296        let folds = FileBoundaryFoldProvider::new(mb, buf_id).compute_folds();
7297        assert_eq!(folds.len(), 1);
7298        assert!(folds[0].identity.is_some());
7299    }
7300}