systemctl_tui/components/
home.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use futures::Future;
3use indexmap::IndexMap;
4use itertools::Itertools;
5use ratatui::{
6  layout::{Constraint, Direction, Layout, Rect},
7  style::{Color, Modifier, Style},
8  text::{Line, Span},
9  widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap},
10};
11use tokio::{
12  io::AsyncBufReadExt,
13  sync::mpsc::{self, UnboundedSender},
14  task::JoinHandle,
15};
16use tokio_util::sync::CancellationToken;
17use tracing::{error, info, warn};
18use tui_input::{backend::crossterm::EventHandler, Input};
19
20use std::{
21  process::{Command, Stdio},
22  time::Duration,
23};
24
25use super::{logger::Logger, Component, Frame};
26use crate::{
27  action::Action,
28  systemd::{self, Scope, UnitId, UnitScope, UnitWithStatus},
29};
30
31#[derive(Debug, Default, Copy, Clone, PartialEq)]
32pub enum Mode {
33  #[default]
34  Search,
35  ServiceList,
36  Help,
37  ActionMenu,
38  Processing,
39  Error,
40}
41
42#[derive(Default)]
43pub struct Home {
44  pub scope: Scope,
45  pub limit_units: Vec<String>,
46  pub logger: Logger,
47  pub show_logger: bool,
48  pub all_units: IndexMap<UnitId, UnitWithStatus>,
49  pub filtered_units: StatefulList<UnitWithStatus>,
50  pub logs: Vec<String>,
51  pub logs_scroll_offset: u16,
52  pub mode: Mode,
53  pub previous_mode: Option<Mode>,
54  pub input: Input,
55  pub menu_items: StatefulList<MenuItem>,
56  pub cancel_token: Option<CancellationToken>,
57  pub spinner_tick: u8,
58  pub error_message: String,
59  pub action_tx: Option<mpsc::UnboundedSender<Action>>,
60  pub journalctl_tx: Option<std::sync::mpsc::Sender<UnitId>>,
61}
62
63pub struct MenuItem {
64  pub name: String,
65  pub action: Action,
66  pub key: Option<KeyCode>,
67}
68
69impl MenuItem {
70  pub fn new(name: &str, action: Action, key: Option<KeyCode>) -> Self {
71    Self { name: name.to_owned(), action, key }
72  }
73
74  pub fn key_string(&self) -> String {
75    if let Some(key) = self.key {
76      format!("{}", key)
77    } else {
78      String::new()
79    }
80  }
81}
82
83pub struct StatefulList<T> {
84  state: ListState,
85  items: Vec<T>,
86}
87
88impl<T> Default for StatefulList<T> {
89  fn default() -> Self {
90    Self::with_items(vec![])
91  }
92}
93
94impl<T> StatefulList<T> {
95  pub fn with_items(items: Vec<T>) -> StatefulList<T> {
96    StatefulList { state: ListState::default(), items }
97  }
98
99  #[allow(dead_code)]
100  fn selected_mut(&mut self) -> Option<&mut T> {
101    if self.items.is_empty() {
102      return None;
103    }
104    match self.state.selected() {
105      Some(i) => Some(&mut self.items[i]),
106      None => None,
107    }
108  }
109
110  fn selected(&self) -> Option<&T> {
111    if self.items.is_empty() {
112      return None;
113    }
114    match self.state.selected() {
115      Some(i) => Some(&self.items[i]),
116      None => None,
117    }
118  }
119
120  fn next(&mut self) {
121    let i = match self.state.selected() {
122      Some(i) => {
123        if i >= self.items.len().saturating_sub(1) {
124          0
125        } else {
126          i + 1
127        }
128      },
129      None => 0,
130    };
131    self.state.select(Some(i));
132  }
133
134  fn previous(&mut self) {
135    let i = match self.state.selected() {
136      Some(i) => {
137        if i == 0 {
138          self.items.len() - 1
139        } else {
140          i - 1
141        }
142      },
143      None => 0,
144    };
145    self.state.select(Some(i));
146  }
147
148  fn select(&mut self, index: Option<usize>) {
149    self.state.select(index);
150  }
151
152  fn unselect(&mut self) {
153    self.state.select(None);
154  }
155}
156
157impl Home {
158  pub fn new(scope: Scope, limit_units: &[String]) -> Self {
159    let limit_units = limit_units.to_vec();
160    Self { scope, limit_units, ..Default::default() }
161  }
162
163  pub fn set_units(&mut self, units: Vec<UnitWithStatus>) {
164    self.all_units.clear();
165    for unit_status in units.into_iter() {
166      self.all_units.insert(unit_status.id(), unit_status);
167    }
168    self.refresh_filtered_units();
169  }
170
171  // Update units in-place, then filter the list
172  // This is inefficient but it's fast enough
173  // (on gen 13 i7: ~100 microseconds to update, ~100 microseconds to filter)
174  // revisit if needed
175  pub fn update_units(&mut self, units: Vec<UnitWithStatus>) {
176    let now = std::time::Instant::now();
177
178    for unit in units {
179      if let Some(existing) = self.all_units.get_mut(&unit.id()) {
180        existing.update(unit);
181      } else {
182        self.all_units.insert(unit.id(), unit);
183      }
184    }
185    info!("Updated units in {:?}", now.elapsed());
186
187    let now = std::time::Instant::now();
188    self.refresh_filtered_units();
189    info!("Filtered units in {:?}", now.elapsed());
190  }
191
192  pub fn next(&mut self) {
193    self.logs = vec![];
194    self.filtered_units.next();
195    self.get_logs();
196    self.logs_scroll_offset = 0;
197  }
198
199  pub fn previous(&mut self) {
200    self.logs = vec![];
201    self.filtered_units.previous();
202    self.get_logs();
203    self.logs_scroll_offset = 0;
204  }
205
206  pub fn select(&mut self, index: Option<usize>, refresh_logs: bool) {
207    if refresh_logs {
208      self.logs = vec![];
209    }
210    self.filtered_units.select(index);
211    if refresh_logs {
212      self.get_logs();
213      self.logs_scroll_offset = 0;
214    }
215  }
216
217  pub fn unselect(&mut self) {
218    self.logs = vec![];
219    self.filtered_units.unselect();
220  }
221
222  pub fn selected_service(&self) -> Option<UnitId> {
223    self.filtered_units.selected().map(|u| u.id())
224  }
225
226  pub fn get_logs(&mut self) {
227    if let Some(selected) = self.filtered_units.selected() {
228      let unit_id = selected.id();
229      if let Err(e) = self.journalctl_tx.as_ref().unwrap().send(unit_id) {
230        warn!("Error sending unit name to journalctl thread: {}", e);
231      }
232    } else {
233      self.logs = vec![];
234    }
235  }
236
237  fn refresh_filtered_units(&mut self) {
238    let previously_selected = self.selected_service();
239    let search_value_lower = self.input.value().to_lowercase();
240    // TODO: use fuzzy find
241    let matching = self
242      .all_units
243      .values()
244      .filter(|u| u.short_name().to_lowercase().contains(&search_value_lower))
245      .cloned()
246      .collect_vec();
247    self.filtered_units.items = matching;
248
249    // try to select the same item we had selected before
250    // TODO: this is horrible, clean it up
251    if let Some(previously_selected) = previously_selected {
252      if let Some(index) = self
253        .filtered_units
254        .items
255        .iter()
256        .position(|u| u.name == previously_selected.name && u.scope == previously_selected.scope)
257      {
258        self.select(Some(index), false);
259      } else {
260        self.select(Some(0), true);
261      }
262    } else {
263      // if we can't, select the first item in the list
264      if !self.filtered_units.items.is_empty() {
265        self.select(Some(0), true);
266      } else {
267        self.unselect();
268      }
269    }
270  }
271
272  fn start_service(&mut self, service: UnitId) {
273    let cancel_token = CancellationToken::new();
274    let future = systemd::start_service(service.clone(), cancel_token.clone());
275    self.service_action(service, "Start".into(), cancel_token, future);
276  }
277
278  fn stop_service(&mut self, service: UnitId) {
279    let cancel_token = CancellationToken::new();
280    let future = systemd::stop_service(service.clone(), cancel_token.clone());
281    self.service_action(service, "Stop".into(), cancel_token, future);
282  }
283
284  fn reload_service(&mut self, service: UnitId) {
285    let cancel_token = CancellationToken::new();
286    let future = systemd::reload(service.scope, cancel_token.clone());
287    self.service_action(service, "Reload".into(), cancel_token, future);
288  }
289
290  fn restart_service(&mut self, service: UnitId) {
291    let cancel_token = CancellationToken::new();
292    let future = systemd::restart_service(service.clone(), cancel_token.clone());
293    self.service_action(service, "Restart".into(), cancel_token, future);
294  }
295
296  fn service_action<Fut>(&mut self, service: UnitId, action_name: String, cancel_token: CancellationToken, action: Fut)
297  where
298    Fut: Future<Output = anyhow::Result<()>> + Send + 'static,
299  {
300    let tx = self.action_tx.clone().unwrap();
301
302    self.cancel_token = Some(cancel_token.clone());
303
304    let tx_clone = tx.clone();
305    let spinner_task = tokio::spawn(async move {
306      let mut interval = tokio::time::interval(Duration::from_millis(200));
307      loop {
308        interval.tick().await;
309        tx_clone.send(Action::SpinnerTick).unwrap();
310      }
311    });
312
313    tokio::spawn(async move {
314      tx.send(Action::EnterMode(Mode::Processing)).unwrap();
315      match action.await {
316        Ok(_) => {
317          info!("{} of {:?} service {} succeeded", action_name, service.scope, service.name);
318          tx.send(Action::EnterMode(Mode::ServiceList)).unwrap();
319        },
320        // would be nicer to check the error type here, but this is easier
321        Err(_) if cancel_token.is_cancelled() => {
322          warn!("{} of {:?} service {} was cancelled", action_name, service.scope, service.name)
323        },
324        Err(e) => {
325          error!("{} of {:?} service {} failed: {}", action_name, service.scope, service.name, e);
326          let mut error_string = e.to_string();
327
328          if error_string.contains("AccessDenied") {
329            error_string.push('\n');
330            error_string.push('\n');
331            error_string.push_str("Try running this tool with sudo.");
332          }
333
334          tx.send(Action::EnterError(error_string)).unwrap();
335        },
336      }
337      spinner_task.abort();
338      tx.send(Action::RefreshServices).unwrap();
339
340      // Refresh a bit more frequently after a service action
341      for _ in 0..3 {
342        tokio::time::sleep(Duration::from_secs(1)).await;
343        tx.send(Action::RefreshServices).unwrap();
344      }
345    });
346  }
347}
348
349impl Component for Home {
350  fn init(&mut self, tx: UnboundedSender<Action>) -> anyhow::Result<()> {
351    self.action_tx = Some(tx.clone());
352    // TODO find a better name for these. They're used to run any async data loading that needs to happen after the selection is changed,
353    // not just journalctl stuff
354    let (journalctl_tx, journalctl_rx) = std::sync::mpsc::channel::<UnitId>();
355    self.journalctl_tx = Some(journalctl_tx);
356
357    // TODO: move into function
358    tokio::task::spawn_blocking(move || {
359      let mut last_follow_handle: Option<JoinHandle<()>> = None;
360
361      loop {
362        let mut unit: UnitId = match journalctl_rx.recv() {
363          Ok(unit) => unit,
364          Err(_) => return,
365        };
366
367        // drain the channel, use the last value
368        while let Ok(service) = journalctl_rx.try_recv() {
369          info!("Skipping logs for {}...", unit.name);
370          unit = service;
371        }
372
373        if let Some(handle) = last_follow_handle.take() {
374          info!("Cancelling previous journalctl task");
375          handle.abort();
376        }
377
378        // lazy debounce to avoid spamming journalctl on slow connections/systems
379        std::thread::sleep(Duration::from_millis(100));
380
381        // get the unit file path
382        match systemd::get_unit_file_location(&unit) {
383          Ok(path) => {
384            let _ = tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Ok(path) });
385            let _ = tx.send(Action::Render);
386          },
387          Err(e) => {
388            // Fix this!!! Set the path to an error enum variant instead of a string
389            let _ =
390              tx.send(Action::SetUnitFilePath { unit: unit.clone(), path: Err("could not be determined".into()) });
391            let _ = tx.send(Action::Render);
392            error!("Error getting unit file path for {}: {}", unit.name, e);
393          },
394        }
395
396        // First, get the N lines in a batch
397        info!("Getting logs for {}", unit.name);
398        let start = std::time::Instant::now();
399
400        let mut args = vec!["--quiet", "--output=short-iso", "--lines=500", "-u"];
401
402        args.push(&unit.name);
403
404        if unit.scope == UnitScope::User {
405          args.push("--user");
406        }
407
408        match Command::new("journalctl").args(&args).output() {
409          Ok(output) => {
410            if output.status.success() {
411              info!("Got logs for {} in {:?}", unit.name, start.elapsed());
412              if let Ok(stdout) = std::str::from_utf8(&output.stdout) {
413                let mut logs = stdout.trim().split('\n').map(String::from).collect_vec();
414
415                if logs.is_empty() || logs[0].is_empty() {
416                  logs.push(String::from("No logs found/available. Maybe try relaunching with `sudo systemctl-tui`"));
417                }
418                let _ = tx.send(Action::SetLogs { unit: unit.clone(), logs });
419                let _ = tx.send(Action::Render);
420              } else {
421                warn!("Error parsing stdout for {}", unit.name);
422              }
423            } else {
424              warn!("Error getting logs for {}: {}", unit.name, String::from_utf8_lossy(&output.stderr));
425            }
426          },
427          Err(e) => warn!("Error getting logs for {}: {}", unit.name, e),
428        }
429
430        // Then follow the logs
431        // Splitting this into two commands is a bit of a hack that makes it easier to get the initial batch of logs
432        // This does mean that we'll miss any logs that are written between the two commands, low enough risk for now
433        let tx = tx.clone();
434        last_follow_handle = Some(tokio::spawn(async move {
435          let mut command = tokio::process::Command::new("journalctl");
436          command.arg("-u");
437          command.arg(unit.name.clone());
438          command.arg("--output=short-iso");
439          command.arg("--follow");
440          command.arg("--lines=0");
441          command.arg("--quiet");
442          command.stdout(Stdio::piped());
443          command.stderr(Stdio::piped());
444
445          if unit.scope == UnitScope::User {
446            command.arg("--user");
447          }
448
449          let mut child = command.spawn().expect("failed to execute process");
450
451          let stdout = child.stdout.take().unwrap();
452
453          let reader = tokio::io::BufReader::new(stdout);
454          let mut lines = reader.lines();
455          while let Some(line) = lines.next_line().await.unwrap() {
456            let _ = tx.send(Action::AppendLogLine { unit: unit.clone(), line });
457            let _ = tx.send(Action::Render);
458          }
459        }));
460      }
461    });
462    Ok(())
463  }
464
465  fn handle_key_events(&mut self, key: KeyEvent) -> Vec<Action> {
466    if key.modifiers.contains(KeyModifiers::CONTROL) {
467      match key.code {
468        KeyCode::Char('c') => return vec![Action::Quit],
469        KeyCode::Char('q') => return vec![Action::Quit],
470        KeyCode::Char('z') => return vec![Action::Suspend],
471        KeyCode::Char('f') => return vec![Action::EnterMode(Mode::Search)],
472        KeyCode::Char('l') => return vec![Action::ToggleShowLogger],
473        // vim keybindings, apparently
474        KeyCode::Char('d') => return vec![Action::ScrollDown(1), Action::Render],
475        KeyCode::Char('u') => return vec![Action::ScrollUp(1), Action::Render],
476        _ => (),
477      }
478    }
479
480    if matches!(key.code, KeyCode::Char('?')) || matches!(key.code, KeyCode::F(1)) {
481      return vec![Action::ToggleHelp, Action::Render];
482    }
483
484    // TODO: seems like terminals can't recognize shift or ctrl at the same time as page up/down
485    // Is there another way we could scroll in large increments?
486    match key.code {
487      KeyCode::PageDown => return vec![Action::ScrollDown(1), Action::Render],
488      KeyCode::PageUp => return vec![Action::ScrollUp(1), Action::Render],
489      KeyCode::Home => return vec![Action::ScrollToTop, Action::Render],
490      KeyCode::End => return vec![Action::ScrollToBottom, Action::Render],
491      _ => (),
492    }
493
494    match self.mode {
495      Mode::ServiceList => {
496        match key.code {
497          KeyCode::Char('q') => vec![Action::Quit],
498          KeyCode::Up | KeyCode::Char('k') => {
499            // if we're filtering the list, and we're at the top, and there's text in the search box, go to search mode
500            if self.filtered_units.state.selected() == Some(0) {
501              return vec![Action::EnterMode(Mode::Search)];
502            }
503
504            self.previous();
505            vec![Action::Render]
506          },
507          KeyCode::Down | KeyCode::Char('j') => {
508            self.next();
509            vec![Action::Render]
510          },
511          KeyCode::Char('/') => vec![Action::EnterMode(Mode::Search)],
512          KeyCode::Char('e') => {
513            if let Some(selected) = self.filtered_units.selected() {
514              if let Some(Ok(file_path)) = &selected.file_path {
515                return vec![Action::EditUnitFile { unit: selected.id(), path: file_path.clone() }];
516              }
517            }
518            vec![]
519          },
520          KeyCode::Enter | KeyCode::Char(' ') => vec![Action::EnterMode(Mode::ActionMenu)],
521          _ => vec![],
522        }
523      },
524      Mode::Help => match key.code {
525        KeyCode::Esc | KeyCode::Enter => vec![Action::ToggleHelp],
526        _ => vec![],
527      },
528      Mode::Error => match key.code {
529        KeyCode::Esc | KeyCode::Enter => vec![Action::EnterMode(Mode::ServiceList)],
530        _ => vec![],
531      },
532      Mode::Search => match key.code {
533        KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
534        KeyCode::Enter => vec![Action::EnterMode(Mode::ActionMenu)],
535        KeyCode::Down | KeyCode::Tab => {
536          self.next();
537          vec![Action::EnterMode(Mode::ServiceList)]
538        },
539        KeyCode::Up => {
540          self.previous();
541          vec![Action::EnterMode(Mode::ServiceList)]
542        },
543        _ => {
544          let prev_search_value = self.input.value().to_owned();
545          self.input.handle_event(&crossterm::event::Event::Key(key));
546
547          // if the search value changed, filter the list
548          if prev_search_value != self.input.value() {
549            self.refresh_filtered_units();
550          }
551          vec![Action::Render]
552        },
553      },
554      Mode::ActionMenu => match key.code {
555        KeyCode::Esc => vec![Action::EnterMode(Mode::ServiceList)],
556        KeyCode::Down | KeyCode::Char('j') => {
557          self.menu_items.next();
558          vec![Action::Render]
559        },
560        KeyCode::Up | KeyCode::Char('k') => {
561          self.menu_items.previous();
562          vec![Action::Render]
563        },
564        KeyCode::Enter | KeyCode::Char(' ') => match self.menu_items.selected() {
565          Some(i) => vec![i.action.clone()],
566          None => vec![Action::EnterMode(Mode::ServiceList)],
567        },
568        _ => {
569          for item in self.menu_items.items.iter() {
570            if let Some(key_code) = item.key {
571              if key_code == key.code {
572                return vec![item.action.clone()];
573              }
574            }
575          }
576          vec![]
577        },
578      },
579      Mode::Processing => match key.code {
580        KeyCode::Esc => vec![Action::CancelTask],
581        _ => vec![],
582      },
583    }
584  }
585
586  fn dispatch(&mut self, action: Action) -> Option<Action> {
587    match action {
588      Action::ToggleShowLogger => {
589        self.show_logger = !self.show_logger;
590        return Some(Action::Render);
591      },
592      Action::EnterMode(mode) => {
593        if mode == Mode::ActionMenu {
594          if let Some(selected) = self.filtered_units.selected() {
595            let mut menu_items = vec![
596              MenuItem::new("Start", Action::StartService(selected.id()), Some(KeyCode::Char('s'))),
597              MenuItem::new("Stop", Action::StopService(selected.id()), Some(KeyCode::Char('t'))),
598              MenuItem::new("Restart", Action::RestartService(selected.id()), Some(KeyCode::Char('r'))),
599              MenuItem::new("Reload", Action::ReloadService(selected.id()), Some(KeyCode::Char('l'))),
600              // TODO add these
601              // MenuItem::new("Enable", Action::EnableService(selected.clone())),
602              // MenuItem::new("Disable", Action::DisableService(selected.clone())),
603            ];
604
605            if let Some(Ok(file_path)) = &selected.file_path {
606              menu_items.push(MenuItem::new(
607                "Copy unit file path to clipboard",
608                Action::CopyUnitFilePath,
609                Some(KeyCode::Char('c')),
610              ));
611              menu_items.push(MenuItem::new(
612                "Edit unit file",
613                Action::EditUnitFile { unit: selected.id(), path: file_path.clone() },
614                Some(KeyCode::Char('e')),
615              ));
616            }
617
618            self.menu_items = StatefulList::with_items(menu_items);
619            self.menu_items.state.select(Some(0));
620          } else {
621            return None;
622          }
623        }
624
625        self.mode = mode;
626        return Some(Action::Render);
627      },
628      Action::EnterError(err) => {
629        tracing::error!(err);
630        self.error_message = err;
631        return Some(Action::EnterMode(Mode::Error));
632      },
633      Action::ToggleHelp => {
634        if self.mode != Mode::Help {
635          self.previous_mode = Some(self.mode);
636          self.mode = Mode::Help;
637        } else {
638          self.mode = self.previous_mode.unwrap_or(Mode::Search);
639        }
640        return Some(Action::Render);
641      },
642      Action::CopyUnitFilePath => {
643        if let Some(selected) = self.filtered_units.selected() {
644          if let Some(Ok(file_path)) = &selected.file_path {
645            match clipboard_anywhere::set_clipboard(file_path) {
646              Ok(_) => return Some(Action::EnterMode(Mode::ServiceList)),
647              Err(e) => return Some(Action::EnterError(format!("Error copying to clipboard: {}", e))),
648            }
649          } else {
650            return Some(Action::EnterError("No unit file path available".into()));
651          }
652        }
653      },
654      Action::SetUnitFilePath { unit, path } => {
655        if let Some(unit) = self.all_units.get_mut(&unit) {
656          unit.file_path = Some(path.clone());
657        }
658        self.refresh_filtered_units(); // copy the updated unit file path to the filtered list
659      },
660      Action::SetLogs { unit, logs } => {
661        if let Some(selected) = self.filtered_units.selected() {
662          if selected.id() == unit {
663            self.logs = logs;
664          }
665        }
666      },
667      Action::AppendLogLine { unit, line } => {
668        if let Some(selected) = self.filtered_units.selected() {
669          if selected.id() == unit {
670            self.logs.push(line);
671          }
672        }
673      },
674      Action::ScrollUp(offset) => {
675        self.logs_scroll_offset = self.logs_scroll_offset.saturating_sub(offset);
676        info!("scroll offset: {}", self.logs_scroll_offset);
677      },
678      Action::ScrollDown(offset) => {
679        self.logs_scroll_offset = self.logs_scroll_offset.saturating_add(offset);
680        info!("scroll offset: {}", self.logs_scroll_offset);
681      },
682      Action::ScrollToTop => {
683        self.logs_scroll_offset = 0;
684      },
685      Action::ScrollToBottom => {
686        // TODO: this is partially broken, figure out a better way to scroll to end
687        // problem: we don't actually know the height of the paragraph before it's rendered
688        // because it's wrapped based on the width of the widget
689        // A proper fix might need to wait until ratatui improves scrolling: https://github.com/ratatui-org/ratatui/issues/174
690        self.logs_scroll_offset = self.logs.len() as u16;
691      },
692
693      Action::StartService(service_name) => self.start_service(service_name),
694      Action::StopService(service_name) => self.stop_service(service_name),
695      Action::ReloadService(service_name) => self.reload_service(service_name),
696      Action::RestartService(service_name) => self.restart_service(service_name),
697      Action::RefreshServices => {
698        let tx = self.action_tx.clone().unwrap();
699        let scope = self.scope;
700        let limit_units = self.limit_units.to_vec();
701        tokio::spawn(async move {
702          let units = systemd::get_all_services(scope, &limit_units)
703            .await
704            .expect("Failed to get services. Check that systemd is running and try running this tool with sudo.");
705          tx.send(Action::SetServices(units)).unwrap();
706        });
707      },
708      Action::SetServices(units) => {
709        self.update_units(units);
710        return Some(Action::Render);
711      },
712      Action::SpinnerTick => {
713        self.spinner_tick = self.spinner_tick.wrapping_add(1);
714        return Some(Action::Render);
715      },
716      Action::CancelTask => {
717        if let Some(cancel_token) = self.cancel_token.take() {
718          cancel_token.cancel();
719        }
720        self.mode = Mode::ServiceList;
721        return Some(Action::Render);
722      },
723      _ => (),
724    }
725    None
726  }
727
728  fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
729    fn primary(s: &str) -> Span {
730      Span::styled(s, Style::default().fg(Color::Cyan))
731    }
732
733    fn span(s: &str, color: Color) -> Span {
734      Span::styled(s, Style::default().fg(color))
735    }
736
737    fn colored_line(value: &str, color: Color) -> Line {
738      Line::from(vec![Span::styled(value, Style::default().fg(color))])
739    }
740
741    let rect = if self.show_logger {
742      let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);
743
744      self.logger.render(f, chunks[1]);
745      chunks[0]
746    } else {
747      rect
748    };
749
750    let rects =
751      Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100), Constraint::Length(1)])
752        .split(rect);
753    let search_panel = rects[0];
754    let main_panel = rects[1];
755    let help_line_rect = rects[2];
756
757    // Helper for colouring based on the same logic as sysz
758    // https://github.com/joehillen/sysz/blob/8da8e0dcbfde8d68fbdb22382671e395bd370d69/sysz#L69C1-L72C24
759    //    Some units are colored based on state:
760    //    green       active
761    //    red         failed
762    //    yellow      not-found
763    fn unit_color(unit: &UnitWithStatus) -> Color {
764      if unit.is_active() {
765        Color::Green
766      } else if unit.is_failed() {
767        Color::Red
768      } else if unit.is_not_found() {
769        Color::Yellow
770      } else {
771        Color::Reset
772      }
773    }
774
775    let items: Vec<ListItem> = self
776      .filtered_units
777      .items
778      .iter()
779      .map(|i| {
780        let color = unit_color(i);
781        let line = colored_line(i.short_name(), color);
782        ListItem::new(line)
783      })
784      .collect();
785
786    // Create a List from all list items and highlight the currently selected one
787    let items = List::new(items)
788      .block(
789        Block::default()
790          .borders(Borders::ALL)
791          .border_type(BorderType::Rounded)
792          .border_style(if self.mode == Mode::ServiceList {
793            Style::default().fg(Color::LightGreen)
794          } else {
795            Style::default()
796          })
797          .title("─Services"),
798      )
799      .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
800
801    let chunks =
802      Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
803    let right_panel = chunks[1];
804
805    f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);
806
807    let selected_item = self.filtered_units.selected();
808
809    let right_panel =
810      Layout::new(Direction::Vertical, [Constraint::Min(7), Constraint::Percentage(100)]).split(right_panel);
811    let details_panel = right_panel[0];
812    let logs_panel = right_panel[1];
813
814    let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
815    let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
816      .split(details_block.inner(details_panel));
817    let props_pane = details_panel_panes[0];
818    let values_pane = details_panel_panes[1];
819
820    let props_lines = vec![
821      Line::from("Description: "),
822      Line::from("Scope: "),
823      Line::from("Loaded: "),
824      Line::from("Active: "),
825      Line::from("Unit file: "),
826    ];
827
828    let details_text = if let Some(i) = selected_item {
829      fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
830        Line::from(vec![Span::styled(value, Style::default().fg(color))])
831      }
832
833      let load_color = match i.load_state.as_str() {
834        "loaded" => Color::Green,
835        "not-found" => Color::Yellow,
836        "error" => Color::Red,
837        _ => Color::Reset,
838      };
839
840      let active_color = match i.activation_state.as_str() {
841        "active" => Color::Green,
842        "inactive" => Color::Gray,
843        "failed" => Color::Red,
844        _ => Color::Reset,
845      };
846
847      let active_state_value = format!("{} ({})", i.activation_state, i.sub_state);
848
849      let scope = match i.scope {
850        UnitScope::Global => "Global",
851        UnitScope::User => "User",
852      };
853
854      let lines = vec![
855        colored_line(&i.description, Color::Reset),
856        colored_line(scope, Color::Reset),
857        colored_line(&i.load_state, load_color),
858        line_color_string(active_state_value, active_color),
859        match &i.file_path {
860          Some(Ok(file_path)) => Line::from(file_path.as_str()),
861          Some(Err(e)) => colored_line(e, Color::Red),
862          None => Line::from(""),
863        },
864      ];
865
866      lines
867    } else {
868      vec![]
869    };
870
871    let paragraph = Paragraph::new(details_text).style(Style::default());
872
873    let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
874    f.render_widget(props_widget, props_pane);
875
876    f.render_widget(paragraph, values_pane);
877    f.render_widget(details_block, details_panel);
878
879    let log_lines = self
880      .logs
881      .iter()
882      .rev()
883      .map(|l| {
884        if let Some((date, rest)) = l.splitn(2, ' ').collect_tuple() {
885          // This is not a good way to identify dates; the length can vary by system.
886          // TODO: find a better way to identify dates
887          if date.len() != 25 {
888            return Line::from(l.as_str());
889          }
890          Line::from(vec![Span::styled(date, Style::default().fg(Color::DarkGray)), Span::raw(" "), Span::raw(rest)])
891        } else {
892          Line::from(l.as_str())
893        }
894      })
895      .collect_vec();
896
897    let paragraph = Paragraph::new(log_lines)
898      .block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
899      .style(Style::default())
900      .wrap(Wrap { trim: true })
901      .scroll((self.logs_scroll_offset, 0));
902    f.render_widget(paragraph, logs_panel);
903
904    let width = search_panel.width.max(3) - 3; // keep 2 for borders and 1 for cursor
905    let scroll = self.input.visual_scroll(width as usize);
906    let input = Paragraph::new(self.input.value())
907      .style(match self.mode {
908        Mode::Search => Style::default().fg(Color::LightGreen),
909        _ => Style::default(),
910      })
911      .scroll((0, scroll as u16))
912      .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
913        Span::raw("─Search "),
914        Span::styled("(", Style::default().fg(Color::DarkGray)),
915        Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
916        Span::styled(" or ", Style::default().fg(Color::DarkGray)),
917        Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
918        Span::styled(")", Style::default().fg(Color::DarkGray)),
919      ])));
920    f.render_widget(input, search_panel);
921    // clear top right of search panel so we can put help instructions there
922    let help_width = 24;
923    let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
924    f.render_widget(Clear, help_area);
925    let help_text = Paragraph::new(Line::from(vec![
926      Span::raw(" Press "),
927      Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
928      Span::raw(" or "),
929      Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(Color::Gray)),
930      Span::raw(" for help "),
931    ]))
932    .style(Style::default().fg(Color::DarkGray));
933    f.render_widget(help_text, help_area);
934
935    if self.mode == Mode::Search {
936      f.set_cursor_position((
937        (search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
938        search_panel.y + 1,
939      ));
940    }
941
942    if self.mode == Mode::Help {
943      let popup = centered_rect_abs(50, 18, f.area());
944
945      let help_lines = vec![
946        Line::from(""),
947        Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
948        Line::from(""),
949        Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
950        Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
951        Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
952        Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
953        Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
954        Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
955        Line::from(""),
956        Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
957        Line::from(""),
958        Line::from(vec![primary("j"), Span::raw(" navigate down")]),
959        Line::from(vec![primary("k"), Span::raw(" navigate up")]),
960        Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
961      ];
962
963      let name = env!("CARGO_PKG_NAME");
964      let version = env!("CARGO_PKG_VERSION");
965      let title = format!("─Help for {} v{}", name, version);
966
967      let paragraph = Paragraph::new(help_lines)
968        .block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
969        .style(Style::default())
970        .wrap(Wrap { trim: true });
971
972      f.render_widget(Clear, popup);
973      f.render_widget(paragraph, popup);
974    }
975
976    if self.mode == Mode::Error {
977      let popup = centered_rect_abs(50, 12, f.area());
978      let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
979      let paragraph = Paragraph::new(error_lines)
980        .block(
981          Block::default()
982            .title("─Error")
983            .borders(Borders::ALL)
984            .border_type(BorderType::Rounded)
985            .border_style(Style::default().fg(Color::Red)),
986        )
987        .wrap(Wrap { trim: true });
988
989      f.render_widget(Clear, popup);
990      f.render_widget(paragraph, popup);
991    }
992
993    let selected_item = match self.filtered_units.selected() {
994      Some(s) => s,
995      None => return,
996    };
997
998    // Help line at the bottom
999
1000    let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1001
1002    let help_line_rects =
1003      Layout::new(Direction::Horizontal, [Constraint::Fill(1), Constraint::Length(version.len() as u16)])
1004        .split(help_line_rect);
1005    let help_rect = help_line_rects[0];
1006    let version_rect = help_line_rects[1];
1007
1008    let help_line = match self.mode {
1009      Mode::Search => Line::from(span("Show actions: <enter>", Color::Blue)),
1010      Mode::ServiceList => Line::from(span("Show actions: <enter> | Open unit file: e | Quit: q", Color::Blue)),
1011      Mode::Help => Line::from(span("Close menu: <esc>", Color::Blue)),
1012      Mode::ActionMenu => Line::from(span("Execute action: <enter> | Close menu: <esc>", Color::Blue)),
1013      Mode::Processing => Line::from(span("Cancel task: <esc>", Color::Blue)),
1014      Mode::Error => Line::from(span("Close menu: <esc>", Color::Blue)),
1015    };
1016
1017    f.render_widget(help_line, help_rect);
1018    f.render_widget(Line::from(version), version_rect);
1019
1020    let min_width = selected_item.name.len() as u16 + 14;
1021    let desired_width = min_width + 4; // idk, looks alright
1022    let popup_width = desired_width.min(f.area().width);
1023
1024    if self.mode == Mode::ActionMenu {
1025      let height = self.menu_items.items.len() as u16 + 2;
1026      let popup = centered_rect_abs(popup_width, height, f.area());
1027
1028      let items: Vec<ListItem> = self
1029        .menu_items
1030        .items
1031        .iter()
1032        .map(|i| {
1033          let key_string = Span::styled(format!(" {:1} ", i.key_string()), Style::default().fg(Color::Blue));
1034          let line = Line::from(vec![key_string, Span::raw(&i.name)]);
1035          ListItem::new(line)
1036        })
1037        .collect();
1038      let items = List::new(items)
1039        .block(
1040          Block::default()
1041            .borders(Borders::ALL)
1042            .border_type(BorderType::Rounded)
1043            .border_style(Style::default().fg(Color::LightGreen))
1044            .title(format!("Actions for {}", self.filtered_units.selected().unwrap().name)),
1045        )
1046        .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1047
1048      f.render_widget(Clear, popup);
1049      f.render_stateful_widget(items, popup, &mut self.menu_items.state);
1050    }
1051
1052    if self.mode == Mode::Processing {
1053      let height = self.menu_items.items.len() as u16 + 2;
1054      let popup = centered_rect_abs(popup_width, height, f.area());
1055
1056      static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
1057
1058      let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
1059      // TODO: make this a spinner
1060      let paragraph = Paragraph::new(vec![Line::from(format!("{}", spinner_char))])
1061        .block(
1062          Block::default()
1063            .title("Processing")
1064            .border_type(BorderType::Rounded)
1065            .borders(Borders::ALL)
1066            .border_style(Style::default().fg(Color::LightGreen)),
1067        )
1068        .style(Style::default())
1069        .wrap(Wrap { trim: true });
1070
1071      f.render_widget(Clear, popup);
1072      f.render_widget(paragraph, popup);
1073    }
1074  }
1075}
1076
1077/// helper function to create a centered rect using up certain percentage of the available rect `r`
1078fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1079  let popup_layout = Layout::new(
1080    Direction::Vertical,
1081    [
1082      Constraint::Percentage((100 - percent_y) / 2),
1083      Constraint::Percentage(percent_y),
1084      Constraint::Percentage((100 - percent_y) / 2),
1085    ],
1086  )
1087  .split(r);
1088
1089  Layout::new(
1090    Direction::Horizontal,
1091    [
1092      Constraint::Percentage((100 - percent_x) / 2),
1093      Constraint::Percentage(percent_x),
1094      Constraint::Percentage((100 - percent_x) / 2),
1095    ],
1096  )
1097  .split(popup_layout[1])[1]
1098}
1099
1100fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
1101  let offset_x = (r.width.saturating_sub(width)) / 2;
1102  let offset_y = (r.height.saturating_sub(height)) / 2;
1103  let width = width.min(r.width);
1104  let height = height.min(r.height);
1105
1106  Rect::new(offset_x, offset_y, width, height)
1107}