1mod event;
7mod reducer;
8mod render;
9mod state;
10mod style;
11
12pub use crossterm;
14pub use ratatui;
16
17use color_eyre::eyre;
18use futures::StreamExt;
19use micromux::{BoundedLog, Command, Event as SchedulerEvent, ServiceDescriptor};
20use ratatui::DefaultTerminal;
21use tokio::sync::mpsc;
22use tokio_stream::wrappers::ReceiverStream;
23
24type UiEventStream = futures::stream::Chain<
25 ReceiverStream<SchedulerEvent>,
26 futures::stream::Pending<SchedulerEvent>,
27>;
28
29const KIB: usize = 1024;
30const MIB: usize = 1024 * KIB;
31const HEALTHCHECK_HISTORY: usize = 2;
32
33#[derive()]
34pub struct App {
36 running: bool,
38 commands_tx: mpsc::Sender<Command>,
39 shutdown: micromux::CancellationToken,
40 ui_rx: UiEventStream,
41 input_event_handler: event::InputHandler,
43 state: state::State,
45 log_view: crate::render::log_view::LogView,
47 healthcheck_view: crate::render::log_view::LogView,
48 show_healthcheck_pane: bool,
49 attach_mode: bool,
50 focus: Focus,
51 terminal_cols: u16,
52 terminal_rows: u16,
53 last_pty_cols: u16,
54 last_pty_rows: u16,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum Focus {
59 Services,
61 Logs,
63 Healthcheck,
65}
66
67impl App {
68 #[must_use]
70 pub fn new(
71 services: &[ServiceDescriptor],
72 ui_rx: mpsc::Receiver<SchedulerEvent>,
73 commands_tx: mpsc::Sender<Command>,
74 shutdown: micromux::CancellationToken,
75 ) -> Self {
76 let ui_rx = ReceiverStream::new(ui_rx).chain(futures::stream::pending());
77
78 let services = services
79 .iter()
80 .map(|service| {
81 let service_state = state::Service {
82 id: service.id.clone(),
83 exec_state: state::Execution::Pending,
84 open_ports: service.open_ports.clone(),
85 logs: BoundedLog::with_limits(1000, 64 * MIB).into(),
86 cached_num_lines: 0,
87 cached_logs: String::new(),
88 logs_dirty: true,
89 healthcheck_configured: service.healthcheck_configured,
90 healthcheck_attempts: std::collections::VecDeque::new(),
91 healthcheck_cached_num_lines: 0,
92 healthcheck_cached_text: String::new(),
93 healthcheck_dirty: true,
94 };
95 (service.id.clone(), service_state)
96 })
97 .collect();
98
99 let log_view = render::log_view::LogView::default();
100 let healthcheck_view = render::log_view::LogView::default();
101
102 Self {
103 running: true,
104 commands_tx,
105 shutdown,
106 ui_rx,
107 input_event_handler: event::InputHandler::new(),
108 state: state::State::new(services),
109 log_view,
110 healthcheck_view,
111 show_healthcheck_pane: false,
112 attach_mode: false,
113 focus: Focus::Services,
114 terminal_cols: 80,
115 terminal_rows: 24,
116 last_pty_cols: 0,
117 last_pty_rows: 0,
118 }
119 }
120}
121
122impl App {
123 fn desired_pty_size(&self) -> (u16, u16) {
124 use ratatui::layout::{Constraint, Direction, Layout, Rect};
125
126 let area = Rect {
127 x: 0,
128 y: 0,
129 width: self.terminal_cols,
130 height: self.terminal_rows,
131 };
132
133 let [_header_area, main_area, _footer_area] = Layout::default()
134 .direction(Direction::Vertical)
135 .constraints([
136 Constraint::Length(0),
137 Constraint::Min(0),
138 Constraint::Length(1),
139 ])
140 .spacing(0)
141 .areas(area);
142
143 let [_services_area, main_right_area] = Layout::default()
144 .direction(Direction::Horizontal)
145 .constraints([
146 Constraint::Length(self.state.services_sidebar_width),
147 Constraint::Min(0),
148 ])
149 .spacing(0)
150 .areas(main_area);
151
152 let logs_area = if self.show_healthcheck_pane {
153 let [a, _b] = Layout::default()
154 .direction(Direction::Horizontal)
155 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
156 .spacing(0)
157 .areas::<2>(main_right_area);
158 a
159 } else {
160 main_right_area
161 };
162
163 let [logs_pane_area, _scrollbar_area] = Layout::default()
164 .direction(Direction::Horizontal)
165 .constraints([Constraint::Min(0), Constraint::Length(1)])
166 .spacing(0)
167 .areas(logs_area);
168
169 let cols = logs_pane_area.width.saturating_sub(2).max(1);
170 let rows = logs_pane_area.height.saturating_sub(2).max(1);
171 (cols, rows)
172 }
173
174 fn maybe_resize_pty(&mut self) {
175 let (cols, rows) = self.desired_pty_size();
176 if cols == self.last_pty_cols && rows == self.last_pty_rows {
177 return;
178 }
179
180 self.last_pty_cols = cols;
181 self.last_pty_rows = rows;
182 let _ = self.commands_tx.try_send(Command::ResizeAll { cols, rows });
183 }
184
185 pub async fn run(mut self, mut terminal: DefaultTerminal) -> eyre::Result<()> {
193 #[derive(Debug, strum::Display)]
194 enum Event {
195 Input(event::Input),
196 Scheduler(SchedulerEvent),
197 }
198
199 let area = terminal.size()?;
200 self.terminal_cols = area.width;
201 self.terminal_rows = area.height;
202 self.maybe_resize_pty();
203
204 while self.is_running() {
205 let event = tokio::select! {
207 () = self.shutdown.cancelled() => None,
208 input = self.input_event_handler.next() => Some(Event::Input(input?)),
209 event = self.ui_rx.next() => event.map(Event::Scheduler),
210 };
211
212 match &event {
213 Some(Event::Input(event)) if !event.is_tick() => {
214 tracing::trace!(%event, "received event");
215 }
216 Some(Event::Scheduler(event)) => {
217 tracing::debug!(%event, "received event");
218 }
219 _ => {}
220 }
221
222 match event {
223 Some(Event::Input(event)) => {
224 self.handle_input_event(event);
225 terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
226 }
227 Some(Event::Scheduler(event)) => {
228 self.handle_event(event);
229 terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
230 }
231 None => {
232 self.running = false;
233 }
234 }
235 }
236 Ok(())
237 }
238
239 fn handle_event(&mut self, event: SchedulerEvent) {
240 reducer::apply(&mut self.state, event);
241 }
242
243 fn handle_input_event(&mut self, input_event: event::Input) {
244 match input_event {
245 event::Input::Tick => self.tick(),
246 event::Input::Event(event) => self.handle_crossterm_event(&event),
247 }
248 }
249
250 fn handle_crossterm_event(&mut self, event: &crossterm::event::Event) {
251 use crossterm::event::KeyEventKind;
252
253 match *event {
254 crossterm::event::Event::Resize(cols, rows) => {
255 self.terminal_cols = cols;
256 self.terminal_rows = rows;
257 self.maybe_resize_pty();
258 }
259 crossterm::event::Event::Key(key) if key.kind == KeyEventKind::Press => {
260 self.handle_key_press(key);
261 }
262 _ => {}
263 }
264 }
265
266 fn handle_key_press(&mut self, key: crossterm::event::KeyEvent) {
267 use crossterm::event::KeyCode;
268
269 if self.attach_mode {
270 self.handle_key_press_attach_mode(key);
271 return;
272 }
273
274 match key.code {
275 KeyCode::Char('q') => self.exit(),
277
278 KeyCode::Tab => self.toggle_focus(),
280
281 KeyCode::Char('a') => {
283 self.attach_mode = !self.attach_mode;
284 }
285 KeyCode::Char('H') => self.toggle_healthcheck_pane(),
286
287 KeyCode::Char('d') => self.disable_current_service(),
289
290 KeyCode::Char('r') => self.restart_current_service(),
292
293 KeyCode::Char('R') => self.restart_all_services(),
295
296 KeyCode::Char('k') | KeyCode::Up => self.navigate_up(),
298 KeyCode::Char('j') | KeyCode::Down => self.navigate_down(),
299 KeyCode::Char('g') => self.scroll_to_top(),
300 KeyCode::Char('G') => self.scroll_to_bottom(),
301
302 KeyCode::Char('-' | 'h') | KeyCode::Left => {
304 self.state.resize_left();
305 self.maybe_resize_pty();
306 }
307
308 KeyCode::Char('+' | 'l') | KeyCode::Right => {
310 self.state.resize_right();
311 self.maybe_resize_pty();
312 }
313
314 KeyCode::Char('w') => self.toggle_wrap(),
316
317 KeyCode::Char('t') => self.toggle_tail(),
319 _ => {}
320 }
321 }
322
323 fn handle_key_press_attach_mode(&mut self, key: crossterm::event::KeyEvent) {
324 use crossterm::event::{KeyCode, KeyModifiers};
325
326 match key.code {
327 KeyCode::Esc if key.modifiers.contains(KeyModifiers::ALT) => {
328 self.attach_mode = false;
329 }
330 _ => {
331 if let Some(bytes) = key_event_to_bytes(key.code, key.modifiers)
332 && let Some(service) = self.state.current_service()
333 {
334 let service_id = service.id.clone();
335 let _ = self
336 .commands_tx
337 .try_send(Command::SendInput(service_id, bytes));
338 }
339 }
340 }
341 }
342
343 fn toggle_focus(&mut self) {
344 self.focus = if self.show_healthcheck_pane {
345 match self.focus {
346 Focus::Services => Focus::Logs,
347 Focus::Logs => Focus::Healthcheck,
348 Focus::Healthcheck => Focus::Services,
349 }
350 } else {
351 match self.focus {
352 Focus::Services => Focus::Logs,
353 Focus::Logs | Focus::Healthcheck => Focus::Services,
354 }
355 };
356 }
357
358 fn toggle_healthcheck_pane(&mut self) {
359 self.show_healthcheck_pane = !self.show_healthcheck_pane;
360 if !self.show_healthcheck_pane && self.focus == Focus::Healthcheck {
361 self.focus = Focus::Logs;
362 }
363 self.maybe_resize_pty();
364 }
365
366 fn navigate_up(&mut self) {
367 match self.focus {
368 Focus::Services => self.state.service_up(),
369 Focus::Logs => self.scroll_logs_up(1),
370 Focus::Healthcheck => self.scroll_healthchecks_up(1),
371 }
372 }
373
374 fn navigate_down(&mut self) {
375 match self.focus {
376 Focus::Services => self.state.service_down(),
377 Focus::Logs => self.scroll_logs_down(1),
378 Focus::Healthcheck => self.scroll_healthchecks_down(1),
379 }
380 }
381
382 fn scroll_to_top(&mut self) {
383 match self.focus {
384 Focus::Logs => {
385 self.log_view.follow_tail = false;
386 self.log_view.scroll_offset = 0;
387 }
388 Focus::Healthcheck => {
389 self.healthcheck_view.follow_tail = false;
390 self.healthcheck_view.scroll_offset = 0;
391 }
392 Focus::Services => {}
393 }
394 }
395
396 fn scroll_to_bottom(&mut self) {
397 match self.focus {
398 Focus::Logs => {
399 self.log_view.follow_tail = true;
400 }
401 Focus::Healthcheck => {
402 self.healthcheck_view.follow_tail = true;
403 }
404 Focus::Services => {}
405 }
406 }
407
408 fn toggle_wrap(&mut self) {
409 let wrap = !self.log_view.wrap;
410 self.log_view.wrap = wrap;
411 self.healthcheck_view.wrap = wrap;
412 }
413
414 fn toggle_tail(&mut self) {
415 match self.focus {
416 Focus::Logs | Focus::Services => {
417 self.log_view.follow_tail = !self.log_view.follow_tail;
418 }
419 Focus::Healthcheck => {
420 self.healthcheck_view.follow_tail = !self.healthcheck_view.follow_tail;
421 }
422 }
423 }
424
425 fn log_viewport_height(&self) -> u16 {
426 self.terminal_rows.saturating_sub(3)
428 }
429
430 fn scroll_logs_up(&mut self, lines: u16) {
431 self.log_view.follow_tail = false;
432 self.log_view.scroll_offset = self.log_view.scroll_offset.saturating_sub(lines);
433 }
434
435 fn scroll_logs_down(&mut self, lines: u16) {
436 self.log_view.follow_tail = false;
437 let Some(service) = self.state.current_service() else {
438 return;
439 };
440 let (num_lines, _) = service.logs.full_text();
441 let viewport = self.log_viewport_height();
442 let max_off = num_lines.saturating_sub(viewport);
443 self.log_view.scroll_offset = self
444 .log_view
445 .scroll_offset
446 .saturating_add(lines)
447 .min(max_off);
448 }
449
450 fn scroll_healthchecks_up(&mut self, lines: u16) {
451 self.healthcheck_view.follow_tail = false;
452 self.healthcheck_view.scroll_offset =
453 self.healthcheck_view.scroll_offset.saturating_sub(lines);
454 }
455
456 fn scroll_healthchecks_down(&mut self, lines: u16) {
457 self.healthcheck_view.follow_tail = false;
458 let Some(service) = self.state.current_service() else {
459 return;
460 };
461 let num_lines = service.healthcheck_cached_num_lines;
462 let viewport = self.log_viewport_height();
463 let max_off = num_lines.saturating_sub(viewport);
464 self.healthcheck_view.scroll_offset = self
465 .healthcheck_view
466 .scroll_offset
467 .saturating_add(lines)
468 .min(max_off);
469 }
470
471 pub fn tick(&self) {}
473
474 fn is_running(&self) -> bool {
475 self.running
476 }
477
478 fn exit(&mut self) {
479 self.shutdown.cancel();
481 self.running = false;
482 }
483
484 fn disable_current_service(&self) {
486 let Some(service) = self.state.current_service() else {
487 return;
488 };
489 tracing::info!(service_id = service.id, "disabling service");
490 let command = match service.exec_state {
491 state::Execution::Disabled => Command::Enable(service.id.clone()),
492 _ => Command::Disable(service.id.clone()),
493 };
494 let _ = self.commands_tx.try_send(command);
495 }
496
497 fn restart_current_service(&self) {
499 let Some(service) = self.state.current_service() else {
500 return;
501 };
502 tracing::info!(service_id = service.id, "restarting service");
503 if service.exec_state == state::Execution::Disabled {
504 let _ = self
505 .commands_tx
506 .try_send(Command::Enable(service.id.clone()));
507 }
508 let _ = self
509 .commands_tx
510 .try_send(Command::Restart(service.id.clone()));
511 }
512
513 fn restart_all_services(&self) {
515 tracing::info!("restarting all services");
516 let _ = self.commands_tx.try_send(Command::RestartAll);
517 }
518}
519
520fn key_event_to_bytes(
521 code: crossterm::event::KeyCode,
522 modifiers: crossterm::event::KeyModifiers,
523) -> Option<Vec<u8>> {
524 use crossterm::event::KeyCode;
525
526 if modifiers.contains(crossterm::event::KeyModifiers::ALT)
527 && !modifiers.contains(crossterm::event::KeyModifiers::CONTROL)
528 && !matches!(code, KeyCode::Esc)
529 {
530 let base_modifiers = modifiers - crossterm::event::KeyModifiers::ALT;
531 if let Some(mut bytes) = key_event_to_bytes(code, base_modifiers) {
532 let mut out = Vec::with_capacity(1 + bytes.len());
533 out.push(0x1b);
534 out.append(&mut bytes);
535 return Some(out);
536 }
537 }
538
539 if modifiers.contains(crossterm::event::KeyModifiers::CONTROL) {
540 match code {
541 KeyCode::Char(c) => {
542 let c = c.to_ascii_lowercase();
543 if c.is_ascii_lowercase() {
544 return Some(vec![(c as u8) - b'a' + 1]);
545 }
546 match c {
547 '@' => return Some(vec![0x00]),
548 '[' => return Some(vec![0x1b]),
549 '\\' => return Some(vec![0x1c]),
550 ']' => return Some(vec![0x1d]),
551 '^' => return Some(vec![0x1e]),
552 '_' => return Some(vec![0x1f]),
553 _ => {}
554 }
555 }
556 KeyCode::Enter => return Some(vec![b'\n']),
557 _ => {}
558 }
559 }
560
561 match code {
562 KeyCode::Esc => Some(vec![0x1b]),
563 KeyCode::Enter => Some(vec![b'\r']),
564 KeyCode::Tab => Some(vec![b'\t']),
565 KeyCode::BackTab => Some(b"\x1b[Z".to_vec()),
566 KeyCode::Backspace => Some(vec![0x7f]),
567 KeyCode::Char(c) => Some(c.to_string().into_bytes()),
568 KeyCode::Up => Some(b"\x1b[A".to_vec()),
569 KeyCode::Down => Some(b"\x1b[B".to_vec()),
570 KeyCode::Right => Some(b"\x1b[C".to_vec()),
571 KeyCode::Left => Some(b"\x1b[D".to_vec()),
572 KeyCode::Home => Some(b"\x1b[H".to_vec()),
573 KeyCode::End => Some(b"\x1b[F".to_vec()),
574 KeyCode::PageUp => Some(b"\x1b[5~".to_vec()),
575 KeyCode::PageDown => Some(b"\x1b[6~".to_vec()),
576 KeyCode::Delete => Some(b"\x1b[3~".to_vec()),
577 _ => None,
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584 use codespan_reporting::diagnostic::Diagnostic;
585 use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
586 use indoc::indoc;
587 use std::path::Path;
588 use tokio::sync::mpsc;
589
590 fn tab_press() -> crate::event::Input {
591 crate::event::Input::Event(crossterm::event::Event::Key(KeyEvent {
592 code: KeyCode::Tab,
593 modifiers: KeyModifiers::NONE,
594 kind: KeyEventKind::Press,
595 state: KeyEventState::NONE,
596 }))
597 }
598
599 #[test]
600 fn ctrl_letters_map_to_ascii_control_codes() {
601 assert_eq!(
602 key_event_to_bytes(KeyCode::Char('a'), KeyModifiers::CONTROL),
603 Some(vec![0x01])
604 );
605 assert_eq!(
606 key_event_to_bytes(KeyCode::Char('z'), KeyModifiers::CONTROL),
607 Some(vec![0x1a])
608 );
609 }
610
611 #[test]
612 fn ctrl_specials_map_to_standard_codes() {
613 assert_eq!(
614 key_event_to_bytes(KeyCode::Char('@'), KeyModifiers::CONTROL),
615 Some(vec![0x00])
616 );
617 assert_eq!(
618 key_event_to_bytes(KeyCode::Char('['), KeyModifiers::CONTROL),
619 Some(vec![0x1b])
620 );
621 assert_eq!(
622 key_event_to_bytes(KeyCode::Char('\\'), KeyModifiers::CONTROL),
623 Some(vec![0x1c])
624 );
625 assert_eq!(
626 key_event_to_bytes(KeyCode::Char(']'), KeyModifiers::CONTROL),
627 Some(vec![0x1d])
628 );
629 assert_eq!(
630 key_event_to_bytes(KeyCode::Char('^'), KeyModifiers::CONTROL),
631 Some(vec![0x1e])
632 );
633 assert_eq!(
634 key_event_to_bytes(KeyCode::Char('_'), KeyModifiers::CONTROL),
635 Some(vec![0x1f])
636 );
637 }
638
639 #[test]
640 fn special_keys_encode_as_expected() {
641 assert_eq!(
642 key_event_to_bytes(KeyCode::Esc, KeyModifiers::NONE),
643 Some(vec![0x1b])
644 );
645 assert_eq!(
646 key_event_to_bytes(KeyCode::Enter, KeyModifiers::NONE),
647 Some(vec![b'\r'])
648 );
649 assert_eq!(
650 key_event_to_bytes(KeyCode::Enter, KeyModifiers::CONTROL),
651 Some(vec![b'\n'])
652 );
653 assert_eq!(
654 key_event_to_bytes(KeyCode::Tab, KeyModifiers::NONE),
655 Some(vec![b'\t'])
656 );
657 assert_eq!(
658 key_event_to_bytes(KeyCode::BackTab, KeyModifiers::NONE),
659 Some(b"\x1b[Z".to_vec())
660 );
661 assert_eq!(
662 key_event_to_bytes(KeyCode::Backspace, KeyModifiers::NONE),
663 Some(vec![0x7f])
664 );
665 assert_eq!(
666 key_event_to_bytes(KeyCode::Up, KeyModifiers::NONE),
667 Some(b"\x1b[A".to_vec())
668 );
669 assert_eq!(
670 key_event_to_bytes(KeyCode::Delete, KeyModifiers::NONE),
671 Some(b"\x1b[3~".to_vec())
672 );
673 assert_eq!(
674 key_event_to_bytes(KeyCode::Home, KeyModifiers::NONE),
675 Some(b"\x1b[H".to_vec())
676 );
677 assert_eq!(
678 key_event_to_bytes(KeyCode::End, KeyModifiers::NONE),
679 Some(b"\x1b[F".to_vec())
680 );
681 }
682
683 #[test]
684 fn alt_char_is_esc_prefixed() {
685 assert_eq!(
686 key_event_to_bytes(KeyCode::Char('x'), KeyModifiers::ALT),
687 Some(vec![0x1b, b'x'])
688 );
689 }
690
691 #[tokio::test]
692 async fn tab_cycles_focus_with_and_without_healthcheck_pane() -> color_eyre::eyre::Result<()> {
693 let yaml = indoc! {r#"
694 version: 1
695 services:
696 svc:
697 command: ["sh", "-c", "true"]
698 "#};
699 let mut diagnostics: Vec<Diagnostic<usize>> = vec![];
700 let parsed = micromux::from_str(yaml, Path::new("."), 0usize, None, &mut diagnostics)
701 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
702 let mux = micromux::Micromux::new(&parsed)
703 .map_err(|err| color_eyre::eyre::eyre!(err.to_string()))?;
704 let services = mux.services();
705
706 let (_ui_tx, ui_rx) = mpsc::channel(1);
707 let (cmd_tx, _cmd_rx) = mpsc::channel(1);
708 let shutdown = micromux::CancellationToken::new();
709
710 let mut app = App::new(&services, ui_rx, cmd_tx, shutdown);
711 app.focus = Focus::Services;
712 app.show_healthcheck_pane = false;
713
714 app.handle_input_event(tab_press());
715 assert_eq!(app.focus, Focus::Logs);
716 app.handle_input_event(tab_press());
717 assert_eq!(app.focus, Focus::Services);
718
719 app.show_healthcheck_pane = true;
720 app.focus = Focus::Services;
721 app.handle_input_event(tab_press());
722 assert_eq!(app.focus, Focus::Logs);
723 app.handle_input_event(tab_press());
724 assert_eq!(app.focus, Focus::Healthcheck);
725 app.handle_input_event(tab_press());
726 assert_eq!(app.focus, Focus::Services);
727
728 Ok(())
729 }
730}