systemctl_tui/components/
home.rs

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