Skip to main content

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}