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 ®istry,
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}