Skip to main content

systemctl_tui/components/
home.rs

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