Skip to main content

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}