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