diffler 0.11.1

Terminal code review for AI coding agents
Documentation
//! The CI runs start page: a hint line, the list of recent runs for the repo's
//! provider, and the shared status bar. Selecting a run opens its graph.

use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;

use crate::app::App;
use crate::keymap::Action;
use crate::ui::Hint;

const HINTS: &[Hint] = &[
    Hint::Leaf(&[Action::Open], "open graph"),
    Hint::Leaf(&[Action::Search], "search"),
    Hint::Leaf(&[Action::Help], "help"),
];

pub(super) fn draw(frame: &mut Frame<'_>, app: &mut App) {
    let (body, bar) = super::screen_chrome(frame, app, HINTS);
    draw_list(frame, app, body);
    frame.render_widget(Paragraph::new(super::status_bar(app, bar.width)), bar);
}

/// Ranges within `[start, start+len)` of the search-row text, rebased to the
/// segment, so each display column highlights its own slice of the match.
fn clip_ranges(
    ranges: &[(std::ops::Range<usize>, bool)],
    start: usize,
    len: usize,
) -> Vec<(std::ops::Range<usize>, bool)> {
    ranges
        .iter()
        .filter_map(|(r, current)| {
            let s = r.start.max(start);
            let e = r.end.min(start + len);
            (s < e).then(|| (s - start..e - start, *current))
        })
        .collect()
}

fn draw_list(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
    if app.runs.is_empty() {
        frame.render_widget(
            Paragraph::new(Line::styled("  no runs yet…", app.theme.dim_style())),
            area,
        );
        return;
    }
    let height = area.height.max(1) as usize;
    let scroll =
        super::scroll_to_cursor(app.runs_selected(), app.runs_scroll, height, app.runs.len());
    app.runs_scroll = scroll;
    let rows: Vec<Line<'static>> = app
        .runs
        .iter()
        .enumerate()
        .skip(scroll)
        .take(height)
        .map(|(i, run)| {
            let selected = i == app.runs_selected();
            let glyph = run.status.glyph();
            let color = super::ci_status_color(&app.theme, run.status);
            let short = run.commit.chars().take(7).collect::<String>();
            // search rows are "{name} {title}": rebase the match ranges onto
            // the two display segments so `/` highlights like other screens
            let ranges = app
                .search
                .as_ref()
                .map(|s| s.ranges_for(i))
                .unwrap_or_default();
            let name_txt = format!("{:<16}", super::elide(&run.name, 16));
            let title_txt = format!("{:<32}", super::elide(&run.title, 32));
            let mut spans = vec![
                Span::raw("  "),
                Span::styled(format!("{glyph} "), Style::new().fg(color)),
            ];
            spans.extend(super::highlight_spans(
                &name_txt,
                Style::new().fg(app.theme.accent),
                &clip_ranges(&ranges, 0, run.name.chars().count()),
                &app.theme,
            ));
            spans.push(Span::raw("  "));
            spans.extend(super::highlight_spans(
                &title_txt,
                Style::new().fg(app.theme.fg),
                &clip_ranges(
                    &ranges,
                    run.name.chars().count() + 1,
                    run.title.chars().count(),
                ),
                &app.theme,
            ));
            spans.extend([
                Span::styled(
                    format!("  {:<14}", super::elide(&run.branch, 14)),
                    Style::new().fg(app.theme.purple),
                ),
                Span::styled(format!("  {short}"), Style::new().fg(app.theme.warn_fg)),
            ]);
            if let Some(created) = run.created {
                let age = super::relative_time(app.now_unix, created.unix_timestamp());
                let used: usize = spans.iter().map(ratatui::text::Span::width).sum();
                if used + age.chars().count() + 1 < area.width as usize {
                    let gap = area.width as usize - used - age.chars().count() - 1;
                    spans.push(Span::raw(" ".repeat(gap)));
                    spans.push(Span::styled(age, app.theme.dim_style()));
                }
            }
            let line = Line::from(spans);
            if selected {
                super::cursor_line(line, &app.theme, area.width)
            } else {
                line
            }
        })
        .collect();
    frame.render_widget(Paragraph::new(rows), area);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ci::{CiRun, JobStatus, RunId};
    use ratatui::Terminal;
    use ratatui::backend::TestBackend;

    use crate::config::LoadedConfig;
    use crate::test_support::standard_fixture;

    fn run(name: &str, branch: &str, sha: &str, status: JobStatus) -> CiRun {
        CiRun {
            id: RunId(name.to_owned()),
            name: name.to_owned(),
            title: "fix the thing".to_owned(),
            branch: branch.to_owned(),
            commit: sha.to_owned(),
            author: String::new(),
            created: None,
            status,
            url: None,
            remote: None,
        }
    }

    #[test]
    fn renders_the_runs_list_with_a_selection() {
        let fixture = standard_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.runs = vec![
            run("CI", "main", "abc1234def", JobStatus::Ok),
            run("Release", "feature/x", "9988776655", JobStatus::Failed),
            run("Nightly", "main", "0011223344", JobStatus::Running),
        ];
        let backend = TestBackend::new(80, 8);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal.draw(|f| draw(f, &mut app)).expect("draw");
        insta::assert_snapshot!(terminal.backend());
    }

    #[test]
    fn the_selected_run_carries_the_cursor_band_across_the_row() {
        let fixture = standard_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        app.runs = vec![
            run("CI", "main", "abc1234def", JobStatus::Ok),
            run("Release", "feature/x", "9988776655", JobStatus::Failed),
        ];
        let backend = TestBackend::new(80, 8);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal.draw(|f| draw(f, &mut app)).expect("draw");
        let buffer = terminal.backend().buffer();
        let banded = |y: u16| {
            (0..80).all(|x| buffer.cell((x, y)).expect("in bounds").bg == app.theme.cursor_line)
        };
        assert!(banded(1), "the selected row bands edge to edge");
        assert!(!banded(2), "its neighbour keeps the plain surface");
    }

    #[test]
    fn renders_empty_runs() {
        let fixture = standard_fixture();
        let mut app = App::new(fixture.review(), LoadedConfig::default());
        let backend = TestBackend::new(80, 6);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal.draw(|f| draw(f, &mut app)).expect("draw");
        insta::assert_snapshot!(terminal.backend());
    }
}