lattice_mode/provider_view.rs
1//! The **provider-view seam** — one generic host
2//! primitive for "open the multibuffer view a provider owns" (PV.1, 2026-08-12).
3//!
4//! Design: `docs/dev/architecture/multibuffer-views.md` §3.7a. First
5//! consumer: `lattice-magit`'s project-diff view (PD.3).
6//!
7//! ## The problem it solves
8//!
9//! A multibuffer view can only be created through
10//! [`ModeActivator`](crate::ModeActivator), which is `&mut`-backed and
11//! therefore reachable only from the host. A provider's trigger — an
12//! ex-command or a chord-fired action handler — runs against `&self`
13//! state and returns an [`Effect`](lattice_grammar::effect::Effect). So every provider needs *some*
14//! effect that carries "open my view" back to a place holding the
15//! activator.
16//!
17//! Before this seam each provider spent its own `AppEffect` variant on
18//! that, plus a match arm in the host's dispatcher and a third arm at
19//! the plugin boundary: three crates touched for the N+1th provider,
20//! which contradicts the acid test a provider crate is supposed to pass
21//! (`multibuffer-views.md`: a new provider crate should require zero
22//! host additions).
23//!
24//! This registry replaces the per-provider variant with a single
25//! `AppEffect::OpenProviderView { provider, args }`. Provider crates
26//! register an opener under a name at boot; the host arm looks the name
27//! up, calls the opener with itself as the activator, and applies the
28//! generic outcome (activate + echo). Adding a provider now touches
29//! exactly one crate — the provider's own.
30//!
31//! ## What deliberately does NOT go through it
32//!
33//! `:narrow` / `zn` also produce a multibuffer, and they stay on their
34//! typed `AppEffect::{NarrowTrigger,NarrowLines}` variants. They are not
35//! the same operation: narrowing resolves a *range against live editor
36//! state* — cursor, last-Visual extent, the mark table, and the
37//! composed→source one-hop translation — none of which is a provider
38//! parameter. Routing it here would mean exporting marks and visual
39//! state through [`ModeActivator`], polluting a generic trait with one
40//! consumer's surface, which is the rejection `multibuffer-views.md`
41//! §3.6 already made against `Document::excerpts()`.
42
43use std::collections::HashMap;
44use std::sync::Arc;
45
46use arc_swap::ArcSwap;
47use lattice_core::BufferId;
48use lattice_grammar::Args;
49
50use crate::activator::ModeActivator;
51
52/// What an opener did, in terms the host can apply generically.
53///
54/// The host arm knows only these two outcomes — it never learns what
55/// the provider computed. Both carry their own message so the *provider*
56/// words its own success and refusal (the host has no vocabulary for
57/// "no changed files in the working tree").
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ProviderViewOutcome {
60 /// The view exists. The host activates `view` and echoes `message`
61 /// at info level if one is supplied.
62 Opened {
63 /// The view buffer to make active.
64 view: BufferId,
65 /// Echoed at info level when `Some`.
66 message: Option<String>,
67 },
68 /// Nothing was opened, for a reason the user should see (not a git
69 /// repository, empty result set, a service missing because the
70 /// build dropped a feature). Echoed at warn level.
71 ///
72 /// Declining is a first-class outcome, not an error path: opening
73 /// an empty view and leaving the user to guess why is the worse UX.
74 Declined {
75 /// Why nothing opened, in the provider's words.
76 message: String,
77 },
78}
79
80/// A provider's view-opening closure.
81///
82/// Receives the host as a [`ModeActivator`] (so it can call
83/// `create_multibuffer_view` / `ensure_named_document` and reach every
84/// registered service through `activator.services()`) plus the trigger's
85/// arguments, verbatim from the ex-command or transient row that fired.
86///
87/// `Send + Sync` because the registry is shared behind an `Arc`; the
88/// closure itself always runs on the editor thread, inside the host's
89/// effect application.
90pub type ProviderViewOpener =
91 Arc<dyn Fn(&mut dyn ModeActivator, &Args) -> ProviderViewOutcome + Send + Sync + 'static>;
92
93/// Typed handle for `ServiceRegistry` lookup.
94///
95/// Per the `ServiceRegistry` `TypeId` convention: register and look up
96/// under THIS alias, never the inner type — registering an
97/// `Arc<ProviderViewRegistry>` and asking for `ProviderViewRegistry`
98/// silently returns `None`.
99pub type ProviderViewRegistryHandle = Arc<ProviderViewRegistry>;
100
101/// "re-open the view I own, with these arguments" — asked for
102/// from somewhere that holds no activator and returns no
103/// [`Effect`](lattice_grammar::effect::Effect) (OA.15a).
104///
105/// ## Why an effect was not enough
106///
107/// [`AppEffect::OpenProviderView`](lattice_grammar::app_effect::AppEffect)
108/// already says this, and every trigger that can *return* an effect
109/// should keep using it. What it cannot serve is a producer that is not
110/// running inside a trigger at all: a plugin's `on-event` handler
111/// returns `()` by construction (`wit/types.wit`'s event seam is
112/// observation-shaped), and a background task holds neither the
113/// dispatcher nor the activator.
114///
115/// The first consumer is the one that made the gap visible. A guest
116/// mode's activation is delivered as `minor-activated`, and a guest mode
117/// has no lifecycle body of its own to hang behaviour on — `PluginMode`
118/// is data with a no-op `on_activate` (`mode_host.rs`). So a plugin
119/// whose mode is supposed to CHANGE ITS VIEW could observe the
120/// activation and do nothing about it, which makes such a mode a label
121/// rather than a switch.
122///
123/// ## The `enable-mode` precedent, one step further
124///
125/// `Event::ModeEnablementRequested` is the same shape: the guest cannot
126/// reach the activator, so the call is a REQUEST and the Editor applies
127/// it. This is that pattern for views, with one deliberate difference —
128/// it is a **typed** event, so `BootContext::wake_on_event` covers it
129/// and the re-scan reaches the screen with no keystroke. A request that
130/// only landed on the next keypress would reproduce, exactly, the
131/// "works, but only after I hit something" class this codebase has paid
132/// for repeatedly.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct ProviderViewRefreshRequested {
135 /// The provider name, as registered in [`ProviderViewRegistry`].
136 pub provider: String,
137 /// The view arguments, verbatim. Routed, never read: they are the
138 /// provider's own vocabulary, the same contract `scan_args` carries.
139 pub args: Vec<String>,
140}
141
142lattice_protocol::register_event!(
143 ProviderViewRefreshRequested,
144 "provider-view.refresh-requested",
145 "A provider asked the Editor to re-open one of its views.",
146 "lattice-mode",
147);
148
149/// Name → opener, registered at boot and read once per trigger.
150///
151/// Same wait-free shape as
152/// [`ActionHandlerRegistry`](crate::ActionHandlerRegistry) — copy-on-
153/// write registration, `Arc` load on lookup — because it is the same
154/// kind of thing: a table of provider-contributed closures the host
155/// consults without knowing what is in them.
156///
157/// **Lifetime, amended by MV.1.** Native providers register once during
158/// subsystem `install(&mut boot)` and live for the process, which is why
159/// there is no RAII token. A PLUGIN's views do not: a plugin unloads and
160/// reloads, so [`unregister`](Self::unregister) exists for the teardown
161/// path. Without it a reload's `register` would return `false` against
162/// the plugin's own stale opener and its views would come back dead.
163///
164/// # Examples
165///
166/// A provider registers its opener at boot; the host looks it up when
167/// `AppEffect::OpenProviderView { provider: "todo-view", .. }` is applied and
168/// calls it with itself as the [`ModeActivator`]:
169///
170/// ```
171/// use std::sync::Arc;
172/// use lattice_core::{BufferFlags, BufferId, BufferKind};
173/// use lattice_grammar::Args;
174/// use lattice_mode::{
175/// ModeActivator, ModeId, ProviderViewOpener, ProviderViewOutcome, ProviderViewRegistry,
176/// ServiceRegistry,
177/// };
178///
179/// let opener: ProviderViewOpener = Arc::new(|host: &mut dyn ModeActivator, _args: &Args| {
180/// let view = host.ensure_named_document(
181/// "*todos*",
182/// ModeId::new("todo-view-mode"),
183/// BufferFlags::default(),
184/// );
185/// ProviderViewOutcome::Opened { view, message: Some("3 todos".into()) }
186/// });
187///
188/// let registry = ProviderViewRegistry::new();
189/// assert!(registry.register("todo-view", opener.clone()));
190/// assert!(!registry.register("todo-view", opener)); // first registration keeps the name
191///
192/// /// A stand-in for the host's `Editor`.
193/// struct Host;
194/// impl ModeActivator for Host {
195/// fn activate_major_for_kind(&mut self, _: BufferId, _: BufferKind) {}
196/// fn activate_minor_by_id(&mut self, _: BufferId, _: ModeId) {}
197/// fn ensure_named_document(&mut self, _: &str, _: ModeId, _: BufferFlags) -> BufferId {
198/// BufferId(42)
199/// }
200/// fn services(&self) -> Arc<ServiceRegistry> {
201/// Arc::new(ServiceRegistry::new())
202/// }
203/// }
204///
205/// let open = registry.lookup("todo-view").unwrap();
206/// let outcome = open(&mut Host, &Args::default());
207/// assert_eq!(outcome, ProviderViewOutcome::Opened { view: BufferId(42), message: Some("3 todos".into()) });
208/// ```
209#[derive(Default)]
210pub struct ProviderViewRegistry {
211 openers: ArcSwap<HashMap<String, ProviderViewOpener>>,
212}
213
214impl ProviderViewRegistry {
215 /// An empty registry. The host registers one as a
216 /// [`ProviderViewRegistryHandle`] at boot.
217 pub fn new() -> Self {
218 Self {
219 openers: ArcSwap::from_pointee(HashMap::new()),
220 }
221 }
222
223 /// Register `opener` under `name`.
224 ///
225 /// Returns `false` when `name` was already taken — and does NOT
226 /// replace it. Two providers claiming one name is a boot-wiring bug,
227 /// and last-write-wins would make which view `:foo` opens depend on
228 /// install order; refusing lets the caller log the collision while
229 /// the first registration keeps working.
230 pub fn register(&self, name: impl Into<String>, opener: ProviderViewOpener) -> bool {
231 let name = name.into();
232 let mut inserted = false;
233 self.openers.rcu(|map| {
234 let mut next = (**map).clone();
235 inserted = !next.contains_key(&name);
236 if inserted {
237 next.insert(name.clone(), opener.clone());
238 }
239 next
240 });
241 inserted
242 }
243
244 /// Remove `name`'s opener, returning whether one was there.
245 ///
246 /// MV.1: the plugin teardown path. Native providers never call it —
247 /// they outlive every unload — so an unknown name is `false` rather
248 /// than a warning.
249 pub fn unregister(&self, name: &str) -> bool {
250 let mut removed = false;
251 self.openers.rcu(|map| {
252 let mut next = (**map).clone();
253 removed = next.remove(name).is_some();
254 next
255 });
256 removed
257 }
258
259 /// Look up an opener by name. `None` for an unregistered provider —
260 /// the host then echoes rather than silently doing nothing.
261 pub fn lookup(&self, name: &str) -> Option<ProviderViewOpener> {
262 self.openers.load().get(name).cloned()
263 }
264
265 /// Registered provider names, sorted. For introspection + tests.
266 pub fn names(&self) -> Vec<String> {
267 let mut names: Vec<String> = self.openers.load().keys().cloned().collect();
268 names.sort();
269 names
270 }
271}
272
273impl std::fmt::Debug for ProviderViewRegistry {
274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275 f.debug_struct("ProviderViewRegistry")
276 .field("providers", &self.names())
277 .finish()
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 #![allow(clippy::unwrap_used)]
284 use super::*;
285
286 /// Minimal `ModeActivator` for tests whose openers never call
287 /// through it.
288 struct NullActivator;
289
290 impl ModeActivator for NullActivator {
291 fn activate_major_for_kind(&mut self, _: BufferId, _: lattice_core::BufferKind) {}
292 fn activate_minor_by_id(&mut self, _: BufferId, _: crate::ModeId) {}
293 fn ensure_named_document(
294 &mut self,
295 _: &str,
296 _: crate::ModeId,
297 _: lattice_core::BufferFlags,
298 ) -> BufferId {
299 BufferId(0)
300 }
301 fn services(&self) -> Arc<crate::ServiceRegistry> {
302 Arc::new(crate::ServiceRegistry::default())
303 }
304 }
305
306 fn opener(view: u32) -> ProviderViewOpener {
307 Arc::new(
308 move |_: &mut dyn ModeActivator, _: &Args| ProviderViewOutcome::Opened {
309 view: BufferId(view),
310 message: None,
311 },
312 )
313 }
314
315 #[test]
316 fn an_unregistered_provider_looks_up_to_nothing() {
317 let reg = ProviderViewRegistry::new();
318 assert!(reg.lookup("nobody").is_none());
319 assert!(reg.names().is_empty());
320 }
321
322 #[test]
323 fn a_registered_opener_is_found_by_name() {
324 let reg = ProviderViewRegistry::new();
325 assert!(reg.register("magit-project-diff", opener(7)));
326 assert!(reg.lookup("magit-project-diff").is_some());
327 assert_eq!(reg.names(), vec!["magit-project-diff".to_string()]);
328 }
329
330 /// A name collision is a boot-wiring bug. The FIRST registration
331 /// wins so which view a trigger opens does not depend on the order
332 /// subsystems happen to install in.
333 #[test]
334 fn a_duplicate_name_is_refused_and_the_first_registration_survives() {
335 let reg = ProviderViewRegistry::new();
336 assert!(reg.register("dup", opener(1)));
337 assert!(
338 !reg.register("dup", opener(2)),
339 "the second registration is refused"
340 );
341
342 let found = reg.lookup("dup").unwrap();
343 assert_eq!(
344 found(&mut NullActivator, &Args::None),
345 ProviderViewOutcome::Opened {
346 view: BufferId(1),
347 message: None
348 },
349 "the surviving opener is the one registered first"
350 );
351 }
352
353 /// The args reach the opener verbatim — the trigger's parameters are
354 /// the provider's business, not the host's.
355 #[test]
356 fn args_pass_through_to_the_opener_untouched() {
357 let reg = ProviderViewRegistry::new();
358 reg.register(
359 "echo",
360 Arc::new(|_: &mut dyn ModeActivator, args: &Args| match args {
361 Args::String(s) => ProviderViewOutcome::Declined { message: s.clone() },
362 _ => ProviderViewOutcome::Declined {
363 message: "<none>".into(),
364 },
365 }),
366 );
367 let opener = reg.lookup("echo").unwrap();
368 assert_eq!(
369 opener(&mut NullActivator, &Args::String("staged".into())),
370 ProviderViewOutcome::Declined {
371 message: "staged".into()
372 }
373 );
374 }
375}