lattice_multibuffer/providers/plugin_view.rs
1//! MV.1b — the **generic provider** behind every plugin-owned multibuffer view.
2//!
3//! Design: [`plugin-multibuffer-views.md`](../../../../../docs/dev/architecture/plugin-multibuffer-views.md).
4//! Slice plan: `slice-plans/plugin-multibuffer-views.md` MV.1.
5//!
6//! ## What is generic here, and what is the guest's
7//!
8//! One `ProviderViewOpener` is registered per view a guest declared, so
9//! `AppEffect::OpenProviderView { provider, args }` and the view's `gr` both
10//! reach it by the guest's own name. This module knows nothing about what the
11//! view is *for*: it calls `build`, resolves each excerpt's path to a source
12//! document, creates or reuses the named buffer, activates the guest's mode,
13//! and writes the guest's summary into the headerline.
14//!
15//! The guest owns the view's identity (`buffer-name`), its contents and their
16//! order, its interactions (`view-mode`), and its status text. Before this,
17//! all four were host constants and only the agenda had them — which is why
18//! `providers/agenda.rs` is ~1000 lines and org's second view had nowhere to
19//! go.
20//!
21//! ## Why the paths are resolved here rather than crossed as buffer ids
22//!
23//! `Excerpt` carries a `BufferId` and only the host can mint one. A guest
24//! naming a path is the same trade `Effect::WriteToFile` makes: a path is the
25//! stable name both sides already share, and the host resolves it.
26//!
27//! **One source document per path**, however many excerpts point into it —
28//! otherwise a file with five rows is opened five times and an edit through one
29//! row is invisible through the others. That is `providers/agenda.rs`'s rule
30//! and the reason is identical.
31
32use std::collections::HashMap;
33use std::path::PathBuf;
34use std::sync::Arc;
35
36use lattice_core::{BufferFlags, BufferId, DocumentBuilder};
37use lattice_grammar::{CommandRegistry, CommandRegistryHandle};
38use lattice_mode::{ModeActivator, ProviderViewOutcome, ServiceRegistry};
39use lattice_runtime::{Document, spawn_document};
40
41use crate::registry::MultibufferRegistryHandle;
42use crate::view::create_multibuffer_view;
43use crate::{Excerpt, ExcerptHeader, ExcerptHeaderStyle, ExcerptId, HeaderlineStatus};
44
45/// One excerpt a guest asked for, already across the boundary.
46///
47/// A plain struct rather than the WIT type: `lattice-multibuffer` does not
48/// depend on the plugin host (and must not — the host depends on *it*), so the
49/// loader converts and hands these over.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct PluginExcerpt {
52 pub path: PathBuf,
53 pub start_line: u32,
54 pub end_line: u32,
55 /// Empty renders no header row — the grouping mechanism.
56 pub header: String,
57 pub match_count: Option<u32>,
58}
59
60/// What a guest's `build` produced.
61#[derive(Debug, Clone, Default)]
62pub struct PluginViewResult {
63 pub excerpts: Vec<PluginExcerpt>,
64 pub summary: String,
65}
66
67/// The identity a guest declared for one of its views.
68#[derive(Debug, Clone)]
69pub struct PluginViewSpec {
70 pub id: String,
71 pub doc: String,
72 pub buffer_name: String,
73 pub view_mode: Option<String>,
74 pub reuse: bool,
75}
76
77/// Create (or reuse) the view's buffer and hand back its id — the SYNCHRONOUS
78/// half.
79///
80/// `ProviderViewOpener` is a sync closure, and `build` is an async guest call
81/// that may read files. Blocking the opener on it would put a plugin's file
82/// reads on the dispatch path, which paramount #1 forbids outright. So this
83/// half seats an empty view with an in-progress headerline and returns
84/// immediately; [`fill_plugin_view`] applies the guest's result when it lands.
85/// That is `providers/agenda.rs`'s shape (`open_agenda` + `spawn_scan_view_scan`)
86/// and it is the same reason.
87pub fn open_plugin_view(
88 activator: &mut dyn ModeActivator,
89 spec: &PluginViewSpec,
90 last_view: Option<BufferId>,
91) -> ProviderViewOutcome {
92 let services = activator.services();
93
94 let Some(registry) = services.get::<CommandRegistryHandle>() else {
95 return ProviderViewOutcome::Declined {
96 message: format!("{}: command registry unavailable", spec.id),
97 };
98 };
99 let lang_registry = services
100 .get::<Arc<lattice_syntax::LangRegistry>>()
101 .map(|h| (*h).clone());
102
103 // SECURITY: reuse only a view THIS provider made.
104 //
105 // Looking a view up by its buffer name would let a guest declare
106 // `buffer-name: "*agenda*"` and take over the agenda's buffer —
107 // `replace_excerpts` on someone else's view, from a plugin that owns
108 // nothing. Buffer names are a flat, unnamespaced space shared with every
109 // native provider, so a guest-chosen one cannot be an authority to reuse.
110 // The id the provider recorded last time is.
111 let existing = spec
112 .reuse
113 .then(|| last_view.filter(|id| still_a_multibuffer(&services, *id)))
114 .flatten();
115
116 let view = match existing {
117 Some(view) => view,
118 None => create_multibuffer_view(
119 activator,
120 HashMap::new(),
121 Vec::new(),
122 Some(spec.buffer_name.clone()),
123 BufferFlags::default(),
124 (*registry).clone(),
125 lang_registry,
126 crate::FoldGrouping::SourceFile,
127 ),
128 };
129
130 let services = activator.services();
131 let Some(mb_registry) = services.get::<MultibufferRegistryHandle>() else {
132 return ProviderViewOutcome::Declined {
133 message: format!("{}: multibuffer registry unavailable", spec.id),
134 };
135 };
136 let Some(handle) = mb_registry.handle(view) else {
137 return ProviderViewOutcome::Declined {
138 message: format!("{}: the view failed to open", spec.id),
139 };
140 };
141
142 // Replace rather than append: a `gr` refresh re-runs `build` and must not
143 // stack a second copy of every row onto the first.
144 handle.replace_excerpts(HashMap::new(), Vec::new());
145 handle.set_headerline(HeaderlineStatus::InProgress {
146 label: format!("Building {}", spec.id),
147 count: None,
148 emphasis: None,
149 });
150
151 // The guest's minor, by name. A name that is not registered warns through
152 // the ordinary activation path rather than failing the open — the rows are
153 // still worth showing.
154 if let Some(mode) = spec.view_mode.as_deref() {
155 activator.activate_minor_by_id(view, lattice_mode::ModeId::new(mode));
156 }
157
158 ProviderViewOutcome::Opened {
159 view,
160 message: None,
161 }
162}
163
164/// Apply a guest's `build` result to an already-seated view — the ASYNCHRONOUS
165/// half, called from the task that awaited the guest.
166///
167/// Publishing [`MultibufferExcerptsReady`] at the end is not optional. An async
168/// result that lands without a wake sits until the user happens to press a key,
169/// and the symptom reads as a rendering bug rather than a missing wake — the
170/// bug class re-introduced repeatedly and designed out by
171/// `boot.wake_on_event::<MultibufferExcerptsReady>()`.
172pub fn fill_plugin_view(
173 mb_registry: &MultibufferRegistryHandle,
174 events: Option<&Arc<lattice_runtime::EventBus>>,
175 view: BufferId,
176 spec: &PluginViewSpec,
177 result: PluginViewResult,
178) {
179 let Some(handle) = mb_registry.handle(view) else {
180 return;
181 };
182
183 let mut sources: HashMap<PathBuf, BufferId> = HashMap::new();
184 let mut excerpts: Vec<Excerpt> = Vec::with_capacity(result.excerpts.len());
185 let mut dropped = 0usize;
186
187 for row in &result.excerpts {
188 // A path that cannot be read is DROPPED with a log, not a failed view —
189 // `error-parser`'s rule, and the same failure class: a stale index must
190 // not cost you every other row it got right.
191 let source = match sources.get(&row.path) {
192 Some(id) => *id,
193 None => {
194 let Ok(text) = std::fs::read_to_string(&row.path) else {
195 tracing::debug!(
196 view = %spec.id,
197 path = %row.path.display(),
198 "plugin view: excerpt source unreadable; dropping the excerpt"
199 );
200 dropped += 1;
201 continue;
202 };
203 let id = BufferId::next();
204 let document = DocumentBuilder::default()
205 .with_text(&text)
206 .with_path(row.path.clone())
207 .build();
208 let doc_registry =
209 Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
210 let doc_handle = spawn_document(id, document, doc_registry);
211 handle.add_source(id, Arc::new(doc_handle) as Arc<dyn Document>);
212 sources.insert(row.path.clone(), id);
213 id
214 }
215 };
216 excerpts.push(Excerpt {
217 id: ExcerptId::next(),
218 source,
219 start_line: row.start_line,
220 end_line: row.end_line,
221 header: ExcerptHeader {
222 title: row.header.clone(),
223 style: ExcerptHeaderStyle::default(),
224 path: Some(row.path.clone()),
225 match_count: row.match_count,
226 },
227 });
228 }
229
230 let count = excerpts.len();
231 handle.append_excerpts(excerpts);
232
233 // The guest's own summary. The host has no vocabulary for "42 backlinks",
234 // which is exactly why `build` returns one.
235 let summary = if dropped == 0 {
236 result.summary.clone()
237 } else {
238 format!("{} ({dropped} unreadable)", result.summary)
239 };
240 handle.set_headerline(HeaderlineStatus::Complete {
241 summary,
242 emphasis: None,
243 });
244 let _ = count;
245
246 if let Some(bus) = events {
247 bus.publish_typed(crate::events::MultibufferExcerptsReady { view });
248 }
249}
250
251/// Report a guest decline on an already-seated view.
252///
253/// The view stays open with the guest's message in its headerline rather than
254/// vanishing: by the time `build` answers, the user is looking at the buffer,
255/// and closing it under them is worse than telling them why it is empty.
256pub fn decline_plugin_view(
257 mb_registry: &MultibufferRegistryHandle,
258 events: Option<&Arc<lattice_runtime::EventBus>>,
259 view: BufferId,
260 message: &str,
261) {
262 let Some(handle) = mb_registry.handle(view) else {
263 return;
264 };
265 handle.set_headerline(HeaderlineStatus::Complete {
266 summary: message.to_string(),
267 emphasis: None,
268 });
269 if let Some(bus) = events {
270 bus.publish_typed(crate::events::MultibufferExcerptsReady { view });
271 }
272}
273
274/// Whether `id` is still a live multibuffer.
275///
276/// `existing_view`'s rule: a view the user closed is not reusable, and its
277/// `BufferId` may since have been handed to something else — appending excerpts
278/// to that would be worse than making a fresh view.
279fn still_a_multibuffer(services: &ServiceRegistry, id: BufferId) -> bool {
280 services
281 .get::<MultibufferRegistryHandle>()
282 .and_then(|registry| registry.handle(id))
283 .is_some()
284}
285
286/// Turn a guest decline into the generic outcome.
287///
288/// Separate function because the message is the GUEST's — the host must not
289/// reword it. `build` returning `err` means "there is nothing to show and here
290/// is why", which is a first-class outcome and not an error path: opening an
291/// empty view and leaving the user to guess is the worse UX.
292pub fn declined(view_id: &str, message: String) -> ProviderViewOutcome {
293 ProviderViewOutcome::Declined {
294 message: format!("{view_id}: {message}"),
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn a_decline_carries_the_guests_own_words() {
304 let outcome = declined("org-roam-backlinks", "nothing links here yet".to_string());
305 match outcome {
306 ProviderViewOutcome::Declined { message } => {
307 assert!(message.contains("nothing links here yet"));
308 assert!(message.starts_with("org-roam-backlinks:"));
309 }
310 other => panic!("expected a decline, got {other:?}"),
311 }
312 }
313}