1use std::sync::Arc;
21
22use lattice_mode::BufferStoreHandle;
23use lattice_mode::{
24 ActionHandlerContribution, CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode,
25 ModeContext, ModeId, ModeKind, OptionOverrideSet, Subscription, keymap_entry,
26};
27use lattice_plugin_loader::PluginLoaderHandle;
28use lattice_protocol::{Event, EventKind};
29use lattice_runtime::{Document, EventFilter, SubscriptionTarget};
30
31use crate::actions;
32use crate::render::PLUGINS_MODE_ID;
33
34pub const BUILD_HEADERLINE_PROVIDER_ID: u64 = 0x706c_7567_6862_0800; struct BuildHeaderline {
44 loader: PluginLoaderHandle,
45 last_count: std::sync::atomic::AtomicUsize,
46 version: std::sync::atomic::AtomicU64,
47}
48
49impl BuildHeaderline {
50 fn new(loader: PluginLoaderHandle) -> Self {
51 Self {
52 loader,
53 last_count: std::sync::atomic::AtomicUsize::new(0),
54 version: std::sync::atomic::AtomicU64::new(1),
55 }
56 }
57}
58
59impl lattice_cells::Headerline for BuildHeaderline {
60 fn version(&self) -> u64 {
61 use std::sync::atomic::Ordering;
62 let now = self.loader.builds_in_flight();
63 if self.last_count.swap(now, Ordering::Relaxed) != now {
64 self.version.fetch_add(1, Ordering::Release);
65 }
66 self.version.load(Ordering::Acquire)
67 }
68
69 fn render(&self) -> Option<lattice_cells::HeaderlineRow> {
70 let n = self.loader.builds_in_flight();
71 if n == 0 {
72 return None;
75 }
76 let text = if n == 1 {
77 "building 1 plugin…".to_string()
78 } else {
79 format!("building {n} plugins…")
80 };
81 let cells: Vec<lattice_cells::Cell> = text
82 .chars()
83 .map(|ch| lattice_cells::Cell::with_codepoint(ch as u32))
84 .collect();
85 Some(lattice_cells::HeaderlineRow {
86 cells: cells.into(),
87 bg: None,
88 })
89 }
90}
91
92pub struct PluginManagerMode;
94
95impl PluginManagerMode {
96 pub fn mode_id() -> ModeId {
97 ModeId::new(PLUGINS_MODE_ID)
98 }
99}
100
101pub(crate) async fn write_all(handle: &Arc<dyn Document>, text: String) {
105 let snap = handle.snapshot();
106 let last_line = snap.buffer.rope_line_count().saturating_sub(1); let last_len = snap.buffer.line(last_line).unwrap_or_default().len() as u32;
108 let range = lattice_protocol::Range::new(
109 lattice_protocol::Position::new(0, 0),
110 lattice_protocol::Position::new(last_line, last_len),
111 );
112 let edit = lattice_protocol::edit::Edit::replace(range, text);
113 let _ = handle.apply_edit_batch(vec![edit]).await;
114}
115
116fn current_status_render(ctx: &ModeContext) -> Option<crate::render::RenderedStatus> {
120 let loader = ctx.service::<PluginLoaderHandle>()?;
121 Some(crate::render::render_status_styled(
122 &loader.plugin_status(),
123 &loader.failed_loads(),
124 None,
125 ))
126}
127
128pub(crate) fn spawn_write(
133 store: &BufferStoreHandle,
134 buffer_id: lattice_core::BufferId,
135 text: String,
136) {
137 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
138 return;
139 };
140 let Some(handle) = store.handle_for(buffer_id) else {
141 return;
142 };
143 runtime.spawn(async move {
144 write_all(&handle, text).await;
145 });
146}
147
148impl Mode for PluginManagerMode {
149 type Guard = Option<Subscription>;
150
151 fn id(&self) -> ModeId {
152 Self::mode_id()
153 }
154
155 fn kind(&self) -> ModeKind {
156 ModeKind::Major
159 }
160
161 fn options(&self) -> OptionOverrideSet {
162 lattice_config::overrides! {
163 lattice_config::ReadOnly = true,
164 lattice_config::NoFile = true,
165 }
166 }
167
168 fn required_capabilities(&self) -> CapabilitySet {
169 CapabilitySet::empty()
170 }
171
172 fn keymap(&self) -> Keymap {
177 Keymap::from_entries(plugins_keymap_entries())
178 }
179
180 fn refresh_action(&self) -> Option<&'static str> {
188 Some("action:plugins-refresh")
189 }
190
191 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
196 vec![
197 ActionHandlerContribution {
198 action_name: actions::RELOAD,
199 handler: actions::reload_handler(),
200 },
201 ActionHandlerContribution {
202 action_name: actions::UNLOAD,
203 handler: actions::unload_handler(),
204 },
205 ActionHandlerContribution {
206 action_name: actions::DESCRIBE,
207 handler: actions::describe_handler(),
208 },
209 ActionHandlerContribution {
210 action_name: actions::REFRESH,
211 handler: actions::refresh_handler(),
212 },
213 ActionHandlerContribution {
214 action_name: actions::TRACE,
215 handler: actions::trace_handler(),
216 },
217 ActionHandlerContribution {
218 action_name: actions::TRACE_LEVEL,
219 handler: actions::trace_level_handler(),
220 },
221 ActionHandlerContribution {
222 action_name: actions::REBUILD,
223 handler: actions::rebuild_handler(),
224 },
225 ActionHandlerContribution {
226 action_name: actions::UPDATE,
227 handler: actions::update_handler(),
228 },
229 ActionHandlerContribution {
230 action_name: actions::RELOAD_ALL,
231 handler: actions::reload_all_handler(),
232 },
233 ActionHandlerContribution {
234 action_name: actions::REBUILD_ALL,
235 handler: actions::rebuild_all_handler(),
236 },
237 ActionHandlerContribution {
238 action_name: actions::UPDATE_ALL,
239 handler: actions::update_all_handler(),
240 },
241 ActionHandlerContribution {
242 action_name: actions::CLEAN,
243 handler: actions::clean_handler(),
244 },
245 ActionHandlerContribution {
248 action_name: actions::CLEAN_CONFIRMED,
249 handler: actions::clean_confirmed_handler(),
250 },
251 ]
252 }
253
254 fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
255 Box::pin(async move {
256 let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
257 let Some(store) = ctx.service::<BufferStoreHandle>() else {
258 return Ok(None);
259 };
260 let Some(handle) = store.handle_for(buffer_id) else {
261 return Ok(None);
262 };
263 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
264 return Ok(None);
265 };
266
267 let highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
277 if let Some(rendered) = current_status_render(&ctx) {
278 let handle_seed = handle.clone();
279 let ph = highlights.clone();
280 runtime.spawn(async move {
281 write_all(&handle_seed, rendered.text).await;
282 if let Some(ph) = ph {
285 ph.store_and_wake(buffer_id, rendered.spans);
286 }
287 });
288 }
289
290 if let (Some(registrar), Some(loader)) = (
296 ctx.service::<Arc<dyn lattice_mode::VirtualRowRegistrar>>(),
297 ctx.service::<PluginLoaderHandle>(),
298 ) {
299 let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = (*registrar).clone();
300 let provider = Arc::new(lattice_cells::HeaderlineProvider::new(
301 BUILD_HEADERLINE_PROVIDER_ID,
302 Arc::new(BuildHeaderline::new((*loader).clone())),
303 ));
304 registrar.unregister(buffer_id, BUILD_HEADERLINE_PROVIDER_ID);
305 registrar.register(
306 buffer_id,
307 provider as Arc<dyn lattice_cells::VirtualRowProvider>,
308 );
309 }
310
311 let Some(loader) = ctx.service::<PluginLoaderHandle>() else {
316 return Ok(None);
317 };
318
319 let info = Arc::new(crate::headerline::InfoHeaderline::new(
326 crate::headerline::counts(&loader.plugin_status(), &loader.failed_loads()),
327 ));
328 if let Some(registrar) = ctx.service::<Arc<dyn lattice_mode::VirtualRowRegistrar>>() {
329 let registrar: Arc<dyn lattice_mode::VirtualRowRegistrar> = (*registrar).clone();
330 registrar.unregister(buffer_id, crate::headerline::INFO_HEADERLINE_PROVIDER_ID);
331 registrar.register(
332 buffer_id,
333 info.clone() as Arc<dyn lattice_cells::VirtualRowProvider>,
334 );
335 }
336
337 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
338 let sub_id = ctx.events().subscribe(
339 EventFilter::kind(EventKind::PluginCrashed),
340 SubscriptionTarget::Channel(tx),
341 );
342 let bus_handle = ctx.events_handle();
343 let refresh_handle = handle.clone();
344 let refresh_info = info;
345 let refresh_highlights = highlights;
346 runtime.spawn(async move {
347 while rx.recv().await.is_some() {
348 while rx.try_recv().is_ok() {}
350 let status = loader.plugin_status();
351 let failed = loader.failed_loads();
352 refresh_info.set(crate::headerline::counts(&status, &failed));
355 let rendered = crate::render::render_status_styled(&status, &failed, None);
356 write_all(&refresh_handle, rendered.text).await;
357 if let Some(ph) = &refresh_highlights {
358 ph.store_and_wake(buffer_id, rendered.spans);
359 }
360 }
361 });
362
363 Ok(Some(Subscription::new(bus_handle, sub_id)))
364 })
365 }
366}
367
368#[cfg(test)]
376pub(crate) fn plugins_keymap_entries_for_test() -> &'static [KeymapEntry] {
377 plugins_keymap_entries()
378}
379
380fn plugins_keymap_entries() -> &'static [KeymapEntry] {
381 use std::sync::OnceLock;
382 static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
383 ENTRIES.get_or_init(|| {
384 vec![
385 keymap_entry! {
386 mode: Normal, chord: "r",
387 doc: "plugins: reload the plugin under the cursor",
388 cmd: "action:plugins-reload"
389 },
390 keymap_entry! {
391 mode: Normal, chord: "x",
392 doc: "plugins: unload the plugin under the cursor",
393 cmd: "action:plugins-unload"
394 },
395 keymap_entry! {
396 mode: Normal, chord: "K",
397 doc: "plugins: describe the plugin under the cursor",
398 cmd: "action:plugins-describe"
399 },
400 keymap_entry! {
401 mode: Normal, chord: "<CR>",
402 doc: "plugins: describe the plugin under the cursor",
403 cmd: "action:plugins-describe"
404 },
405 keymap_entry! {
406 mode: Normal, chord: "t",
407 doc: "plugins: open the boundary trace for the plugin under the cursor",
408 cmd: "action:plugins-trace"
409 },
410 keymap_entry! {
411 mode: Normal, chord: "T",
412 doc: "plugins: cycle the trace verbosity of the plugin under the cursor",
413 cmd: "action:plugins-trace-level"
414 },
415 keymap_entry! {
419 mode: Normal, chord: "b",
420 doc: "plugins: force a fresh build of the plugin under the cursor",
421 cmd: "action:plugins-rebuild"
422 },
423 keymap_entry! {
427 mode: Normal, chord: "u",
428 doc: "plugins: update the plugin under the cursor",
429 cmd: "action:plugins-update"
430 },
431 keymap_entry! {
443 mode: Normal, chord: "R",
444 doc: "plugins: reload every loaded plugin",
445 cmd: "action:plugins-reload-all"
446 },
447 keymap_entry! {
448 mode: Normal, chord: "B",
449 doc: "plugins: rebuild every loaded plugin from source",
450 cmd: "action:plugins-rebuild-all"
451 },
452 keymap_entry! {
453 mode: Normal, chord: "U",
454 doc: "plugins: update every loaded plugin",
455 cmd: "action:plugins-update-all"
456 },
457 keymap_entry! {
460 mode: Normal, chord: "X",
461 doc: "plugins: remove staged plugin directories nothing loads any more",
462 cmd: "action:plugins-clean"
463 },
464 ]
465 })
466}
467
468#[cfg(test)]
478mod wiring_tests {
479 use super::*;
480 use lattice_grammar::CommandRegistry;
481 use lattice_mode::Mode;
482 use std::collections::HashSet;
483
484 fn registered_action_names() -> HashSet<String> {
485 let mut commands = CommandRegistry::new();
486 actions::register_actions(&mut commands);
487 commands.names().map(|n| n.to_string()).collect()
488 }
489
490 fn handler_names() -> HashSet<String> {
491 PluginManagerMode
492 .action_handlers()
493 .into_iter()
494 .map(|c| c.action_name.to_string())
495 .collect()
496 }
497
498 fn keymap_commands() -> Vec<&'static str> {
499 plugins_keymap_entries()
500 .iter()
501 .filter_map(|e| e.command)
502 .collect()
503 }
504
505 #[test]
506 fn every_keymap_cmd_is_a_registered_action() {
507 let registered = registered_action_names();
508 for cmd in keymap_commands() {
509 assert!(
510 registered.contains(cmd),
511 "`{cmd}` is bound to a chord but never registered — the chord \
512 resolves to nothing and says so nowhere"
513 );
514 }
515 }
516
517 #[test]
518 fn every_keymap_cmd_has_a_handler_body() {
519 let handlers = handler_names();
520 for cmd in keymap_commands() {
521 assert!(
522 handlers.contains(cmd),
523 "`{cmd}` is bound and registered but has no handler — it would \
524 fall through to the dead body and do nothing"
525 );
526 }
527 }
528
529 #[test]
534 fn the_confirmed_clean_half_is_wired_but_unbound() {
535 assert!(registered_action_names().contains(actions::CLEAN_CONFIRMED));
536 assert!(handler_names().contains(actions::CLEAN_CONFIRMED));
537 assert!(
538 !keymap_commands().contains(&actions::CLEAN_CONFIRMED),
539 "the yes-half must not be reachable by a keystroke: pressing it \
540 directly would delete without asking"
541 );
542 }
543
544 #[test]
548 fn every_row_verb_with_an_all_scope_peer_has_both_chords() {
549 let bound: HashSet<&str> = plugins_keymap_entries().iter().map(|e| e.chord).collect();
550 for (row, all) in [("r", "R"), ("b", "B"), ("u", "U"), ("x", "X")] {
551 assert!(bound.contains(row), "the row-scope chord `{row}` is bound");
552 assert!(
553 bound.contains(all),
554 "`{row}` has no all-scope peer `{all}` — the view teaches an \
555 idiom and then breaks it"
556 );
557 }
558 }
559}