Skip to main content

lattice_plugin_manager/
mode.rs

1//! PL8.H.2 — `plugins-mode`: the major mode owning the `*plugins*` manager
2//! buffer.
3//!
4//! The `:plugins` ex-command emits `Effect::OpenSyntheticBuffer { name:
5//! "*plugins*", mode_id: "plugins-mode" }`; the host generically ensures the
6//! buffer under this major mode and activates it, which fires [`on_activate`]
7//! below — the "the owning mode projects the content" pattern
8//! (`OpenSyntheticBuffer` / `OpenPopup`). The mode reads the loader's
9//! `plugin_status()` via the `PluginLoaderHandle` service (resolved at
10//! activation, not install), renders the status table, and writes it into the
11//! buffer OFF the actor thread. It also subscribes to `Event::PluginCrashed` so
12//! a plugin that traps while the view is open flips to `quarantined` live.
13//!
14//! Read-only + no-file (`Mode::options`), so the user can't edit it and `:w`
15//! won't try to save; the owner writes through `apply_edit_batch` (which bypasses
16//! the modal read-only gate by construction).
17//!
18//! [`on_activate`]: PluginManagerMode::on_activate
19
20use std::sync::Arc;
21
22use lattice_mode::BufferStoreHandle;
23use lattice_mode::{
24    ActionHandlerContribution, CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode,
25    ModeContext, ModeId, ModeKind, OptionOverrideSet, Subscription, keymap_entry,
26};
27use lattice_plugin_loader::PluginLoaderHandle;
28use lattice_protocol::{Event, EventKind};
29use lattice_runtime::{Document, EventFilter, SubscriptionTarget};
30
31use crate::actions;
32use crate::render::PLUGINS_MODE_ID;
33
34/// PM.8b: the headerline provider id for the build-progress row.
35pub const BUILD_HEADERLINE_PROVIDER_ID: u64 = 0x706c_7567_6862_0800; // "plug-hb"
36
37/// PM.8b: a sticky row reporting builds in flight.
38///
39/// `version()` is polled by the cells worker on every tick and the trait
40/// forbids blocking there, so it reads the loader's lock-free counter and
41/// bumps a local version only when the count actually changes — the row is
42/// re-rendered on a transition, not on a tick.
43struct BuildHeaderline {
44    loader: PluginLoaderHandle,
45    last_count: std::sync::atomic::AtomicUsize,
46    version: std::sync::atomic::AtomicU64,
47}
48
49impl BuildHeaderline {
50    fn new(loader: PluginLoaderHandle) -> Self {
51        Self {
52            loader,
53            last_count: std::sync::atomic::AtomicUsize::new(0),
54            version: std::sync::atomic::AtomicU64::new(1),
55        }
56    }
57}
58
59impl lattice_cells::Headerline for BuildHeaderline {
60    fn version(&self) -> u64 {
61        use std::sync::atomic::Ordering;
62        let now = self.loader.builds_in_flight();
63        if self.last_count.swap(now, Ordering::Relaxed) != now {
64            self.version.fetch_add(1, Ordering::Release);
65        }
66        self.version.load(Ordering::Acquire)
67    }
68
69    fn render(&self) -> Option<lattice_cells::HeaderlineRow> {
70        let n = self.loader.builds_in_flight();
71        if n == 0 {
72            // Hidden while idle — the row exists only while there is
73            // something to say.
74            return None;
75        }
76        let text = if n == 1 {
77            "building 1 plugin…".to_string()
78        } else {
79            format!("building {n} plugins…")
80        };
81        let cells: Vec<lattice_cells::Cell> = text
82            .chars()
83            .map(|ch| lattice_cells::Cell::with_codepoint(ch as u32))
84            .collect();
85        Some(lattice_cells::HeaderlineRow {
86            cells: cells.into(),
87            bg: None,
88        })
89    }
90}
91
92/// The `*plugins*` buffer's major mode.
93pub struct PluginManagerMode;
94
95impl PluginManagerMode {
96    pub fn mode_id() -> ModeId {
97        ModeId::new(PLUGINS_MODE_ID)
98    }
99}
100
101/// Replace the whole buffer with `text` (a full-range edit). The manager view is
102/// a snapshot, not an append log, so every render overwrites. Runs on the caller's
103/// task; callers spawn it off the actor thread.
104pub(crate) async fn write_all(handle: &Arc<dyn Document>, text: String) {
105    let snap = handle.snapshot();
106    let last_line = snap.buffer.rope_line_count().saturating_sub(1); // CV.3: rope — whole-buffer extent
107    let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
108    let range = lattice_protocol::Range::new(
109        lattice_protocol::Position::new(0, 0),
110        lattice_protocol::Position::new(last_line, last_len),
111    );
112    let edit = lattice_protocol::edit::Edit::replace(range, text);
113    let _ = handle.apply_edit_batch(vec![edit]).await;
114}
115
116/// Render the current status snapshot into the buffer. Returns the rendered text
117/// so a caller can also await the write. `None` if the loader service is absent
118/// (a test harness with no plugin support) — the buffer stays empty, not a panic.
119fn current_status_render(ctx: &ModeContext) -> Option<crate::render::RenderedStatus> {
120    let loader = ctx.service::<PluginLoaderHandle>()?;
121    Some(crate::render::render_status_styled(
122        &loader.plugin_status(),
123        &loader.failed_loads(),
124        None,
125    ))
126}
127
128/// Re-render the manager buffer from the pre-rendered `text`, OFF the actor
129/// thread — the shared refresh the PL8.H.3 action handlers use after a reload /
130/// unload / explicit refresh. A no-op if there's no current runtime or the
131/// buffer is gone (never a panic).
132pub(crate) fn spawn_write(
133    store: &BufferStoreHandle,
134    buffer_id: lattice_core::BufferId,
135    text: String,
136) {
137    let Ok(runtime) = tokio::runtime::Handle::try_current() else {
138        return;
139    };
140    let Some(handle) = store.handle_for(buffer_id) else {
141        return;
142    };
143    runtime.spawn(async move {
144        write_all(&handle, text).await;
145    });
146}
147
148impl Mode for PluginManagerMode {
149    type Guard = Option<Subscription>;
150
151    fn id(&self) -> ModeId {
152        Self::mode_id()
153    }
154
155    fn kind(&self) -> ModeKind {
156        // Content-type identity of the `*plugins*` buffer — a major mode, like
157        // `lsp-log-mode` / `dashboard-mode`.
158        ModeKind::Major
159    }
160
161    fn options(&self) -> OptionOverrideSet {
162        lattice_config::overrides! {
163            lattice_config::ReadOnly = true,
164            lattice_config::NoFile = true,
165        }
166    }
167
168    fn required_capabilities(&self) -> CapabilitySet {
169        CapabilitySet::empty()
170    }
171
172    /// The in-view chords (PL8.H.3). Pushed under `KeymapLayer::MajorMode(
173    /// plugins-mode)` by the host's mode-keymap walk; gated to the `*plugins*`
174    /// buffer. Each `cmd:` resolves to an `action:plugins-*` command registered
175    /// at `install`, and the mode's `action_handlers` below intercept them.
176    fn keymap(&self) -> Keymap {
177        Keymap::from_entries(plugins_keymap_entries())
178    }
179
180    /// RV.2 (2026-08-10): refresh is declared, not bound.
181    ///
182    /// `gr` used to be an entry in this mode's own keymap. It now lives
183    /// once on `refreshable-view-mode`, which the implies cascade
184    /// activates because this returns `Some`; the handler body for
185    /// `action:plugins-refresh` is unchanged. See
186    /// `docs/dev/architecture/mode-architecture.md` §5.5.
187    fn refresh_action(&self) -> Option<&'static str> {
188        Some("action:plugins-refresh")
189    }
190
191    /// The reload / unload / describe / refresh handlers (bodies in
192    /// [`crate::actions`]). Registered globally by the host's
193    /// `register_mode_action_handlers` walk, gated to `plugins-mode`-active
194    /// buffers.
195    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
196        vec![
197            ActionHandlerContribution {
198                action_name: actions::RELOAD,
199                handler: actions::reload_handler(),
200            },
201            ActionHandlerContribution {
202                action_name: actions::UNLOAD,
203                handler: actions::unload_handler(),
204            },
205            ActionHandlerContribution {
206                action_name: actions::DESCRIBE,
207                handler: actions::describe_handler(),
208            },
209            ActionHandlerContribution {
210                action_name: actions::REFRESH,
211                handler: actions::refresh_handler(),
212            },
213            ActionHandlerContribution {
214                action_name: actions::TRACE,
215                handler: actions::trace_handler(),
216            },
217            ActionHandlerContribution {
218                action_name: actions::TRACE_LEVEL,
219                handler: actions::trace_level_handler(),
220            },
221            ActionHandlerContribution {
222                action_name: actions::REBUILD,
223                handler: actions::rebuild_handler(),
224            },
225            ActionHandlerContribution {
226                action_name: actions::UPDATE,
227                handler: actions::update_handler(),
228            },
229            ActionHandlerContribution {
230                action_name: actions::RELOAD_ALL,
231                handler: actions::reload_all_handler(),
232            },
233            ActionHandlerContribution {
234                action_name: actions::REBUILD_ALL,
235                handler: actions::rebuild_all_handler(),
236            },
237            ActionHandlerContribution {
238                action_name: actions::UPDATE_ALL,
239                handler: actions::update_all_handler(),
240            },
241            ActionHandlerContribution {
242                action_name: actions::CLEAN,
243                handler: actions::clean_handler(),
244            },
245            // No chord: reached only through `clean`'s confirmation, which
246            // names it as its `yes_action`.
247            ActionHandlerContribution {
248                action_name: actions::CLEAN_CONFIRMED,
249                handler: actions::clean_confirmed_handler(),
250            },
251        ]
252    }
253
254    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
255        Box::pin(async move {
256            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
257            let Some(store) = ctx.service::<BufferStoreHandle>() else {
258                return Ok(None);
259            };
260            let Some(handle) = store.handle_for(buffer_id) else {
261                return Ok(None);
262            };
263            let Ok(runtime) = tokio::runtime::Handle::try_current() else {
264                return Ok(None);
265            };
266
267            // Initial render: the current status snapshot. Written off the actor
268            // thread (paramount #1 — no document-proportional work on activation's
269            // synchronous path; the render is O(plugins), tiny, but the write goes
270            // through the async edit path regardless).
271            // `PendingSyntheticHighlights`, NOT the `…Handle` alias: boot
272            // registers the bare type and the ServiceRegistry keys on the exact
273            // `T`, so asking for the alias compiles, returns `None`, and leaves
274            // the table permanently unstyled with nothing to show for it. The
275            // which-key hint names the same type for the same reason.
276            let highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
277            if let Some(rendered) = current_status_render(&ctx) {
278                let handle_seed = handle.clone();
279                let ph = highlights.clone();
280                runtime.spawn(async move {
281                    write_all(&handle_seed, rendered.text).await;
282                    // After the text: the spans index by line, so they mean
283                    // nothing until the lines exist.
284                    if let Some(ph) = ph {
285                        ph.store_and_wake(buffer_id, rendered.spans);
286                    }
287                });
288            }
289
290            // PM.8b: a build takes seconds to minutes, so its progress goes in
291            // the buffer's headerline — not a status line the next echo
292            // overwrites (the async-buffer-status-in-headerline rule). The
293            // row hides itself when nothing is building, so the common case
294            // costs a virtual row that is never drawn.
295            if let (Some(registrar), Some(loader)) = (
296                ctx.service::<Arc<dyn lattice_mode::VirtualRowRegistrar>>(),
297                ctx.service::<PluginLoaderHandle>(),
298            ) {
299                let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = (*registrar).clone();
300                let provider = Arc::new(lattice_cells::HeaderlineProvider::new(
301                    BUILD_HEADERLINE_PROVIDER_ID,
302                    Arc::new(BuildHeaderline::new((*loader).clone())),
303                ));
304                registrar.unregister(buffer_id, BUILD_HEADERLINE_PROVIDER_ID);
305                registrar.register(
306                    buffer_id,
307                    provider as Arc<dyn lattice_cells::VirtualRowProvider>,
308                );
309            }
310
311            // Live health: re-render when any plugin crashes while the view is
312            // open. Filtered by kind (indexed dispatch); drained on the runtime
313            // via a `Channel` sink (off the UI/actor thread) — the LSP-log
314            // precedent. The `Subscription` guard unsubscribes on deactivate.
315            let Some(loader) = ctx.service::<PluginLoaderHandle>() else {
316                return Ok(None);
317            };
318
319            // The information rows: how many plugins there are, how many are
320            // in trouble, and which keys act on them. Counts are CACHED in the
321            // provider rather than recomputed in `version()` — that is polled
322            // on every cells tick and must not allocate, and `plugin_status()`
323            // does. Registered as a second provider so the transient
324            // build-progress row above keeps its own lifetime.
325            let info = Arc::new(crate::headerline::InfoHeaderline::new(
326                crate::headerline::counts(&loader.plugin_status(), &loader.failed_loads()),
327            ));
328            if let Some(registrar) = ctx.service::<Arc<dyn lattice_mode::VirtualRowRegistrar>>() {
329                let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = (*registrar).clone();
330                registrar.unregister(buffer_id, crate::headerline::INFO_HEADERLINE_PROVIDER_ID);
331                registrar.register(
332                    buffer_id,
333                    info.clone() as Arc<dyn lattice_cells::VirtualRowProvider>,
334                );
335            }
336
337            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
338            let sub_id = ctx.events().subscribe(
339                EventFilter::kind(EventKind::PluginCrashed),
340                SubscriptionTarget::Channel(tx),
341            );
342            let bus_handle = ctx.events_handle();
343            let refresh_handle = handle.clone();
344            let refresh_info = info;
345            let refresh_highlights = highlights;
346            runtime.spawn(async move {
347                while rx.recv().await.is_some() {
348                    // Coalesce a burst before re-rendering the whole snapshot.
349                    while rx.try_recv().is_ok() {}
350                    let status = loader.plugin_status();
351                    let failed = loader.failed_loads();
352                    // One snapshot feeds all three surfaces, so the header, the
353                    // table and its highlights can never disagree.
354                    refresh_info.set(crate::headerline::counts(&status, &failed));
355                    let rendered = crate::render::render_status_styled(&status, &failed, None);
356                    write_all(&refresh_handle, rendered.text).await;
357                    if let Some(ph) = &refresh_highlights {
358                        ph.store_and_wake(buffer_id, rendered.spans);
359                    }
360                }
361            });
362
363            Ok(Some(Subscription::new(bus_handle, sub_id)))
364        })
365    }
366}
367
368/// The `plugins-mode` in-view chords. `cmd:` literals MUST match the
369/// `crate::actions` command-name consts (the `keymap_entry!` macro requires a
370/// literal, so they can't reference the const directly) — pinned by
371/// `keymap_cmds_have_registered_handlers`.
372/// Test seam: the keymap entries, for the headerline's hint-row
373/// cross-check. A chord added without a hint stops being discoverable, and
374/// nothing else would notice.
375#[cfg(test)]
376pub(crate) fn plugins_keymap_entries_for_test() -> &'static [KeymapEntry] {
377    plugins_keymap_entries()
378}
379
380fn plugins_keymap_entries() -> &'static [KeymapEntry] {
381    use std::sync::OnceLock;
382    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
383    ENTRIES.get_or_init(|| {
384        vec![
385            keymap_entry! {
386                mode: Normal, chord: "r",
387                doc: "plugins: reload the plugin under the cursor",
388                cmd: "action:plugins-reload"
389            },
390            keymap_entry! {
391                mode: Normal, chord: "x",
392                doc: "plugins: unload the plugin under the cursor",
393                cmd: "action:plugins-unload"
394            },
395            keymap_entry! {
396                mode: Normal, chord: "K",
397                doc: "plugins: describe the plugin under the cursor",
398                cmd: "action:plugins-describe"
399            },
400            keymap_entry! {
401                mode: Normal, chord: "<CR>",
402                doc: "plugins: describe the plugin under the cursor",
403                cmd: "action:plugins-describe"
404            },
405            keymap_entry! {
406                mode: Normal, chord: "t",
407                doc: "plugins: open the boundary trace for the plugin under the cursor",
408                cmd: "action:plugins-trace"
409            },
410            keymap_entry! {
411                mode: Normal, chord: "T",
412                doc: "plugins: cycle the trace verbosity of the plugin under the cursor",
413                cmd: "action:plugins-trace-level"
414            },
415            // PM.8b: `b` for build. Distinct from `r` (reload), which
416            // re-instantiates whatever is on disk — `b` rebuilds that from
417            // source first.
418            keymap_entry! {
419                mode: Normal, chord: "b",
420                doc: "plugins: force a fresh build of the plugin under the cursor",
421                cmd: "action:plugins-rebuild"
422            },
423            // `u` for update: fetch something newer, THEN build it. The
424            // difference from `b` is where the source comes from, not what
425            // happens to it.
426            keymap_entry! {
427                mode: Normal, chord: "u",
428                doc: "plugins: update the plugin under the cursor",
429                cmd: "action:plugins-update"
430            },
431            // The all-scope peers. Uppercase is the view's existing idiom for
432            // "the other scope of this verb" — `t` / `T` already read that
433            // way — so `r`/`R`, `b`/`B`, `u`/`U` and `x`/`X` follow it rather
434            // than inventing a second convention inside one buffer.
435            //
436            // These shadow vim's `u` (undo), `R` (Replace) and `U` (undo-line)
437            // in this buffer, which costs nothing: `*plugins*` is a read-only
438            // synthetic buffer, so there is no edit for undo to reverse and no
439            // text for Replace to overwrite. Shadowing in a mode layer is
440            // scoped to `plugins-mode`-active buffers by the per-keystroke
441            // filter, so vim's meanings are untouched everywhere else.
442            keymap_entry! {
443                mode: Normal, chord: "R",
444                doc: "plugins: reload every loaded plugin",
445                cmd: "action:plugins-reload-all"
446            },
447            keymap_entry! {
448                mode: Normal, chord: "B",
449                doc: "plugins: rebuild every loaded plugin from source",
450                cmd: "action:plugins-rebuild-all"
451            },
452            keymap_entry! {
453                mode: Normal, chord: "U",
454                doc: "plugins: update every loaded plugin",
455                cmd: "action:plugins-update-all"
456            },
457            // `x` unloads the row; `X` cleans everything unused. Confirmed
458            // before it runs — the only verb in this view that deletes files.
459            keymap_entry! {
460                mode: Normal, chord: "X",
461                doc: "plugins: remove staged plugin directories nothing loads any more",
462                cmd: "action:plugins-clean"
463            },
464        ]
465    })
466}
467
468/// The wiring between the three halves of a mode-owned chord: the keymap entry
469/// names a command, `register_actions` registers that command, and
470/// `action_handlers` supplies the body that intercepts it.
471///
472/// `actions.rs` has claimed since PL8.H.3 that this is "pinned by
473/// `keymap_cmds_have_registered_handlers`". It was not — no such test existed
474/// anywhere in the workspace — so a chord could name a command nobody
475/// registered, or one with no handler behind it, and the only symptom would be
476/// a key that does nothing while `:describe-key` happily agrees it is bound.
477#[cfg(test)]
478mod wiring_tests {
479    use super::*;
480    use lattice_grammar::CommandRegistry;
481    use lattice_mode::Mode;
482    use std::collections::HashSet;
483
484    fn registered_action_names() -> HashSet<String> {
485        let mut commands = CommandRegistry::new();
486        actions::register_actions(&mut commands);
487        commands.names().map(|n| n.to_string()).collect()
488    }
489
490    fn handler_names() -> HashSet<String> {
491        PluginManagerMode
492            .action_handlers()
493            .into_iter()
494            .map(|c| c.action_name.to_string())
495            .collect()
496    }
497
498    fn keymap_commands() -> Vec<&'static str> {
499        plugins_keymap_entries()
500            .iter()
501            .filter_map(|e| e.command)
502            .collect()
503    }
504
505    #[test]
506    fn every_keymap_cmd_is_a_registered_action() {
507        let registered = registered_action_names();
508        for cmd in keymap_commands() {
509            assert!(
510                registered.contains(cmd),
511                "`{cmd}` is bound to a chord but never registered — the chord \
512                 resolves to nothing and says so nowhere"
513            );
514        }
515    }
516
517    #[test]
518    fn every_keymap_cmd_has_a_handler_body() {
519        let handlers = handler_names();
520        for cmd in keymap_commands() {
521            assert!(
522                handlers.contains(cmd),
523                "`{cmd}` is bound and registered but has no handler — it would \
524                 fall through to the dead body and do nothing"
525            );
526        }
527    }
528
529    /// The confirmation's yes-half is deliberately chordless: it is reached
530    /// only through `Effect::Confirm`, carrying the names the prompt named. It
531    /// still needs to be registered AND handled, which is exactly the pair a
532    /// chordless action is easiest to forget.
533    #[test]
534    fn the_confirmed_clean_half_is_wired_but_unbound() {
535        assert!(registered_action_names().contains(actions::CLEAN_CONFIRMED));
536        assert!(handler_names().contains(actions::CLEAN_CONFIRMED));
537        assert!(
538            !keymap_commands().contains(&actions::CLEAN_CONFIRMED),
539            "the yes-half must not be reachable by a keystroke: pressing it \
540             directly would delete without asking"
541        );
542    }
543
544    /// The view's scope idiom: lowercase acts on the row, uppercase on every
545    /// row. `t`/`T` already read that way; a gap in the set is the silent kind
546    /// of bug — nobody notices the one chord that was never added.
547    #[test]
548    fn every_row_verb_with_an_all_scope_peer_has_both_chords() {
549        let bound: HashSet<&str> = plugins_keymap_entries().iter().map(|e| e.chord).collect();
550        for (row, all) in [("r", "R"), ("b", "B"), ("u", "U"), ("x", "X")] {
551            assert!(bound.contains(row), "the row-scope chord `{row}` is bound");
552            assert!(
553                bound.contains(all),
554                "`{row}` has no all-scope peer `{all}` — the view teaches an \
555                 idiom and then breaks it"
556            );
557        }
558    }
559}