1use std::sync::Mutex;
34
35use lattice_dashboard::{
36 Align, DashboardCtx, DashboardFragment, DashboardRole, DashboardRow, DashboardSpan, LinkTarget,
37};
38
39use crate::{Component, PluginBudget, PluginHost, PluginHostError, PluginManifest, TrustTier};
40
41pub(crate) mod bindings {
42 wasmtime::component::bindgen!({
43 world: "dashboard-plugin",
44 path: "../lattice-wit/wit",
45 with: {
48 "lattice:plugin-host/logging": crate::lattice::plugin_host::logging,
49 "lattice:plugin-host/project": crate::lattice::plugin_host::project,
50 },
51 });
52}
53
54use bindings::lattice::plugin_host::dashboard as wit;
55
56const MAX_ROWS: usize = 512;
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct DashboardSectionSpec {
68 pub id: String,
69 pub order: i32,
70 pub default_enabled: bool,
71}
72
73pub fn validate_section(
79 id: &str,
80 order: i32,
81 default_enabled: bool,
82) -> Result<DashboardSectionSpec, String> {
83 let id = id.trim();
84 if id.is_empty() {
85 return Err("register-section: id is empty; `dashboard.sections` \
86 addresses sections by id, so an unnamed one cannot be \
87 ordered or disabled"
88 .to_string());
89 }
90 Ok(DashboardSectionSpec {
91 id: id.to_string(),
92 order,
93 default_enabled,
94 })
95}
96
97fn role_from_wit(r: wit::Role) -> DashboardRole {
98 match r {
99 wit::Role::Logo => DashboardRole::Logo,
100 wit::Role::Cursor => DashboardRole::Cursor,
101 wit::Role::Title => DashboardRole::Title,
102 wit::Role::Tagline => DashboardRole::Tagline,
103 wit::Role::SectionHeading => DashboardRole::SectionHeading,
104 wit::Role::Body => DashboardRole::Body,
105 wit::Role::Key => DashboardRole::Key,
106 wit::Role::Hint => DashboardRole::Hint,
107 wit::Role::Link => DashboardRole::Link,
108 }
109}
110
111fn align_from_wit(a: wit::Align) -> Align {
112 match a {
113 wit::Align::Left => Align::Left,
114 wit::Align::Center => Align::Center,
115 }
116}
117
118fn link_from_wit(t: wit::LinkTarget) -> Option<LinkTarget> {
125 let (value, build): (String, fn(String) -> LinkTarget) = match t {
126 wit::LinkTarget::Command(v) => (v, LinkTarget::Command),
127 wit::LinkTarget::Topic(v) => (v, LinkTarget::Topic),
128 wit::LinkTarget::Url(v) => (v, LinkTarget::Url),
129 };
130 let value = value.trim().to_string();
131 if value.is_empty() {
132 return None;
133 }
134 Some(build(value))
135}
136
137pub fn fragment_from_wit(plugin: &str, f: wit::Fragment) -> DashboardFragment {
145 let mut out = DashboardFragment::new();
146 if f.rows.len() > MAX_ROWS {
147 tracing::debug!(
148 plugin,
149 rows = f.rows.len(),
150 cap = MAX_ROWS,
151 "dashboard section returned more rows than the cap; truncating"
152 );
153 }
154 for row in f.rows.into_iter().take(MAX_ROWS) {
155 let spans: Vec<DashboardSpan> = row
159 .spans
160 .into_iter()
161 .map(|s| DashboardSpan {
162 text: s.text,
163 role: role_from_wit(s.role),
164 link: s.link.and_then(link_from_wit),
165 })
166 .collect();
167 out.push(DashboardRow {
168 spans,
169 align: align_from_wit(row.align),
170 });
171 }
172 out
173}
174
175pub struct WasmDashboardSection {
180 inner: Mutex<SectionGuest>,
181 spec: DashboardSectionSpec,
182 plugin: String,
183 plugin_id: u64,
184 budget: PluginBudget,
187}
188
189struct SectionGuest {
190 store: wasmtime::Store<crate::PluginState>,
191 bindings: bindings::DashboardPlugin,
192 poisoned: bool,
196}
197
198impl std::fmt::Debug for WasmDashboardSection {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct("WasmDashboardSection")
201 .field("plugin", &self.plugin)
202 .field("id", &self.spec.id)
203 .finish()
204 }
205}
206
207impl WasmDashboardSection {
208 pub fn plugin_name(&self) -> &str {
210 &self.plugin
211 }
212}
213
214impl lattice_dashboard::DashboardSection for WasmDashboardSection {
215 fn id(&self) -> &str {
216 &self.spec.id
217 }
218
219 fn order(&self) -> i32 {
220 self.spec.order
221 }
222
223 fn default_enabled(&self) -> bool {
224 self.spec.default_enabled
225 }
226
227 fn plugin_id(&self) -> Option<u64> {
228 Some(self.plugin_id)
229 }
230
231 fn render(&self, ctx: &DashboardCtx) -> DashboardFragment {
232 let Ok(mut guest) = self.inner.lock() else {
233 tracing::debug!(
237 plugin = %self.plugin,
238 id = %self.spec.id,
239 "dashboard section mutex poisoned; rendering nothing"
240 );
241 return DashboardFragment::new();
242 };
243 if guest.poisoned {
244 return DashboardFragment::new();
245 }
246 let wit_ctx = wit::Ctx {
247 pane_width: ctx.pane_width.min(u32::MAX as usize) as u32,
248 nerd_fonts: ctx.nerd_fonts,
249 version: ctx.version.clone(),
250 };
251 let SectionGuest {
252 store,
253 bindings,
254 poisoned,
255 } = &mut *guest;
256 if let Err(e) = crate::arm_store(store, self.budget) {
266 *poisoned = true;
267 tracing::warn!(
268 plugin = %self.plugin,
269 id = %self.spec.id,
270 error = %e,
271 "could not re-arm a dashboard section's budget; it will render nothing further"
272 );
273 return DashboardFragment::new();
274 }
275 match bindings.call_render_section(&mut *store, &self.spec.id, &wit_ctx) {
276 Ok(fragment) => fragment_from_wit(&self.plugin, fragment),
277 Err(e) => {
278 *poisoned = true;
279 tracing::warn!(
280 plugin = %self.plugin,
281 id = %self.spec.id,
282 error = %e,
283 "dashboard section trapped; it will render nothing further this session"
284 );
285 DashboardFragment::new()
286 }
287 }
288 }
289}
290
291impl PluginHost {
292 pub fn spawn_dashboard_sections(
301 &self,
302 component: &Component,
303 manifest: &PluginManifest,
304 tier: TrustTier,
305 budget: PluginBudget,
306 ) -> Result<(crate::PluginId, Vec<WasmDashboardSection>), PluginHostError> {
307 let id = self.alloc_id();
308 let mut declarer =
312 self.instantiate_dashboard_guest(component, manifest, tier, budget, id)?;
313 declarer
314 .bindings
315 .call_register_dashboard_sections(&mut declarer.store)
316 .map_err(|source| PluginHostError::Trap {
317 func: "register-dashboard-sections",
318 kind: crate::classify_trap(&source),
319 source: source.into(),
320 })?;
321 let specs = std::mem::take(&mut declarer.store.data_mut().dashboard_contributions);
322 drop(declarer);
323
324 let mut sections = Vec::with_capacity(specs.len());
325 for spec in specs {
326 let guest = self.instantiate_dashboard_guest(component, manifest, tier, budget, id)?;
327 sections.push(WasmDashboardSection {
328 inner: Mutex::new(guest),
329 spec,
330 plugin: manifest.id.clone(),
331 plugin_id: id.0 as u64,
332 budget,
333 });
334 }
335 Ok((id, sections))
336 }
337
338 fn instantiate_dashboard_guest(
345 &self,
346 component: &Component,
347 manifest: &PluginManifest,
348 tier: TrustTier,
349 budget: PluginBudget,
350 id: crate::PluginId,
351 ) -> Result<SectionGuest, PluginHostError> {
352 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
353 for denied in &outcome.denied {
354 tracing::warn!(
355 plugin = %manifest.id,
356 capability = ?denied,
357 "dashboard plugin loaded with a withheld capability (reduced function)"
358 );
359 }
360 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
361 let bindings =
362 bindings::DashboardPlugin::instantiate(&mut store, component, &self.grammar_linker)
363 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
364 store.data_mut().log_ctx = self.log_ctx_for(id);
365 crate::arm_store(&mut store, budget)?;
366 Ok(SectionGuest {
367 store,
368 bindings,
369 poisoned: false,
370 })
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn span(text: &str, link: Option<wit::LinkTarget>) -> wit::Span {
379 wit::Span {
380 text: text.to_string(),
381 role: wit::Role::Body,
382 link,
383 }
384 }
385
386 fn frag(rows: Vec<wit::Row>) -> wit::Fragment {
387 wit::Fragment { rows }
388 }
389
390 #[test]
391 fn a_well_formed_fragment_converts() {
392 let f = fragment_from_wit(
393 "p",
394 frag(vec![wit::Row {
395 spans: vec![
396 span("Open ", None),
397 span(
398 ":tutor",
399 Some(wit::LinkTarget::Command("tutor".to_string())),
400 ),
401 ],
402 align: wit::Align::Center,
403 }]),
404 );
405 assert_eq!(f.rows.len(), 1);
406 assert_eq!(f.rows[0].text(), "Open :tutor");
407 assert_eq!(f.rows[0].align, Align::Center);
408 assert_eq!(
409 f.rows[0].spans[1].link,
410 Some(LinkTarget::Command("tutor".into()))
411 );
412 }
413
414 #[test]
417 fn a_blank_link_target_is_dropped_but_the_label_stays() {
418 let f = fragment_from_wit(
419 "p",
420 frag(vec![wit::Row {
421 spans: vec![span(
422 "dead",
423 Some(wit::LinkTarget::Topic(" ".to_string())),
424 )],
425 align: wit::Align::Left,
426 }]),
427 );
428 assert_eq!(f.rows[0].spans[0].text, "dead");
429 assert!(f.rows[0].spans[0].link.is_none());
430 }
431
432 #[test]
435 fn an_empty_row_is_kept_as_a_spacer() {
436 let f = fragment_from_wit(
437 "p",
438 frag(vec![
439 wit::Row {
440 spans: vec![],
441 align: wit::Align::Left,
442 },
443 wit::Row {
444 spans: vec![span("after", None)],
445 align: wit::Align::Left,
446 },
447 ]),
448 );
449 assert_eq!(f.rows.len(), 2);
450 assert_eq!(f.rows[0].text(), "");
451 assert_eq!(f.rows[1].text(), "after");
452 }
453
454 #[test]
457 fn a_fragment_over_the_row_cap_is_truncated() {
458 let rows = (0..MAX_ROWS + 10)
459 .map(|_| wit::Row {
460 spans: vec![span("x", None)],
461 align: wit::Align::Left,
462 })
463 .collect();
464 let f = fragment_from_wit("p", frag(rows));
465 assert_eq!(f.rows.len(), MAX_ROWS);
466 }
467
468 #[test]
469 fn every_role_maps() {
470 for (w, native) in [
471 (wit::Role::Logo, DashboardRole::Logo),
472 (wit::Role::Cursor, DashboardRole::Cursor),
473 (wit::Role::Title, DashboardRole::Title),
474 (wit::Role::Tagline, DashboardRole::Tagline),
475 (wit::Role::SectionHeading, DashboardRole::SectionHeading),
476 (wit::Role::Body, DashboardRole::Body),
477 (wit::Role::Key, DashboardRole::Key),
478 (wit::Role::Hint, DashboardRole::Hint),
479 (wit::Role::Link, DashboardRole::Link),
480 ] {
481 assert_eq!(role_from_wit(w), native);
482 }
483 }
484
485 #[test]
486 fn a_section_with_no_id_is_rejected() {
487 assert!(validate_section("", 0, true).is_err());
488 assert!(validate_section(" ", 0, true).is_err());
489 assert_eq!(
490 validate_section(" recent ", 5, false).expect("accepted"),
491 DashboardSectionSpec {
492 id: "recent".to_string(),
493 order: 5,
494 default_enabled: false,
495 }
496 );
497 }
498}