Skip to main content

lattice_multibuffer/
mode.rs

1//! M.2.b.2 (2026-06-01): `MultibufferMode` — the major mode bound
2//! to `BufferKind::Multibuffer` via H.2's `Mode::target_buffer_kind`
3//! declaration.
4//!
5//! Thin major: contributes `ReadOnly = true` + `NoFile = true`
6//! (M.3 will make `ReadOnly` conditional once edit propagation
7//! lands). Excerpt-jump motion keymap (`]e` / `[e` / `]E` / `[E`)
8//! arrives in M.2.b.3. Provider-specific behaviour layers on as
9//! minor modes (`ProjectSearchMode` etc., M.6+).
10//!
11//! `register_multibuffer_modes(®istry, &events, mb_registry)`
12//! is the single boot-wiring entry point the host calls. It
13//! registers `MultibufferMode` AND wires the
14//! `Event::DocumentClosed` subscriber that removes the closed
15//! multibuffer's entry from the `MultibufferRegistry` (cleanup
16//! contract per `multibuffer-views.md` §3.7).
17//!
18//! See `docs/dev/architecture/multibuffer-views.md` §3.7.
19
20use std::sync::{Arc, OnceLock};
21
22use lattice_config::{OptionOverrideSet, overrides};
23use lattice_core::{BufferKind, FoldOverlayServiceHandle, ProviderId};
24use lattice_grammar::{CommandRegistryHandle, Effect};
25use lattice_mode::{
26    ActionContext, ActionHandler, ActionHandlerRegistration, ActionHandlerRegistryHandle,
27    CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
28    ModeRegistry, keymap_entry,
29};
30use lattice_protocol::{Event, EventKind};
31use lattice_runtime::{EventBus, EventFilter, SubscriptionTarget};
32use lattice_theme::{ElementOwner, ThemeRegistryHandle};
33
34use crate::registry::MultibufferRegistryHandle;
35
36/// M.7 / M.8: deregisters all fold overlay providers when the mode is
37/// deactivated. Holds one entry per registered provider
38/// (`ExcerptFoldProvider` + `FileBoundaryFoldProvider`). `Drop` fires
39/// when the buffer's major mode is swapped out or the buffer closes.
40/// Also drops the generic `<CR>` jump-to-source action handler
41/// registration on deactivation.
42pub struct MultibufferModeGuard {
43    pub(crate) fold_registrations: Vec<(FoldOverlayServiceHandle, ProviderId)>,
44    /// RAII tokens for action-handler registrations made in
45    /// `on_activate`. Dropping the Guard drops these, which in
46    /// turn unregisters the closures from `ActionHandlerRegistry`.
47    /// Currently: `<CR>` jump-to-source.
48    ///
49    /// `pub(crate)` (matching `fold_registrations`) so the
50    /// guard-drop unit test in `lib.rs` can construct a guard
51    /// directly — it was left private when this field was added,
52    /// which broke that test's struct literal and stopped the whole
53    /// crate's test target from compiling.
54    pub(crate) _action_handler_registrations: Vec<ActionHandlerRegistration>,
55}
56
57impl Drop for MultibufferModeGuard {
58    fn drop(&mut self) {
59        for (svc, id) in self.fold_registrations.drain(..) {
60            svc.remove_source(id);
61        }
62    }
63}
64
65/// K.2.5 (2026-06-02): static keymap catalog for `MultibufferMode`.
66///
67/// Four excerpt-jump motions registered by
68/// [`crate::register_multibuffer_motions`] (`motions.rs:57-109`)
69/// against the [`lattice_grammar::CommandRegistry`] under their
70/// canonical names. The host translation pass
71/// (`crates/lattice-host/src/keymap_mode_contributions.rs`)
72/// resolves each row's `cmd` string at registration time and
73/// builds a `KeymapBinding` carrying the entry's `doc` and
74/// macro-captured `source`.
75///
76/// Replaces `crates/lattice-host/src/multibuffer_keymap.rs`'s
77/// `multibuffer_mode_layer_bindings` which built the layer
78/// trie by hand and was pushed explicitly via
79/// `KeymapHandle::push_layer` at boot. The K.2.4 translation
80/// pass handles that uniformly now; no per-mode host glue.
81fn multibuffer_keymap_entries() -> &'static [KeymapEntry] {
82    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
83    ENTRIES.get_or_init(|| {
84        vec![
85            keymap_entry! {
86                mode: Normal, chord: "<CR>",
87                doc: "Jump to source file/row of the excerpt under cursor",
88                cmd: "action:multibuffer-jump-to-source"
89            },
90            keymap_entry! {
91                mode: Normal, chord: "]e",
92                doc: "Jump to next excerpt",
93                cmd: "multibuffer.next-excerpt-start"
94            },
95            keymap_entry! {
96                mode: Normal, chord: "[e",
97                doc: "Jump to previous excerpt",
98                cmd: "multibuffer.prev-excerpt-start"
99            },
100            keymap_entry! {
101                mode: Normal, chord: "]E",
102                doc: "Jump to next file boundary",
103                cmd: "multibuffer.next-file-boundary"
104            },
105            keymap_entry! {
106                mode: Normal, chord: "[E",
107                doc: "Jump to previous file boundary",
108                cmd: "multibuffer.prev-file-boundary"
109            },
110        ]
111    })
112}
113
114/// Major mode for buffers of [`BufferKind::Multibuffer`]. Generic;
115/// knows nothing about *why* excerpts exist. Provider-specific
116/// behaviour (project-search, lsp-references, etc.) is layered as
117/// minor modes registered by each provider's own
118/// `register_<provider>` helper.
119pub struct MultibufferMode;
120
121impl MultibufferMode {
122    pub fn mode_id() -> ModeId {
123        ModeId::new("multibuffer-mode")
124    }
125}
126
127impl Mode for MultibufferMode {
128    type Guard = MultibufferModeGuard;
129
130    fn id(&self) -> ModeId {
131        Self::mode_id()
132    }
133
134    fn kind(&self) -> ModeKind {
135        ModeKind::Major
136    }
137
138    /// H.2 (2026-05-31) + M.2.b.2 (2026-06-01): buffers whose
139    /// `BufferKind` is `Multibuffer` dispatch to this major via
140    /// `ModeRegistry::find_major_for_kind`.
141    fn target_buffer_kind(&self) -> Option<BufferKind> {
142        Some(BufferKind::Multibuffer)
143    }
144
145    fn options(&self) -> OptionOverrideSet {
146        // M.3 (2026-06-01): `ReadOnly` dropped from the major's
147        // contribution now that edit propagation lands. Providers
148        // that want a read-only view (e.g. read-only LSP-references
149        // view) layer a minor mode that contributes `ReadOnly = true`.
150        // `NoFile` stays because multibuffers aren't on-disk files;
151        // `:w` is a no-op until a provider attaches save semantics
152        // (M.6 SearchProvider's "save all sources" wrapper, etc.).
153        overrides! {
154            lattice_config::NoFile = true,
155        }
156    }
157
158    fn required_capabilities(&self) -> CapabilitySet {
159        CapabilitySet::empty()
160    }
161
162    /// K.2.5 (2026-06-02): excerpt-jump chord bindings.
163    /// `]e` / `[e` (next / previous excerpt) and `]E` / `[E`
164    /// (next / previous file boundary). Resolved at host
165    /// translation time via `CommandRegistry` against the
166    /// canonical motion names registered by
167    /// `register_multibuffer_motions`.
168    fn keymap(&self) -> Keymap {
169        Keymap::from_entries(multibuffer_keymap_entries())
170    }
171
172    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, MultibufferModeGuard> {
173        Box::pin(async move {
174            // lattice-host converts lattice_core::BufferId → lattice_protocol::ids::BufferId
175            // via `new(id.0 as u64)`; invert here so we can key into MultibufferRegistry.
176            let core_buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
177
178            // T.7 (2026-06-18): the mode OWNS its excerpt-header theme
179            // elements + their defaults — register them here so the
180            // mode is the single source of the element vocabulary
181            // ([[feedback_mode_owns_its_surface]]). Idempotent by name,
182            // so re-activation is safe; `create_multibuffer_view` also
183            // registers (before building the provider) to capture the
184            // ids — both paths hit the same interned ids. Missing
185            // service (test harness) just skips: the provider then
186            // renders with no baked bg/fg.
187            if let Some(theme) = ctx
188                .service::<ThemeRegistryHandle>()
189                .map(|outer| (*outer).clone())
190            {
191                let owner = ElementOwner::Mode(Self::mode_id().as_str().to_string().into());
192                let _ = crate::register_multibuffer_theme_elements(theme.as_ref(), owner);
193            }
194
195            // Both handle types are `Arc<dyn Trait>` aliases.
196            // `ctx.service::<T>()` returns `Option<Arc<T>>` which is
197            // `Option<Arc<Arc<dyn Trait>>>` — clone through the outer
198            // Arc to obtain the inner handle.
199            let fold_service = ctx
200                .service::<FoldOverlayServiceHandle>()
201                .map(|outer| (*outer).clone());
202            let mb_registry = ctx
203                .service::<MultibufferRegistryHandle>()
204                .map(|outer| (*outer).clone());
205
206            let mut fold_registrations = Vec::new();
207            // Clone before consuming in the match — also used by
208            // the generic `<CR>` handler registration below.
209            let mb_registry_for_handler = mb_registry.clone();
210
211            match (fold_service, mb_registry) {
212                (Some(svc), Some(reg)) => {
213                    match reg.handle(core_buffer_id) {
214                        Some(mb_handle) => {
215                            // M.7: one fold per excerpt.
216                            let excerpt_provider = Arc::new(crate::ExcerptFoldProvider::new(
217                                (*mb_handle).clone(),
218                                core_buffer_id,
219                            ));
220                            let excerpt_id = svc.add_source(excerpt_provider, core_buffer_id);
221                            fold_registrations.push((svc.clone(), excerpt_id));
222
223                            // M.8 / AF.1: the second fold source is whichever
224                            // grouping the PROVIDER that built this view
225                            // declared. Not a heuristic over the excerpt shape:
226                            // search groups by file, project-diff titles its
227                            // hunks with line numbers, and the agenda
228                            // interleaves files inside date groups on purpose —
229                            // so the same guess is right for two and silently
230                            // wrong for the third.
231                            let group_id = match mb_handle.fold_grouping() {
232                                crate::FoldGrouping::SourceFile => {
233                                    let p = Arc::new(crate::FileBoundaryFoldProvider::new(
234                                        (*mb_handle).clone(),
235                                        core_buffer_id,
236                                    ));
237                                    svc.add_source(p, core_buffer_id)
238                                }
239                                crate::FoldGrouping::HeaderRuns => {
240                                    let p = Arc::new(crate::HeaderGroupFoldProvider::new(
241                                        (*mb_handle).clone(),
242                                        core_buffer_id,
243                                    ));
244                                    svc.add_source(p, core_buffer_id)
245                                }
246                            };
247                            fold_registrations.push((svc, group_id));
248                        }
249                        None => {
250                            tracing::debug!(
251                                "MultibufferMode::on_activate: no handle for buffer {:?}; \
252                                 excerpt + file-boundary folds inactive",
253                                core_buffer_id
254                            );
255                        }
256                    }
257                }
258                _ => {
259                    tracing::debug!(
260                        "MultibufferMode::on_activate: fold service or multibuffer \
261                         registry not registered; excerpt folds inactive (expected in tests)"
262                    );
263                }
264            }
265
266            // Register the generic `<CR>` jump-to-source handler
267            // on `action:multibuffer-jump-to-source`. Makes EVERY
268            // multibuffer view support `<CR>` navigation regardless
269            // of provider (search, problems, narrow, etc.). Provider-
270            // specific minor modes that bind their own `<CR>`
271            // (e.g. `ProjectSearchMode`) shadow this via
272            // the minor-mode keymap priority — their handler fires
273            // instead.
274            let mut action_registrations: Vec<ActionHandlerRegistration> = Vec::new();
275            if let (Some(mb_reg), Some(cmd_registry_arc), Some(action_handlers_arc)) = (
276                mb_registry_for_handler,
277                ctx.service::<CommandRegistryHandle>(),
278                ctx.service::<ActionHandlerRegistryHandle>(),
279            ) {
280                let cmd_registry_snapshot = cmd_registry_arc.load();
281                if let Some(jump_command_id) =
282                    cmd_registry_snapshot.id_by_name("action:multibuffer-jump-to-source")
283                {
284                    let action_handlers: ActionHandlerRegistryHandle =
285                        (*action_handlers_arc).clone();
286                    let view_id_for_handler = core_buffer_id;
287                    let handler: ActionHandler =
288                        Arc::new(move |ctx: &ActionContext<'_>| -> Option<Effect> {
289                            let view = mb_reg.handle(view_id_for_handler)?;
290                            let (source_buffer_id, source_position) =
291                                view.translate_composed_to_source(ctx.cursor)?;
292                            let path = view.source_path(source_buffer_id)?;
293                            Some(Effect::Many(vec![
294                                Effect::RecordJump,
295                                Effect::OpenBufferAt {
296                                    path: Some(path),
297                                    position: source_position,
298                                    force: false,
299                                    content: None,
300                                    activate_minor: None,
301                                },
302                            ]))
303                        });
304                    action_registrations.push(action_handlers.register(jump_command_id, handler));
305                }
306            }
307
308            Ok(MultibufferModeGuard {
309                fold_registrations,
310                _action_handler_registrations: action_registrations,
311            })
312        })
313    }
314}
315
316/// Boot wiring entry point. Called once from `lattice-host`'s
317/// `editor_boot::boot` after the `ServiceRegistry` is populated
318/// with [`MultibufferRegistryHandle`] and the `EventBus` exists.
319///
320/// 1. Registers [`MultibufferMode`] against `registry` (so
321///    `ModeRegistry::find_major_for_kind(BufferKind::Multibuffer)`
322///    returns its id post-H.2).
323/// 2. Subscribes a `DocumentClosed` cleanup task that removes a
324///    closed multibuffer's entry from `multibuffer_registry`.
325///    Uses the existing `SubscriptionTarget::Channel` shape +
326///    `tokio::spawn` for the drain loop (same pattern as the
327///    LSP / mode-lifecycle drains).
328pub fn register_multibuffer_modes(
329    registry: &mut ModeRegistry,
330    events: &Arc<EventBus>,
331    multibuffer_registry: MultibufferRegistryHandle,
332) {
333    registry
334        .register(MultibufferMode)
335        .expect("multibuffer-mode registers without conflict at boot");
336
337    // Cleanup subscriber: only wire when a tokio runtime is in
338    // scope. Production boot runs inside the App's runtime so
339    // this fires; tests that construct `Editor` outside a runtime
340    // (`lattice-host` lib tests) gracefully skip the subscriber
341    // wiring — the registry simply leaks entries for the
342    // (short-lived) test process, which is observably fine
343    // because no test asserts cleanup behaviour.
344    let Ok(handle) = tokio::runtime::Handle::try_current() else {
345        tracing::debug!(
346            "register_multibuffer_modes: no tokio runtime in scope; \
347             skipping DocumentClosed cleanup subscriber wiring \
348             (expected in test paths)"
349        );
350        return;
351    };
352
353    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
354    events.subscribe(
355        EventFilter::kind(EventKind::DocumentClosed),
356        SubscriptionTarget::Channel(tx),
357    );
358
359    let reg = multibuffer_registry;
360    handle.spawn(async move {
361        while let Some(event) = rx.recv().await {
362            if let Event::DocumentClosed { id } = event {
363                reg.remove_by_document_id(id);
364            }
365        }
366    });
367}
368
369/// K.2.5 (2026-06-02): register `:multibuffer-expand [n]` and
370/// `:multibuffer-contract [n]` ex-commands.
371///
372/// Relocated from `crates/lattice-host/src/multibuffer_keymap.rs::register_multibuffer_ex_commands`
373/// as part of the K.2.5 migration that moves multibuffer keymap +
374/// ex-command registration into its owning crate. Boot path in
375/// `editor_boot.rs` now calls this directly instead of the host
376/// glue. Behaviour preserved verbatim:
377///
378/// Both commands take an optional non-negative integer (default 5
379/// — Zed precedent). `apply` produces
380/// `Effect::AppAction(AppEffect::MultibufferExpand { delta })`
381/// where `delta` is positive for expand, negative for contract.
382/// The host's apply_effect arm calls the substrate helper
383/// `multibuffer_expand_excerpt_at` (M.10.4, 2026-06-03), which
384/// looks up the active view via `MultibufferRegistry` and calls
385/// `expand_excerpt_at` at the active cursor's row.
386///
387/// No-op when invoked on a non-multibuffer active buffer (no
388/// registry entry for the buffer id).
389pub fn register_multibuffer_ex_commands(registry: &mut lattice_grammar::CommandRegistry) {
390    use lattice_grammar::app_effect::AppEffect;
391    use lattice_grammar::args::{ArgSpec, Args};
392    use lattice_grammar::command::LatencyClass;
393    use lattice_grammar::effect::Effect;
394    use lattice_grammar::error::CommandError;
395    use lattice_grammar::registry::{ExCommandSpec, SurfaceForm};
396
397    fn parse_optional_count(s: &str, _bang: bool) -> Result<Args, CommandError> {
398        let trimmed = s.trim();
399        if trimmed.is_empty() {
400            return Ok(Args::None);
401        }
402        match trimmed.parse::<u32>() {
403            // Stash as the decimal string so the apply closure
404            // can re-parse without re-validating; production code
405            // typically passes 1-2 digit counts.
406            Ok(_) => Ok(Args::String(trimmed.to_string())),
407            Err(_) => Err(CommandError::BadArgs(format!(
408                "expected non-negative integer, got `{trimmed}`"
409            ))),
410        }
411    }
412
413    fn count_from_args(args: &Args) -> i32 {
414        match args {
415            Args::String(s) => s.parse::<i32>().unwrap_or(5),
416            _ => 5,
417        }
418    }
419
420    registry.register_ex_command(
421        "multibuffer-expand",
422        "Expand context around the excerpt under the cursor by N rows (default 5).",
423        ExCommandSpec {
424            latency_class: LatencyClass::Reflex,
425            accepts_bang: false,
426            accepts_range: false,
427            parse_args: Arc::new(parse_optional_count),
428            apply: Arc::new(|ctx| {
429                let delta = count_from_args(&ctx.args);
430                Ok(Effect::AppAction(AppEffect::MultibufferExpand { delta }))
431            }),
432            args_schema: Vec::<ArgSpec>::new(),
433            surface_form: SurfaceForm::Keyword,
434        },
435    );
436
437    registry.register_ex_command(
438        "multibuffer-contract",
439        "Contract the excerpt under the cursor by N rows (default 5).",
440        ExCommandSpec {
441            latency_class: LatencyClass::Reflex,
442            accepts_bang: false,
443            accepts_range: false,
444            parse_args: Arc::new(parse_optional_count),
445            apply: Arc::new(|ctx| {
446                let delta = -count_from_args(&ctx.args);
447                Ok(Effect::AppAction(AppEffect::MultibufferExpand { delta }))
448            }),
449            args_schema: Vec::<ArgSpec>::new(),
450            surface_form: SurfaceForm::Keyword,
451        },
452    );
453}