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        let limit_units = self.limit_units.clone();
907        tokio::spawn(async move {
908          match systemd::get_unit_files(scope, &limit_units).await {
909            Ok(unit_files) => {
910              let _ = tx.send(Action::SetUnitFiles(unit_files));
911            },
912            Err(e) => {
913              error!("Failed to get unit files: {:?}", e);
914            },
915          }
916        });
917      },
918      Action::SetUnitFiles(unit_files) => {
919        self.merge_unit_files(unit_files);
920        return Some(Action::Render);
921      },
922      Action::KillService(service_name, signal) => self.kill_service(service_name, signal),
923      Action::SpinnerTick => {
924        self.spinner_tick = self.spinner_tick.wrapping_add(1);
925        return Some(Action::Render);
926      },
927      Action::CancelTask => {
928        if let Some(cancel_token) = self.cancel_token.take() {
929          cancel_token.cancel();
930        }
931        self.mode = Mode::ServiceList;
932        return Some(Action::Render);
933      },
934      _ => (),
935    }
936    None
937  }
938
939  fn render(&mut self, f: &mut Frame<'_>, rect: Rect) {
940    // Theme colors for adaptive light/dark support
941    let theme = self.theme;
942
943    fn span(s: &str, color: Color) -> Span<'_> {
944      Span::styled(s, Style::default().fg(color))
945    }
946
947    fn colored_line(value: &str, color: Color) -> Line<'_> {
948      Line::from(vec![Span::styled(value, Style::default().fg(color))])
949    }
950
951    let rect = if self.show_logger {
952      let chunks = Layout::new(Direction::Vertical, Constraint::from_percentages([50, 50])).split(rect);
953
954      self.logger.render(f, chunks[1]);
955      chunks[0]
956    } else {
957      rect
958    };
959
960    let rects =
961      Layout::new(Direction::Vertical, [Constraint::Min(3), Constraint::Percentage(100), Constraint::Length(1)])
962        .split(rect);
963    let search_panel = rects[0];
964    let main_panel = rects[1];
965    let help_line_rect = rects[2];
966
967    // Helper for colouring based on the same logic as sysz
968    // https://github.com/joehillen/sysz/blob/8da8e0dcbfde8d68fbdb22382671e395bd370d69/sysz#L69C1-L72C24
969    //    Some units are colored based on state:
970    //    green       active
971    //    red         failed
972    //    yellow      not-found
973    fn unit_color(unit: &UnitWithStatus) -> Color {
974      if unit.is_active() {
975        Color::Green
976      } else if unit.is_failed() {
977        Color::Red
978      } else if unit.is_not_found() {
979        Color::Yellow
980      } else {
981        Color::Reset
982      }
983    }
984
985    let items: Vec<ListItem> = self
986      .filtered_units
987      .items
988      .iter()
989      .map(|m| {
990        let color = unit_color(&m.unit);
991        let name = m.unit.short_name();
992
993        if m.match_indices.is_empty() {
994          ListItem::new(colored_line(name, color))
995        } else {
996          // Build spans with highlighted matched characters
997          let mut spans = Vec::new();
998          let mut last_end = 0;
999
1000          for &idx in &m.match_indices {
1001            if idx > last_end && idx <= name.len() {
1002              // Non-matched portion
1003              spans.push(Span::styled(&name[last_end..idx], Style::default().fg(color)));
1004            }
1005            // Matched character - bold + underlined
1006            if idx < name.len() {
1007              let char_end = name[idx..].chars().next().map(|c| idx + c.len_utf8()).unwrap_or(idx + 1);
1008              spans.push(Span::styled(
1009                &name[idx..char_end],
1010                Style::default().fg(color).add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1011              ));
1012              last_end = char_end;
1013            }
1014          }
1015
1016          if last_end < name.len() {
1017            spans.push(Span::styled(&name[last_end..], Style::default().fg(color)));
1018          }
1019
1020          ListItem::new(Line::from(spans))
1021        }
1022      })
1023      .collect();
1024
1025    // Create a List from all list items and highlight the currently selected one
1026    let items = List::new(items)
1027      .block(
1028        Block::default()
1029          .borders(Borders::ALL)
1030          .border_type(BorderType::Rounded)
1031          .border_style(if self.mode == Mode::ServiceList {
1032            Style::default().fg(theme.accent)
1033          } else {
1034            Style::default()
1035          })
1036          .title("─Services"),
1037      )
1038      .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1039
1040    let chunks =
1041      Layout::new(Direction::Horizontal, [Constraint::Min(30), Constraint::Percentage(100)]).split(main_panel);
1042    let right_panel = chunks[1];
1043
1044    f.render_stateful_widget(items, chunks[0], &mut self.filtered_units.state);
1045
1046    let selected_item = self.filtered_units.selected();
1047
1048    let right_panel =
1049      Layout::new(Direction::Vertical, [Constraint::Min(8), Constraint::Percentage(100)]).split(right_panel);
1050    let details_panel = right_panel[0];
1051    let logs_panel = right_panel[1];
1052
1053    let details_block = Block::default().title("─Details").borders(Borders::ALL).border_type(BorderType::Rounded);
1054    let details_panel_panes = Layout::new(Direction::Horizontal, [Constraint::Min(14), Constraint::Percentage(100)])
1055      .split(details_block.inner(details_panel));
1056    let props_pane = details_panel_panes[0];
1057    let values_pane = details_panel_panes[1];
1058
1059    let props_lines = vec![
1060      Line::from("Description: "),
1061      Line::from("Enablement: "),
1062      Line::from("Scope: "),
1063      Line::from("Loaded: "),
1064      Line::from("Active: "),
1065      Line::from("Unit file: "),
1066    ];
1067
1068    let details_text = if let Some(m) = selected_item {
1069      fn line_color_string<'a>(value: String, color: Color) -> Line<'a> {
1070        Line::from(vec![Span::styled(value, Style::default().fg(color))])
1071      }
1072
1073      let load_color = match m.unit.load_state.as_str() {
1074        "loaded" => Color::Green,
1075        "not-found" => Color::Yellow,
1076        "error" => Color::Red,
1077        _ => Color::Reset,
1078      };
1079
1080      let active_color = match m.unit.activation_state.as_str() {
1081        "active" => Color::Green,
1082        "inactive" => Color::Reset,
1083        "failed" => Color::Red,
1084        _ => Color::Reset,
1085      };
1086
1087      let active_state_value = format!("{} ({})", m.unit.activation_state, m.unit.sub_state);
1088
1089      let scope = match m.unit.scope {
1090        UnitScope::Global => "Global",
1091        UnitScope::User => "User",
1092      };
1093
1094      let enablement_state = m.unit.enablement_state.as_deref().unwrap_or("");
1095      let enablement_color = match enablement_state {
1096        "enabled" => Color::Green,
1097        "disabled" => Color::Yellow,
1098        "masked" => Color::Red,
1099        _ => Color::Reset,
1100      };
1101
1102      let lines = vec![
1103        colored_line(&m.unit.description, Color::Reset),
1104        colored_line(enablement_state, enablement_color),
1105        colored_line(scope, Color::Reset),
1106        colored_line(&m.unit.load_state, load_color),
1107        line_color_string(active_state_value, active_color),
1108        match &m.unit.file_path {
1109          Some(Ok(file_path)) => Line::from(file_path.as_str()),
1110          Some(Err(e)) => colored_line(e, Color::Red),
1111          None => Line::from(""),
1112        },
1113      ];
1114
1115      lines
1116    } else {
1117      vec![]
1118    };
1119
1120    let paragraph = Paragraph::new(details_text).style(Style::default());
1121
1122    let props_widget = Paragraph::new(props_lines).alignment(ratatui::layout::Alignment::Right);
1123    f.render_widget(props_widget, props_pane);
1124
1125    f.render_widget(paragraph, values_pane);
1126    f.render_widget(details_block, details_panel);
1127
1128    let log_lines = self
1129      .logs
1130      .iter()
1131      .rev()
1132      .map(|l| {
1133        if let Some((timestamp, rest)) = l.split_once(' ') {
1134          if let Some(formatted_date) = parse_journalctl_timestamp(timestamp) {
1135            return Line::from(vec![
1136              Span::styled(formatted_date, Style::default().add_modifier(Modifier::DIM)),
1137              Span::raw(" "),
1138              Span::raw(rest),
1139            ]);
1140          }
1141        }
1142
1143        Line::from(l.as_str())
1144      })
1145      .collect_vec();
1146
1147    let paragraph = Paragraph::new(log_lines)
1148      .block(Block::default().title("─Service Logs").borders(Borders::ALL).border_type(BorderType::Rounded))
1149      .style(Style::default())
1150      .wrap(Wrap { trim: true })
1151      .scroll((self.logs_scroll_offset, 0));
1152    f.render_widget(paragraph, logs_panel);
1153
1154    let width = search_panel.width.max(3) - 3; // keep 2 for borders and 1 for cursor
1155    let scroll = self.input.visual_scroll(width as usize);
1156    let input = Paragraph::new(self.input.value())
1157      .style(match self.mode {
1158        Mode::Search => Style::default().fg(theme.accent),
1159        _ => Style::default(),
1160      })
1161      .scroll((0, scroll as u16))
1162      .block(Block::default().borders(Borders::ALL).border_type(BorderType::Rounded).title(Line::from(vec![
1163        Span::raw("─Search "),
1164        Span::styled("(", Style::default().fg(theme.muted_alt)),
1165        Span::styled("ctrl+f", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1166        Span::styled(" or ", Style::default().fg(theme.muted_alt)),
1167        Span::styled("/", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1168        Span::styled(")", Style::default().fg(theme.muted_alt)),
1169      ])));
1170    f.render_widget(input, search_panel);
1171    // clear top right of search panel so we can put help instructions there
1172    let help_width = 24;
1173    let help_area = Rect::new(search_panel.x + search_panel.width - help_width - 2, search_panel.y, help_width, 1);
1174    f.render_widget(Clear, help_area);
1175    let help_text = Paragraph::new(Line::from(vec![
1176      Span::raw(" Press "),
1177      Span::styled("?", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1178      Span::raw(" or "),
1179      Span::styled("F1", Style::default().add_modifier(Modifier::BOLD).fg(theme.kbd)),
1180      Span::raw(" for help "),
1181    ]))
1182    .style(Style::default().fg(theme.muted_alt));
1183    f.render_widget(help_text, help_area);
1184
1185    if self.mode == Mode::Search {
1186      f.set_cursor_position((
1187        (search_panel.x + 1 + self.input.cursor() as u16).min(search_panel.x + search_panel.width - 2),
1188        search_panel.y + 1,
1189      ));
1190    }
1191
1192    if self.mode == Mode::Help {
1193      let popup = centered_rect_abs(50, 18, f.area());
1194
1195      let primary = |s| Span::styled(s, Style::default().fg(theme.primary));
1196      let help_lines = vec![
1197        Line::from(""),
1198        Line::from(Span::styled("Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1199        Line::from(""),
1200        Line::from(vec![primary("ctrl+C"), Span::raw(" or "), primary("ctrl+Q"), Span::raw(" to quit")]),
1201        Line::from(vec![primary("ctrl+L"), Span::raw(" toggles the logger pane")]),
1202        Line::from(vec![primary("PageUp"), Span::raw(" / "), primary("PageDown"), Span::raw(" scroll the logs")]),
1203        Line::from(vec![primary("Home"), Span::raw(" / "), primary("End"), Span::raw(" scroll to top/bottom")]),
1204        Line::from(vec![primary("Enter"), Span::raw(" or "), primary("Space"), Span::raw(" open the action menu")]),
1205        Line::from(vec![primary("?"), Span::raw(" / "), primary("F1"), Span::raw(" open this help pane")]),
1206        Line::from(""),
1207        Line::from(Span::styled("Vim Style Shortcuts", Style::default().add_modifier(Modifier::UNDERLINED))),
1208        Line::from(""),
1209        Line::from(vec![primary("j"), Span::raw(" navigate down")]),
1210        Line::from(vec![primary("k"), Span::raw(" navigate up")]),
1211        Line::from(vec![primary("ctrl+U"), Span::raw(" / "), primary("ctrl+D"), Span::raw(" scroll the logs")]),
1212      ];
1213
1214      let name = env!("CARGO_PKG_NAME");
1215      let version = env!("CARGO_PKG_VERSION");
1216      let title = format!("─Help for {name} v{version}");
1217
1218      let paragraph = Paragraph::new(help_lines)
1219        .block(Block::default().title(title).borders(Borders::ALL).border_type(BorderType::Rounded))
1220        .style(Style::default())
1221        .wrap(Wrap { trim: true });
1222
1223      f.render_widget(Clear, popup);
1224      f.render_widget(paragraph, popup);
1225    }
1226
1227    if self.mode == Mode::Error {
1228      let popup = centered_rect_abs(50, 12, f.area());
1229      let error_lines = self.error_message.split('\n').map(Line::from).collect_vec();
1230      let paragraph = Paragraph::new(error_lines)
1231        .block(
1232          Block::default()
1233            .title("─Error")
1234            .borders(Borders::ALL)
1235            .border_type(BorderType::Rounded)
1236            .border_style(Style::default().fg(Color::Red)),
1237        )
1238        .wrap(Wrap { trim: true });
1239
1240      f.render_widget(Clear, popup);
1241      f.render_widget(paragraph, popup);
1242    }
1243
1244    let selected_item = match self.filtered_units.selected() {
1245      Some(s) => s,
1246      None => return,
1247    };
1248
1249    // Help line at the bottom
1250
1251    let version = format!("v{}", env!("CARGO_PKG_VERSION"));
1252
1253    let help_line_rects =
1254      Layout::new(Direction::Horizontal, [Constraint::Fill(1), Constraint::Length(version.len() as u16)])
1255        .split(help_line_rect);
1256    let help_rect = help_line_rects[0];
1257    let version_rect = help_line_rects[1];
1258
1259    let help_line = match self.mode {
1260      Mode::Search => Line::from(span("Show actions: <enter>", theme.primary)),
1261      Mode::ServiceList => {
1262        Line::from(span("Show actions: <enter> | Open logs in pager: o | Edit unit file: e | Quit: q", theme.primary))
1263      },
1264      Mode::Help => Line::from(span("Close menu: <esc>", theme.primary)),
1265      Mode::ActionMenu => Line::from(span("Execute action: <enter> | Close menu: <esc>", theme.primary)),
1266      Mode::Processing => Line::from(span("Cancel task: <esc>", theme.primary)),
1267      Mode::Error => Line::from(span("Close menu: <esc>", theme.primary)),
1268      Mode::SignalMenu => Line::from(span("Send signal: <enter> | Close menu: <esc>", theme.primary)),
1269    };
1270
1271    f.render_widget(help_line, help_rect);
1272    f.render_widget(Line::from(version), version_rect);
1273
1274    let title = format!("Actions for {}", selected_item.unit.name);
1275    let mut min_width = title.len() as u16 + 2; // title plus corners
1276    min_width = min_width.max(24); // hack: the width of the longest action name + 2
1277
1278    let popup_width = min_width.min(f.area().width);
1279
1280    if self.mode == Mode::ActionMenu || self.mode == Mode::SignalMenu {
1281      let title_prefix = if self.mode == Mode::ActionMenu { "Actions" } else { "Signals" };
1282      let title = format!("{} for {}", title_prefix, selected_item.unit.name);
1283      let height = self.menu_items.items.len() as u16 + 2;
1284      let popup = centered_rect_abs(popup_width, height, f.area());
1285
1286      let items: Vec<ListItem> = self
1287        .menu_items
1288        .items
1289        .iter()
1290        .map(|i| {
1291          let key_string = Span::styled(format!(" {:1} ", i.key_string()), Style::default().fg(theme.primary));
1292          let line = Line::from(vec![key_string, Span::raw(&i.name)]);
1293          ListItem::new(line)
1294        })
1295        .collect();
1296      let items = List::new(items)
1297        .block(
1298          Block::default()
1299            .borders(Borders::ALL)
1300            .border_type(BorderType::Rounded)
1301            .border_style(Style::default().fg(theme.accent))
1302            .title(title),
1303        )
1304        .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD));
1305
1306      f.render_widget(Clear, popup);
1307      f.render_stateful_widget(items, popup, &mut self.menu_items.state);
1308    }
1309
1310    if self.mode == Mode::Processing {
1311      let height = self.menu_items.items.len() as u16 + 2;
1312      let popup = centered_rect_abs(popup_width, height, f.area());
1313
1314      static SPINNER_CHARS: &[char] = &['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾'];
1315
1316      let spinner_char = SPINNER_CHARS[self.spinner_tick as usize % SPINNER_CHARS.len()];
1317      // TODO: make this a spinner
1318      let paragraph = Paragraph::new(vec![Line::from(format!("{spinner_char}"))])
1319        .block(
1320          Block::default()
1321            .title("Processing")
1322            .border_type(BorderType::Rounded)
1323            .borders(Borders::ALL)
1324            .border_style(Style::default().fg(theme.accent)),
1325        )
1326        .style(Style::default())
1327        .wrap(Wrap { trim: true });
1328
1329      f.render_widget(Clear, popup);
1330      f.render_widget(paragraph, popup);
1331    }
1332  }
1333}
1334
1335/// helper function to create a centered rect using up certain percentage of the available rect `r`
1336fn _centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
1337  let popup_layout = Layout::new(
1338    Direction::Vertical,
1339    [
1340      Constraint::Percentage((100 - percent_y) / 2),
1341      Constraint::Percentage(percent_y),
1342      Constraint::Percentage((100 - percent_y) / 2),
1343    ],
1344  )
1345  .split(r);
1346
1347  Layout::new(
1348    Direction::Horizontal,
1349    [
1350      Constraint::Percentage((100 - percent_x) / 2),
1351      Constraint::Percentage(percent_x),
1352      Constraint::Percentage((100 - percent_x) / 2),
1353    ],
1354  )
1355  .split(popup_layout[1])[1]
1356}
1357
1358fn centered_rect_abs(width: u16, height: u16, r: Rect) -> Rect {
1359  let offset_x = (r.width.saturating_sub(width)) / 2;
1360  let offset_y = (r.height.saturating_sub(height)) / 2;
1361  let width = width.min(r.width);
1362  let height = height.min(r.height);
1363
1364  Rect::new(offset_x, offset_y, width, height)
1365}
1366
1367/// Parse a journalctl timestamp and return a formatted date string.
1368///
1369/// systemd v255 changed the timestamp format from `-0700` to `-07:00` (RFC 3339).
1370/// See: https://github.com/systemd/systemd/pull/29134
1371fn parse_journalctl_timestamp(timestamp: &str) -> Option<String> {
1372  // %z accepts both "-0700" (systemd <v255) and "-07:00" (systemd >=v255)
1373  DateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%z").ok().map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
1374}
1375
1376#[cfg(test)]
1377mod tests {
1378  use super::*;
1379
1380  #[test]
1381  fn test_parse_timestamp_systemd_v255_and_later() {
1382    // systemd >=v255 uses RFC 3339 format with colon in timezone offset
1383    // https://github.com/systemd/systemd/pull/29134
1384    let timestamp = "2025-04-26T06:04:45-07:00";
1385    let result = parse_journalctl_timestamp(timestamp);
1386    assert_eq!(result, Some("2025-04-26 06:04".to_string()));
1387  }
1388
1389  #[test]
1390  fn test_parse_timestamp_systemd_before_v255() {
1391    // systemd <v255 uses ISO 8601 format without colon in timezone offset
1392    let timestamp = "2025-10-06T11:07:44-0700";
1393    let result = parse_journalctl_timestamp(timestamp);
1394    assert_eq!(result, Some("2025-10-06 11:07".to_string()));
1395  }
1396}