Skip to main content

lattice_dashboard/
install.rs

1//! Wire the dashboard subsystem into the editor at boot (DB.2, DB.5, DB.6).
2//! Called from the host's Phase-B install list (`editor_boot.rs`) — the
3//! *only* host touch-point the mode-ownership acid test allows.
4//!
5//! `install` registers five things, all owned by this crate:
6//! - `dashboard-mode` (the major mode) into the mode registry;
7//! - the `:dashboard` ex-command, whose `apply` returns the lifecycle
8//!   `Effect::OpenDashboard` the host applies (buffer creation mutates
9//!   `&mut Editor`, so the *applier* is the sanctioned host boundary — same
10//!   shape as `:messages`/`:diff`);
11//! - the built-in `DashboardRegistry` as a service, so the host's
12//!   `do_open_dashboard` can compose the page;
13//! - (DB.5) the mode-owned startup trigger: a one-shot subscription to
14//!   `lattice_mode::Startup` that emits the *same* `Effect::OpenDashboard`
15//!   when the editor booted with no file argument and `dashboard.enabled`;
16//! - (DB.6) the mode-owned recompose trigger: a long-lived subscription to
17//!   `Event::OptionChanged` that re-emits `Effect::OpenDashboard` — this
18//!   time gated on the dashboard already being open — whenever
19//!   `dashboard.sections`, `dashboard.source`, or `ui.nerd_fonts` changes.
20
21use std::sync::Arc;
22
23use lattice_config::ConfigRegistry;
24use lattice_grammar::{
25    Args, CommandError, CommandRegistry, Effect, ExCommandSpec, GrammarResult, LatencyClass,
26    SurfaceForm,
27};
28use lattice_mode::{BufferStoreHandle, Startup, SubsystemBoot};
29use lattice_protocol::event::{Event, EventKind};
30use lattice_runtime::{EventFilter, SubscriptionTarget};
31
32use crate::mode::register_dashboard_modes;
33use crate::options::DashboardEnabled;
34use crate::registry::DashboardRegistryHandle;
35use crate::sections::builtin_registry;
36
37/// The `*dashboard*` synthetic buffer's registered name — the same literal
38/// the host's `do_open_dashboard` / `BufferRegistry::by_name` use. Kept as
39/// one constant here since both triggers below need it.
40const DASHBOARD_BUFFER_NAME: &str = "*dashboard*";
41
42/// DB.5: the mode-internal signal `install_startup_trigger`'s spawned task
43/// sends through the generic `inbound` primitive once it decides the
44/// dashboard should auto-open. Carries no payload — the handler always maps
45/// it to `Effect::OpenDashboard`, mirroring `:dashboard`'s own applier.
46struct DashboardStartupTrigger;
47
48/// DB.6: the signal `install_recompose_triggers`'s spawned task sends when a
49/// composition-affecting option changes AND the dashboard is already open.
50/// Maps to the same `Effect::OpenDashboard` — its host applier
51/// (`do_open_dashboard`) already recomposes-in-place for an existing buffer,
52/// so no new host applier is needed, only a new (gated) trigger.
53struct DashboardRecomposeTrigger;
54
55/// Reject any trailing characters after `:dashboard` (it takes no args).
56fn parse_no_args(rest: &str, _bang: bool) -> GrammarResult<Args> {
57    if rest.trim().is_empty() {
58        Ok(Args::None)
59    } else {
60        Err(CommandError::BadArgs(
61            "trailing characters after :dashboard".into(),
62        ))
63    }
64}
65
66/// Install the dashboard subsystem: mode + `:dashboard` command + the
67/// built-in section registry service.
68pub fn install(boot: &mut impl SubsystemBoot) {
69    register_dashboard_modes(boot.modes_mut());
70    register_dashboard_commands(boot.commands_mut());
71    // The section registry, read by `do_open_dashboard` at compose time.
72    //
73    // CR.2: registered as the runtime-mutable `DashboardRegistryHandle`
74    // (looked up under that exact alias, per the ServiceRegistry
75    // Arc/TypeId rule) rather than the bare value — the builtin set is the
76    // registry's *initial* contents now, and a `dashboard` plugin RCUs its
77    // own sections in. This `install` runs early in Phase B, well ahead of
78    // `lattice_plugin_loader::install`, which is what makes that drain
79    // possible; a handle registered after the loader would be a silent
80    // no-contribution.
81    boot.register_service::<DashboardRegistryHandle>(builtin_registry().into_handle());
82    install_startup_trigger(boot);
83    install_recompose_triggers(boot);
84}
85
86/// DB.5 (design.md §9.1): subscribe to `Startup` and, if the editor booted
87/// with no file argument and `dashboard.enabled`, emit the same
88/// `Effect::OpenDashboard` the `:dashboard` command emits — one applier, two
89/// triggers. The activation-decision (no file + enabled) lives here, not in
90/// the host.
91///
92/// The subscription is registered SYNCHRONOUSLY (`event_bus().subscribe_typed`
93/// runs before `runtime.spawn`, not inside the spawned future) — `Startup`
94/// publishes almost immediately after this `install` call returns (the
95/// renderer's post-boot seam runs right after `Editor::boot`), so a
96/// subscription registered lazily on the task's first poll could lose the
97/// race and never see it. Mirrors `lattice_lsp::modeline::spawn_modeline_forwarder`'s
98/// shape (`bus.subscribe_typed(tx)` before `runtime.spawn`).
99///
100/// Reads `dashboard.enabled` via `boot.service::<Arc<ConfigRegistry>>()` —
101/// available because the host now registers it as a Phase-A service (the
102/// DB.5 `ConfigRegistry` hoist in `editor_boot.rs`), before this Phase-B
103/// `install` runs.
104fn install_startup_trigger(boot: &mut impl SubsystemBoot) {
105    let config = boot.service::<Arc<ConfigRegistry>>();
106    let trigger_bus = boot.inbound(|_: DashboardStartupTrigger| vec![Effect::OpenDashboard]);
107    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Startup>();
108    boot.event_bus().subscribe_typed(tx);
109    boot.runtime_handle().spawn(async move {
110        let Some(Startup { opened_file }) = rx.recv().await else {
111            // Bus dropped before boot published `Startup` — unreachable in
112            // practice (the bus outlives the editor), but a closed channel
113            // ends the task cleanly rather than panicking.
114            return;
115        };
116        let enabled = config
117            .and_then(|c| c.get_typed::<DashboardEnabled>())
118            .map(|v| *v)
119            .unwrap_or(true);
120        if opened_file.is_none() && enabled {
121            let _ = trigger_bus.send(DashboardStartupTrigger);
122        }
123    });
124}
125
126/// DB.6 (design.md §7): recompose the dashboard, in place, whenever one of
127/// the options its composition depends on changes: `dashboard.sections`,
128/// `dashboard.source` (§8's full-override escape hatch), or `ui.nerd_fonts`
129/// (icon-palette choice — a `lattice-host`-owned option; matched by string
130/// name here, the same way `dashboard.enabled`'s startup gate stays free of
131/// naming its own crate's option *type* across the trigger boundary, so this
132/// crate never needs a `lattice-host` dependency).
133///
134/// Gated on `boot.buffer_store().find_by_name(DASHBOARD_BUFFER_NAME)` —
135/// unlike the startup trigger, this must NEVER create the buffer. Editing
136/// `dashboard.sections` while the dashboard isn't the active buffer (or
137/// isn't open at all) must stay silent; only an already-open dashboard
138/// refreshes.
139///
140/// Pane resize is deliberately NOT one of the matched names: `content_left_pad`
141/// (DB.4's gutter-based centring) is recomputed from the live viewport width
142/// on every `rebuild_option_cache` call, which `Editor::set_pane_viewport`
143/// already invokes on every resize (`lattice-host/src/editor_actor.rs`) — a
144/// full recompose would just redundantly rebuild the same fragments for a
145/// value the renderer already derives fresh, with no visible difference.
146///
147/// Subscribes via the legacy `Event::OptionChanged` bus (not the typed-event
148/// path DB.5 uses for `Startup`) — `OptionChanged` is a pre-existing
149/// `lattice_protocol::Event` variant, the same one `ConfigRegistry` has
150/// always published on every `:set`. Long-lived (loops for the editor's
151/// lifetime), unlike the startup trigger's single `.recv().await`.
152fn install_recompose_triggers(boot: &mut impl SubsystemBoot) {
153    let buffer_store: BufferStoreHandle = boot.buffer_store().clone();
154    let recompose_bus = boot.inbound(|_: DashboardRecomposeTrigger| vec![Effect::OpenDashboard]);
155    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
156    boot.event_bus().subscribe(
157        EventFilter::kind(EventKind::OptionChanged),
158        SubscriptionTarget::Channel(tx),
159    );
160    boot.runtime_handle().spawn(async move {
161        while let Some(event) = rx.recv().await {
162            let Event::OptionChanged { name, .. } = event else {
163                continue;
164            };
165            if !matches!(
166                name.as_str(),
167                "dashboard.sections" | "dashboard.source" | "ui.nerd_fonts"
168            ) {
169                continue;
170            }
171            if buffer_store.find_by_name(DASHBOARD_BUFFER_NAME).is_some() {
172                let _ = recompose_bus.send(DashboardRecomposeTrigger);
173            }
174        }
175    });
176}
177
178fn register_dashboard_commands(registry: &mut CommandRegistry) {
179    registry.register_ex_command(
180        "dashboard",
181        "Open the *dashboard* launch page (`:dashboard`).",
182        ExCommandSpec {
183            latency_class: LatencyClass::Display,
184            accepts_bang: false,
185            accepts_range: false,
186            parse_args: Arc::new(parse_no_args),
187            apply: Arc::new(|_| Ok(Effect::OpenDashboard)),
188            args_schema: vec![],
189            surface_form: SurfaceForm::Keyword,
190        },
191    );
192}