use crate::ci::{AnnotationLevel, RunExtras};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use super::Hint;
use crate::app::App;
use crate::keymap::Action;
use crate::theme::Theme;
const MIN_BODY_FOR_PANEL: u16 = 9;
const GRAPH_HINTS: &[Hint] = &[
Hint::Leaf(
&[
Action::MoveLeft,
Action::MoveDown,
Action::MoveUp,
Action::MoveRight,
],
"move",
),
Hint::Leaf(&[Action::Open], "open/fold"),
Hint::Leaf(&[Action::Search], "search"),
Hint::Leaf(&[Action::Help], "help"),
];
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let (header, body, bar) = super::screen_chrome_with_header(frame, app, GRAPH_HINTS);
frame.render_widget(Paragraph::new(run_header(app, &app.theme)), header);
let (graph_area, panel) = carve_panel(app, body);
let gtheme = crate::graph::graph_theme(&app.theme);
let marks = app
.search
.as_ref()
.map(crate::search::Search::match_rows)
.unwrap_or_default();
let mut status = if let Some(graph) = app.graph.as_mut() {
graph.set_marks(&marks);
graph.render(graph_area, frame.buffer_mut(), >heme);
format!(" GRAPH zoom: {}", graph.zoom().label())
} else {
" GRAPH".to_owned()
};
if let Some(search) = &app.search {
use std::fmt::Write;
let (i, n) = search.count();
let count = if n == 0 {
"[no match]".to_owned()
} else {
format!("[{i}/{n}]")
};
let _ = write!(status, " /{} {count}", search.query());
}
if let Some((rect, lines)) = panel {
render_panel(frame, rect, lines, &app.theme);
}
let bar_style = Style::new().fg(app.theme.fg).bg(app.theme.panel);
frame.render_widget(
Paragraph::new(Line::styled(status, bar_style)).style(Style::new().bg(app.theme.panel)),
bar,
);
}
pub(crate) fn run_header(app: &App, theme: &Theme) -> Line<'static> {
let Some(run) = app.open_run_summary() else {
return Line::default();
};
let source = match &run.remote {
Some(remote) => format!(" {remote}/{}", run.name),
None => format!(" {}", run.name),
};
let commit: String = run.commit.chars().take(7).collect();
let mut spans = vec![Span::styled(source, Style::new().fg(theme.accent))];
spans.push(Span::styled(
format!(" {}", run.branch),
Style::new().fg(theme.fg),
));
spans.push(Span::styled(format!(" @ {commit}"), theme.dim_style()));
if !run.title.is_empty() {
spans.push(Span::styled(format!(" {}", run.title), theme.dim_style()));
}
Line::from(spans)
}
fn carve_panel(app: &App, body: Rect) -> (Rect, Option<(Rect, Vec<Line<'static>>)>) {
let lines = app
.extras
.as_ref()
.filter(|e| !e.artifacts.is_empty() || !e.annotations.is_empty())
.filter(|_| body.height >= MIN_BODY_FOR_PANEL)
.map(|extras| extras_lines(extras, &app.theme));
match lines {
Some(lines) => {
let want = u16::try_from(lines.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.min(body.height / 2);
let [graph_area, panel] =
Layout::vertical([Constraint::Min(0), Constraint::Length(want)]).areas(body);
(graph_area, Some((panel, lines)))
}
None => (body, None),
}
}
fn render_panel(frame: &mut Frame<'_>, rect: Rect, lines: Vec<Line<'static>>, theme: &Theme) {
let block = Block::new()
.borders(Borders::TOP)
.border_style(Style::new().fg(theme.border))
.title(Span::styled(" run extras ", theme.dim_style()));
let inner = block.inner(rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(lines).style(theme.base()), inner);
}
fn extras_lines(extras: &RunExtras, theme: &Theme) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !extras.artifacts.is_empty() {
lines.push(Line::styled("artifacts", theme.dim_style()));
for artifact in &extras.artifacts {
let mut spans = vec![
Span::styled(" ", theme.base()),
Span::styled(artifact.name.clone(), Style::new().fg(theme.fg)),
Span::styled(
format!(" {}", human_size(artifact.size_bytes)),
theme.dim_style(),
),
];
if artifact.expired {
spans.push(Span::styled(" (expired)", Style::new().fg(theme.dim)));
}
lines.push(Line::from(spans));
}
}
if !extras.annotations.is_empty() {
lines.push(Line::styled("annotations", theme.dim_style()));
for annotation in &extras.annotations {
let (glyph, color) = match annotation.level {
AnnotationLevel::Failure => ("×", theme.error_fg),
AnnotationLevel::Warning => ("!", theme.warn_fg),
AnnotationLevel::Notice => ("•", theme.accent),
};
let mut spans = vec![Span::styled(format!(" {glyph} "), Style::new().fg(color))];
let loc = annotation_location(annotation);
if !loc.is_empty() {
spans.push(Span::styled(format!("{loc} "), theme.dim_style()));
}
spans.push(Span::styled(
annotation_text(annotation),
Style::new().fg(theme.fg),
));
lines.push(Line::from(spans));
}
}
lines
}
fn annotation_location(annotation: &crate::ci::Annotation) -> String {
match (annotation.path.as_str(), annotation.start_line) {
("", _) => String::new(),
(path, Some(line)) => format!("{path}:{line}"),
(path, None) => path.to_owned(),
}
}
fn annotation_text(annotation: &crate::ci::Annotation) -> String {
let body = if annotation.message.is_empty() {
annotation.title.clone()
} else {
annotation.message.clone()
};
body.lines().next().unwrap_or_default().to_owned()
}
#[allow(clippy::cast_precision_loss)]
fn human_size(bytes: u64) -> String {
let mut size = bytes as f64;
let mut units = ["B", "KB", "MB", "GB"].into_iter();
let mut unit = units.next().unwrap_or("B");
while size >= 1024.0 {
let Some(next) = units.next() else { break };
size /= 1024.0;
unit = next;
}
if unit == "B" {
format!("{bytes} B")
} else {
format!("{size:.1} {unit}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ci::{Annotation, Artifact};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use crate::app::App;
use crate::config::LoadedConfig;
use crate::event::AppEvent;
use crate::test_support::standard_fixture;
fn graph_app_with_extras(extras: RunExtras) -> App {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.push_screen(crate::app::Screen::Graph);
app.graph = Some(crate::graph::GraphView::new());
app.handle(AppEvent::CiExtras(extras));
app
}
#[test]
fn human_size_scales_units() {
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(2048), "2.0 KB");
assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
}
#[test]
fn no_panel_without_extras_keeps_full_body() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.push_screen(crate::app::Screen::Graph);
app.graph = Some(crate::graph::GraphView::new());
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
let rendered = format!("{:?}", terminal.backend());
assert!(
!rendered.contains("run extras"),
"no extras → no panel carved from the graph body"
);
}
#[test]
fn renders_artifacts_and_annotations_panel() {
let extras = RunExtras {
artifacts: vec![Artifact {
name: "coverage".into(),
size_bytes: 2048,
expired: false,
}],
annotations: vec![Annotation {
level: AnnotationLevel::Warning,
title: "clippy".into(),
message: "unused import".into(),
path: "src/lib.rs".into(),
start_line: Some(12),
}],
};
let mut app = graph_app_with_extras(extras);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| draw(f, &mut app)).expect("draw");
insta::assert_snapshot!(terminal.backend());
}
}