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}