1use std::collections::BTreeMap;
4
5use ansi_to_tui::IntoText;
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::prelude::{Alignment, Color, Line, Modifier, Span, Style, Text};
9use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap};
10use time::OffsetDateTime;
11
12use cargowatch_core::{
13 AppConfig, DetectedProcess, LogFilter, SessionEvent, SessionHistoryEntry, SessionInfo,
14 SessionMode, SessionSelection, SessionState, SessionStatus, SummaryCounts,
15};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum UiAction {
20 Quit,
22 StartManagedCommand(String),
24 CancelSession(String),
26 LoadSession(String),
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum FocusPane {
32 Sessions,
33 Main,
34 Diagnostics,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38enum CenterView {
39 Logs,
40 Diagnostics,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44enum InputOverlay {
45 Search { buffer: String },
46 Command { buffer: String },
47}
48
49pub struct Dashboard {
51 config: AppConfig,
52 active_sessions: BTreeMap<String, SessionState>,
53 history_sessions: BTreeMap<String, SessionState>,
54 history: Vec<SessionHistoryEntry>,
55 selection: usize,
56 focus: FocusPane,
57 center_view: CenterView,
58 overlay: Option<InputOverlay>,
59 filter: LogFilter,
60 follow: bool,
61 show_raw: bool,
62 log_scroll: u16,
63 diagnostic_scroll: u16,
64 status_message: String,
65}
66
67impl Dashboard {
68 pub fn new(config: AppConfig) -> Self {
70 let follow = config.auto_follow_running_session;
71 Self {
72 config,
73 active_sessions: BTreeMap::new(),
74 history_sessions: BTreeMap::new(),
75 history: Vec::new(),
76 selection: 0,
77 focus: FocusPane::Sessions,
78 center_view: CenterView::Logs,
79 overlay: None,
80 filter: LogFilter::default(),
81 follow,
82 show_raw: false,
83 log_scroll: 0,
84 diagnostic_scroll: 0,
85 status_message: "Press `n` for a managed run with logs, diagnostics, and artifacts. Detected external builds are summary-only.".to_string()
86 }
87 }
88
89 pub fn set_history(&mut self, history: Vec<SessionHistoryEntry>) {
91 self.history = history;
92 if self.selection >= self.left_items().len() {
93 self.selection = self.left_items().len().saturating_sub(1);
94 }
95 }
96
97 pub fn insert_history_session(&mut self, session: SessionState) {
99 self.history_sessions
100 .insert(session.info.session_id.clone(), session);
101 }
102
103 pub fn apply_event(&mut self, event: &SessionEvent, max_logs: usize) {
105 match event {
106 SessionEvent::SessionStarted(info) => {
107 self.active_sessions
108 .entry(info.session_id.clone())
109 .or_insert_with(|| SessionState::new(info.clone(), max_logs));
110 }
111 SessionEvent::ProcessDetected(process) => {
112 let info = process_to_session_info(process);
113 self.active_sessions
114 .entry(info.session_id.clone())
115 .or_insert_with(|| SessionState::new(info, max_logs));
116 self.status_message = format!(
117 "Detected {} on pid {}. Logs, diagnostics, and artifacts require managed mode.",
118 process.classification.label(),
119 process.pid
120 );
121 }
122 SessionEvent::ProcessUpdated(process) => {
123 self.active_sessions
124 .entry(process.session_id.clone())
125 .or_insert_with(|| {
126 SessionState::new(process_to_session_info(process), max_logs)
127 })
128 .apply(event);
129 }
130 SessionEvent::ProcessGone { session_id, .. } => {
131 if let Some(session) = self.active_sessions.get_mut(session_id) {
132 session.apply(event);
133 }
134 }
135 SessionEvent::OutputLine { session_id, .. }
136 | SessionEvent::Diagnostic { session_id, .. }
137 | SessionEvent::ArtifactBuilt { session_id, .. }
138 | SessionEvent::SessionFinished(cargowatch_core::SessionFinished {
139 session_id, ..
140 }) => {
141 if let Some(session) = self.active_sessions.get_mut(session_id) {
142 session.apply(event);
143 }
144 }
145 }
146 }
147
148 pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Option<UiAction> {
150 use crossterm::event::{KeyCode, KeyModifiers};
151
152 if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
153 return self
154 .selected_session()
155 .filter(|session| session.info.mode == SessionMode::Managed && session.is_running())
156 .map(|session| UiAction::CancelSession(session.info.session_id.clone()))
157 .or(Some(UiAction::Quit));
158 }
159
160 if let Some(overlay) = &mut self.overlay {
161 match key.code {
162 KeyCode::Esc => {
163 self.overlay = None;
164 self.status_message = "Overlay dismissed.".to_string();
165 }
166 KeyCode::Enter => {
167 let action = match overlay {
168 InputOverlay::Search { buffer } => {
169 self.filter.search =
170 (!buffer.trim().is_empty()).then(|| buffer.trim().to_string());
171 self.status_message = format!(
172 "Search {}",
173 self.filter
174 .search
175 .as_deref()
176 .map(|query| format!("set to `{query}`"))
177 .unwrap_or_else(|| "cleared".to_string())
178 );
179 None
180 }
181 InputOverlay::Command { buffer } => {
182 let command = buffer.trim().to_string();
183 if command.is_empty() {
184 self.status_message =
185 "Enter a command after `n` to start a managed session."
186 .to_string();
187 None
188 } else {
189 self.status_message = format!("Launching `{command}`...");
190 Some(UiAction::StartManagedCommand(command))
191 }
192 }
193 };
194 self.overlay = None;
195 return action;
196 }
197 KeyCode::Backspace => match overlay {
198 InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
199 buffer.pop();
200 }
201 },
202 KeyCode::Char(ch) => match overlay {
203 InputOverlay::Search { buffer } | InputOverlay::Command { buffer } => {
204 buffer.push(ch);
205 }
206 },
207 _ => {}
208 }
209 return None;
210 }
211
212 match key.code {
213 KeyCode::Char('q') => return Some(UiAction::Quit),
214 KeyCode::Tab => self.focus = self.focus.next(),
215 KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
216 KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
217 KeyCode::PageUp => self.scroll_active_pane(-8),
218 KeyCode::PageDown => self.scroll_active_pane(8),
219 KeyCode::Char('f') => {
220 self.follow = !self.follow;
221 self.status_message = if self.follow {
222 "Follow mode enabled.".to_string()
223 } else {
224 "Follow mode disabled.".to_string()
225 };
226 }
227 KeyCode::Char('r') => {
228 self.show_raw = !self.show_raw;
229 self.status_message = if self.show_raw {
230 "Showing raw output.".to_string()
231 } else {
232 "Showing rendered output.".to_string()
233 };
234 }
235 KeyCode::Char('v') => {
236 self.center_view = match self.center_view {
237 CenterView::Logs => CenterView::Diagnostics,
238 CenterView::Diagnostics => CenterView::Logs,
239 };
240 }
241 KeyCode::Char('/') => {
242 self.overlay = Some(InputOverlay::Search {
243 buffer: self.filter.search.clone().unwrap_or_default(),
244 });
245 }
246 KeyCode::Char('n') => {
247 self.overlay = Some(InputOverlay::Command {
248 buffer: "cargo check".to_string(),
249 });
250 }
251 KeyCode::Char('0') => self.filter = LogFilter::default(),
252 KeyCode::Char('1') => {
253 self.filter = LogFilter::only(cargowatch_core::event::Severity::Error)
254 }
255 KeyCode::Char('2') => {
256 self.filter = LogFilter::only(cargowatch_core::event::Severity::Warning)
257 }
258 KeyCode::Char('3') => {
259 self.filter = LogFilter::only(cargowatch_core::event::Severity::Note)
260 }
261 KeyCode::Char('4') => {
262 self.filter = LogFilter::only(cargowatch_core::event::Severity::Help)
263 }
264 KeyCode::Char('5') => {
265 self.filter = LogFilter::only(cargowatch_core::event::Severity::Info)
266 }
267 KeyCode::Enter => {
268 if let Some(session_id) = self.selected_history_id_to_load() {
269 return Some(UiAction::LoadSession(session_id));
270 }
271 }
272 KeyCode::Char('c') => {
273 return self
274 .selected_session()
275 .filter(|session| {
276 session.info.mode == SessionMode::Managed && session.is_running()
277 })
278 .map(|session| UiAction::CancelSession(session.info.session_id.clone()));
279 }
280 _ => {}
281 }
282 None
283 }
284
285 pub fn render(&self, frame: &mut Frame) {
287 let root = frame.area();
288 let rows = Layout::default()
289 .direction(Direction::Vertical)
290 .constraints([
291 Constraint::Length(3),
292 Constraint::Min(12),
293 Constraint::Length(2),
294 ])
295 .split(root);
296 let body = Layout::default()
297 .direction(Direction::Horizontal)
298 .constraints([
299 Constraint::Percentage(24),
300 Constraint::Percentage(51),
301 Constraint::Percentage(25),
302 ])
303 .split(rows[1]);
304
305 frame.render_widget(self.render_status_bar(), rows[0]);
306 let mut session_state = ListState::default();
307 session_state.select(Some(self.selection));
308 frame.render_stateful_widget(self.render_session_list(), body[0], &mut session_state);
309 frame.render_widget(self.render_center_pane(), body[1]);
310 frame.render_widget(self.render_diagnostics_pane(), body[2]);
311 frame.render_widget(self.render_footer(), rows[2]);
312
313 if let Some(overlay) = &self.overlay {
314 let popup = centered_rect(60, 5, root);
315 frame.render_widget(Clear, popup);
316 frame.render_widget(self.render_overlay(overlay), popup);
317 }
318 }
319
320 fn selected_history_id_to_load(&self) -> Option<String> {
321 match self.left_items().get(self.selection) {
322 Some(SessionSelection::History(session_id))
323 if !self.history_sessions.contains_key(session_id) =>
324 {
325 Some(session_id.clone())
326 }
327 _ => None,
328 }
329 }
330
331 fn selected_session(&self) -> Option<&SessionState> {
332 match self.left_items().get(self.selection) {
333 Some(SessionSelection::Active(session_id)) => self.active_sessions.get(session_id),
334 Some(SessionSelection::History(session_id)) => self.history_sessions.get(session_id),
335 None => None,
336 }
337 }
338
339 fn left_items(&self) -> Vec<SessionSelection> {
340 let mut active = self
341 .active_sessions
342 .values()
343 .map(|session| session.history_entry())
344 .collect::<Vec<_>>();
345 active.sort_by(|left, right| right.info.started_at.cmp(&left.info.started_at));
346
347 let mut items = active
348 .into_iter()
349 .map(|entry| SessionSelection::Active(entry.info.session_id))
350 .collect::<Vec<_>>();
351 for entry in &self.history {
352 if !self.active_sessions.contains_key(&entry.info.session_id) {
353 items.push(SessionSelection::History(entry.info.session_id.clone()));
354 }
355 }
356 items
357 }
358
359 fn move_selection(&mut self, delta: isize) {
360 match self.focus {
361 FocusPane::Sessions => {
362 let len = self.left_items().len();
363 if len == 0 {
364 self.selection = 0;
365 return;
366 }
367 let current = self.selection as isize;
368 self.selection = (current + delta).clamp(0, (len - 1) as isize) as usize;
369 }
370 FocusPane::Main => {
371 self.log_scroll = self.log_scroll.saturating_add_signed(-delta as i16)
372 }
373 FocusPane::Diagnostics => {
374 self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(-delta as i16)
375 }
376 }
377 }
378
379 fn scroll_active_pane(&mut self, delta: i16) {
380 match self.focus {
381 FocusPane::Sessions => self.move_selection(delta.signum() as isize),
382 FocusPane::Main => self.log_scroll = self.log_scroll.saturating_add_signed(delta),
383 FocusPane::Diagnostics => {
384 self.diagnostic_scroll = self.diagnostic_scroll.saturating_add_signed(delta)
385 }
386 }
387 }
388
389 fn render_status_bar(&self) -> Paragraph<'static> {
390 let selected = self.selected_session();
391 let title = selected
392 .map(|session| session.command_line())
393 .unwrap_or_else(|| "No session selected".to_string());
394 let workspace = selected
395 .map(|session| session.workspace_label())
396 .unwrap_or_else(|| "Waiting for a managed run or detected process".to_string());
397 let status = selected
398 .map(|session| format_status(session.info.status, session.duration_ms))
399 .unwrap_or_else(|| "idle".to_string());
400 let mode = selected
401 .map(|session| match session.info.mode {
402 SessionMode::Managed => "managed",
403 SessionMode::Detected => "detected",
404 })
405 .unwrap_or("monitor");
406
407 let lines = Text::from(vec![
408 Line::from(vec![
409 Span::styled(
410 " CargoWatch ",
411 Style::default()
412 .bg(self.color(&self.config.theme.accent))
413 .fg(Color::Black)
414 .add_modifier(Modifier::BOLD),
415 ),
416 Span::raw(" "),
417 Span::styled(
418 mode,
419 Style::default().fg(self.color(&self.config.theme.info)),
420 ),
421 Span::raw(" "),
422 Span::raw(title),
423 ]),
424 Line::from(vec![
425 Span::styled(
426 "workspace ",
427 Style::default().fg(self.color(&self.config.theme.muted)),
428 ),
429 Span::raw(workspace),
430 Span::raw(" "),
431 Span::styled(
432 "status ",
433 Style::default().fg(self.color(&self.config.theme.muted)),
434 ),
435 Span::raw(status),
436 ]),
437 ]);
438 Paragraph::new(lines).block(Block::default().borders(Borders::ALL))
439 }
440
441 fn render_session_list(&self) -> List<'static> {
442 let items = self
443 .left_items()
444 .into_iter()
445 .map(|selection| {
446 let history = match selection {
447 SessionSelection::Active(id) => self
448 .active_sessions
449 .get(&id)
450 .map(SessionState::history_entry),
451 SessionSelection::History(id) => self
452 .history_sessions
453 .get(&id)
454 .map(SessionState::history_entry)
455 .or_else(|| {
456 self.history
457 .iter()
458 .find(|entry| entry.info.session_id == id)
459 .cloned()
460 }),
461 };
462 history.unwrap_or_else(empty_history_item)
463 })
464 .map(|entry| {
465 let status_style = style_for_status(&self.config, entry.info.status);
466 let title = entry.info.title.clone();
467 let command_line = entry.command_line();
468 ListItem::new(vec![
469 Line::from(vec![
470 Span::styled(
471 format_status(entry.info.status, entry.duration_ms),
472 status_style,
473 ),
474 Span::raw(" "),
475 Span::styled(title, Style::default().add_modifier(Modifier::BOLD)),
476 ]),
477 Line::from(vec![Span::styled(
478 command_line,
479 Style::default().fg(self.color(&self.config.theme.muted)),
480 )]),
481 Line::from(vec![Span::raw(format!(
482 "e:{} w:{} n:{} h:{}",
483 entry.summary.errors,
484 entry.summary.warnings,
485 entry.summary.notes,
486 entry.summary.help
487 ))]),
488 ])
489 })
490 .collect::<Vec<_>>();
491
492 let block = titled_block(
493 self.focus == FocusPane::Sessions,
494 "Sessions / History",
495 self.color(&self.config.theme.accent),
496 );
497 List::new(items)
498 .block(block)
499 .highlight_style(Style::default().bg(Color::DarkGray))
500 .highlight_symbol(">> ")
501 }
502
503 fn render_center_pane(&self) -> Paragraph<'static> {
504 let title = match self.center_view {
505 CenterView::Logs => {
506 if self.show_raw {
507 "Live Logs (raw)"
508 } else {
509 "Live Logs (rendered)"
510 }
511 }
512 CenterView::Diagnostics => "Structured Diagnostics",
513 };
514 let text = match self.center_view {
515 CenterView::Logs => self.render_logs_text(),
516 CenterView::Diagnostics => self.render_diagnostic_text(true),
517 };
518
519 Paragraph::new(text)
520 .block(titled_block(
521 self.focus == FocusPane::Main,
522 title,
523 self.color(&self.config.theme.accent),
524 ))
525 .wrap(Wrap { trim: false })
526 .scroll((self.current_log_scroll(), 0))
527 }
528
529 fn render_diagnostics_pane(&self) -> Paragraph<'static> {
530 let mut lines = Vec::new();
531 if let Some(session) = self.selected_session() {
532 lines.extend(render_summary_lines(&self.config, session.summary));
533 lines.push(Line::default());
534
535 let diagnostics = session
536 .diagnostics
537 .iter()
538 .filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
539 .take(64)
540 .collect::<Vec<_>>();
541 if diagnostics.is_empty() {
542 lines.push(Line::raw("No diagnostics match the current filter."));
543 } else {
544 for diagnostic in diagnostics {
545 let location = diagnostic
546 .file
547 .as_ref()
548 .map(|file| {
549 format!("{}:{}", file.display(), diagnostic.line.unwrap_or_default())
550 })
551 .unwrap_or_else(|| "unknown location".to_string());
552 lines.push(Line::from(vec![
553 Span::styled(
554 format!("[{}] ", diagnostic.severity_label()),
555 style_for_severity(&self.config, diagnostic.severity),
556 ),
557 Span::raw(diagnostic.message.clone()),
558 ]));
559 lines.push(Line::from(vec![Span::styled(
560 location,
561 Style::default().fg(self.color(&self.config.theme.muted)),
562 )]));
563 lines.push(Line::default());
564 }
565 }
566 } else {
567 lines.push(Line::raw(
568 "Waiting for a selected session. Use `n` for a managed run or leave the dashboard open for detected external build summaries.",
569 ));
570 }
571
572 Paragraph::new(Text::from(lines))
573 .block(titled_block(
574 self.focus == FocusPane::Diagnostics,
575 "Diagnostics",
576 self.color(&self.config.theme.accent),
577 ))
578 .wrap(Wrap { trim: false })
579 .scroll((self.diagnostic_scroll, 0))
580 }
581
582 fn render_footer(&self) -> Paragraph<'static> {
583 let filter = if self.filter == LogFilter::default() {
584 "all".to_string()
585 } else {
586 format!(
587 "e:{} w:{} n:{} h:{} i:{}",
588 self.filter.errors as u8,
589 self.filter.warnings as u8,
590 self.filter.notes as u8,
591 self.filter.help as u8,
592 self.filter.info as u8
593 )
594 };
595 let footer = Line::from(vec![
596 Span::raw("Tab panes "),
597 Span::raw("j/k move "),
598 Span::raw("PgUp/PgDn scroll "),
599 Span::raw("n new run "),
600 Span::raw("c cancel "),
601 Span::raw("/ search "),
602 Span::raw("r raw/rendered "),
603 Span::raw("v log/diag "),
604 Span::raw("0-5 filter "),
605 Span::raw("q quit "),
606 Span::styled(
607 format!(
608 "filter:{filter} follow:{}",
609 if self.follow { "on" } else { "off" }
610 ),
611 Style::default().fg(self.color(&self.config.theme.info)),
612 ),
613 ]);
614 Paragraph::new(Text::from(vec![
615 footer,
616 Line::from(Span::styled(
617 self.status_message.clone(),
618 Style::default().fg(self.color(&self.config.theme.muted)),
619 )),
620 ]))
621 .alignment(Alignment::Left)
622 }
623
624 fn render_overlay(&self, overlay: &InputOverlay) -> Paragraph<'static> {
625 let (title, buffer, hint) = match overlay {
626 InputOverlay::Search { buffer } => (
627 "Search Logs",
628 buffer.as_str(),
629 "Press Enter to apply, Esc to cancel.",
630 ),
631 InputOverlay::Command { buffer } => (
632 "Start Managed Run",
633 buffer.as_str(),
634 "Command is parsed shell-style. Example: cargo test -p my_crate",
635 ),
636 };
637 Paragraph::new(Text::from(vec![
638 Line::raw(buffer.to_string()),
639 Line::default(),
640 Line::styled(
641 hint,
642 Style::default().fg(self.color(&self.config.theme.muted)),
643 ),
644 ]))
645 .block(
646 Block::default()
647 .title(title)
648 .borders(Borders::ALL)
649 .border_style(Style::default().fg(self.color(&self.config.theme.accent))),
650 )
651 .wrap(Wrap { trim: false })
652 }
653
654 fn render_logs_text(&self) -> Text<'static> {
655 let Some(session) = self.selected_session() else {
656 return Text::from(vec![
657 Line::raw("No session selected."),
658 Line::raw(""),
659 Line::raw(
660 "Managed mode captures logs, diagnostics, and artifacts because CargoWatch launches the process.",
661 ),
662 Line::raw(
663 "Detected mode only shows best-effort summaries for already-running external builds.",
664 ),
665 ]);
666 };
667 let entries = session
668 .logs
669 .iter()
670 .filter(|entry| self.filter.matches_log(entry))
671 .collect::<Vec<_>>();
672 if entries.is_empty() {
673 return Text::from(vec![
674 Line::raw("No log lines match the current filter."),
675 Line::raw(""),
676 Line::raw("Tip: press `0` to reset filters or `/` to edit the search query."),
677 ]);
678 }
679
680 let mut lines = Vec::new();
681 for entry in entries {
682 let content = if self.show_raw {
683 entry.raw.as_deref().unwrap_or(&entry.text)
684 } else {
685 &entry.text
686 };
687 match content.to_string().into_text() {
688 Ok(text) => lines.extend(text.lines),
689 Err(_) => lines.push(Line::styled(
690 content.to_string(),
691 style_for_severity(
692 &self.config,
693 entry
694 .severity
695 .unwrap_or(cargowatch_core::event::Severity::Info),
696 ),
697 )),
698 }
699 lines.push(Line::default());
700 }
701 Text::from(lines)
702 }
703
704 fn render_diagnostic_text(&self, detailed: bool) -> Text<'static> {
705 let Some(session) = self.selected_session() else {
706 return Text::from("No session selected.");
707 };
708 let diagnostics = session
709 .diagnostics
710 .iter()
711 .filter(|diagnostic| self.filter.matches_diagnostic(diagnostic))
712 .collect::<Vec<_>>();
713 if diagnostics.is_empty() {
714 return Text::from("No diagnostics match the current filter.");
715 }
716
717 let mut lines = Vec::new();
718 for diagnostic in diagnostics {
719 let body = if detailed {
720 diagnostic
721 .rendered
722 .as_deref()
723 .unwrap_or(&diagnostic.message)
724 } else {
725 &diagnostic.message
726 };
727 match body.to_string().into_text() {
728 Ok(text) => lines.extend(text.lines),
729 Err(_) => {
730 lines.push(Line::styled(
731 body.to_string(),
732 style_for_severity(&self.config, diagnostic.severity),
733 ));
734 }
735 }
736 lines.push(Line::default());
737 }
738 Text::from(lines)
739 }
740
741 fn current_log_scroll(&self) -> u16 {
742 if self.follow { 65_535 } else { self.log_scroll }
743 }
744
745 fn color(&self, value: &str) -> Color {
746 parse_color(value).unwrap_or(Color::White)
747 }
748}
749
750impl FocusPane {
751 fn next(self) -> Self {
752 match self {
753 Self::Sessions => Self::Main,
754 Self::Main => Self::Diagnostics,
755 Self::Diagnostics => Self::Sessions,
756 }
757 }
758}
759
760trait DiagnosticSeverityLabel {
761 fn severity_label(&self) -> &'static str;
762}
763
764impl DiagnosticSeverityLabel for cargowatch_core::DiagnosticRecord {
765 fn severity_label(&self) -> &'static str {
766 match self.severity {
767 cargowatch_core::event::Severity::Error => "error",
768 cargowatch_core::event::Severity::Warning => "warning",
769 cargowatch_core::event::Severity::Note => "note",
770 cargowatch_core::event::Severity::Help => "help",
771 cargowatch_core::event::Severity::Info => "info",
772 cargowatch_core::event::Severity::Success => "success",
773 }
774 }
775}
776
777fn process_to_session_info(process: &DetectedProcess) -> SessionInfo {
778 SessionInfo {
779 session_id: process.session_id.clone(),
780 mode: SessionMode::Detected,
781 title: format!("{} ({})", process.classification.label(), process.pid),
782 command: process.command.clone(),
783 cwd: process.cwd.clone().unwrap_or_else(|| ".".into()),
784 workspace_root: process.workspace_root.clone(),
785 started_at: process.started_at,
786 status: SessionStatus::Running,
787 external_pid: Some(process.pid),
788 classification: Some(process.classification),
789 }
790}
791
792fn render_summary_lines(config: &AppConfig, summary: SummaryCounts) -> Vec<Line<'static>> {
793 vec![
794 Line::from(vec![
795 Span::styled(
796 format!("Errors: {}", summary.errors),
797 Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red)),
798 ),
799 Span::raw(" "),
800 Span::styled(
801 format!("Warnings: {}", summary.warnings),
802 Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow)),
803 ),
804 ]),
805 Line::from(vec![
806 Span::styled(
807 format!("Notes: {}", summary.notes),
808 Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue)),
809 ),
810 Span::raw(" "),
811 Span::styled(
812 format!("Help: {}", summary.help),
813 Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan)),
814 ),
815 ]),
816 ]
817}
818
819fn style_for_status(config: &AppConfig, status: SessionStatus) -> Style {
820 match status {
821 SessionStatus::Running => {
822 Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
823 }
824 SessionStatus::Succeeded => {
825 Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
826 }
827 SessionStatus::Failed => {
828 Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
829 }
830 SessionStatus::Cancelled => {
831 Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
832 }
833 SessionStatus::Lost => {
834 Style::default().fg(parse_color(&config.theme.muted).unwrap_or(Color::Gray))
835 }
836 }
837}
838
839fn style_for_severity(config: &AppConfig, severity: cargowatch_core::event::Severity) -> Style {
840 match severity {
841 cargowatch_core::event::Severity::Error => {
842 Style::default().fg(parse_color(&config.theme.error).unwrap_or(Color::Red))
843 }
844 cargowatch_core::event::Severity::Warning => {
845 Style::default().fg(parse_color(&config.theme.warning).unwrap_or(Color::Yellow))
846 }
847 cargowatch_core::event::Severity::Note => {
848 Style::default().fg(parse_color(&config.theme.info).unwrap_or(Color::Blue))
849 }
850 cargowatch_core::event::Severity::Help => {
851 Style::default().fg(parse_color(&config.theme.accent).unwrap_or(Color::Cyan))
852 }
853 cargowatch_core::event::Severity::Info => Style::default().fg(Color::White),
854 cargowatch_core::event::Severity::Success => {
855 Style::default().fg(parse_color(&config.theme.success).unwrap_or(Color::Green))
856 }
857 }
858}
859
860fn parse_color(value: &str) -> Option<Color> {
861 let hex = value.strip_prefix('#').unwrap_or(value);
862 if hex.len() != 6 {
863 return None;
864 }
865 let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
866 let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
867 let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
868 Some(Color::Rgb(red, green, blue))
869}
870
871fn titled_block(active: bool, title: &str, accent: Color) -> Block<'static> {
872 Block::default()
873 .title(if active {
874 Span::styled(
875 title.to_string(),
876 Style::default().fg(accent).add_modifier(Modifier::BOLD),
877 )
878 } else {
879 Span::raw(title.to_string())
880 })
881 .borders(Borders::ALL)
882}
883
884fn empty_history_item() -> SessionHistoryEntry {
885 SessionHistoryEntry {
886 info: SessionInfo {
887 session_id: "missing".to_string(),
888 mode: SessionMode::Managed,
889 title: "missing session".to_string(),
890 command: Vec::new(),
891 cwd: ".".into(),
892 workspace_root: None,
893 started_at: OffsetDateTime::UNIX_EPOCH,
894 status: SessionStatus::Lost,
895 external_pid: None,
896 classification: None,
897 },
898 finished_at: None,
899 exit_code: None,
900 duration_ms: None,
901 summary: SummaryCounts::default(),
902 }
903}
904
905fn format_status(status: SessionStatus, duration_ms: Option<i64>) -> String {
906 let base = match status {
907 SessionStatus::Running => "running",
908 SessionStatus::Succeeded => "ok",
909 SessionStatus::Failed => "failed",
910 SessionStatus::Cancelled => "cancelled",
911 SessionStatus::Lost => "gone",
912 };
913 match duration_ms {
914 Some(duration_ms) => format!("{base} {}s", duration_ms / 1_000),
915 None => base.to_string(),
916 }
917}
918
919fn centered_rect(width_percent: u16, height: u16, area: Rect) -> Rect {
920 let vertical = Layout::default()
921 .direction(Direction::Vertical)
922 .constraints([
923 Constraint::Min(1),
924 Constraint::Length(height),
925 Constraint::Min(1),
926 ])
927 .split(area);
928 Layout::default()
929 .direction(Direction::Horizontal)
930 .constraints([
931 Constraint::Percentage((100 - width_percent) / 2),
932 Constraint::Percentage(width_percent),
933 Constraint::Percentage((100 - width_percent) / 2),
934 ])
935 .split(vertical[1])[1]
936}