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}