octorus 0.6.2

A TUI tool for GitHub PR review, designed for Helix editor users
Documentation
use ratatui::{
    layout::{Constraint, Direction, Layout, Margin},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap},
    Frame,
};

use crate::app::App;
use crate::diff::LineType;
use crate::ui::common::{build_ci_status_span, build_pr_info};

pub fn render(frame: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(1),
        ])
        .split(frame.area());

    render_header(frame, app, chunks[0]);

    render_body(frame, app, chunks[1]);

    render_footer(frame, app, chunks[2]);
}

fn render_header(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
    let pr_info = build_pr_info(app);
    let ci_span = build_ci_status_span(app);
    let header = Paragraph::new(Line::from(vec![
        Span::styled(
            "PR Description",
            Style::default().add_modifier(Modifier::BOLD),
        ),
        Span::raw(" - "),
        Span::styled(pr_info, Style::default().fg(Color::Cyan)),
        ci_span,
    ]))
    .block(Block::default().borders(Borders::ALL));
    frame.render_widget(header, area);
}

fn render_body(frame: &mut Frame, app: &mut App, area: ratatui::layout::Rect) {
    let content_height = area.height.saturating_sub(2) as usize;

    let Some(ref cache) = app.pr_description_cache else {
        let no_desc = Paragraph::new(Line::from(Span::styled(
            "  No description provided.",
            Style::default().fg(Color::DarkGray),
        )))
        .block(Block::default().borders(Borders::ALL).title("Description"));
        frame.render_widget(no_desc, area);
        return;
    };

    let lines: Vec<Line<'_>> = cache
        .lines
        .iter()
        .filter(|cached| cached.line_type != LineType::Header)
        .map(|cached| {
            let spans: Vec<Span<'_>> = cached
                .spans
                .iter()
                .enumerate()
                .map(|(i, s)| {
                    let text = cache.resolve(s.content);
                    if i == 0 && text.starts_with(' ') {
                        Span::styled(text[1..].to_string(), s.style)
                    } else {
                        Span::styled(text.to_string(), s.style)
                    }
                })
                .collect();
            Line::from(spans)
        })
        .collect();

    let total_lines = lines.len();
    let max_scroll = total_lines.saturating_sub(content_height);
    if app.pr_description_scroll_offset > max_scroll {
        app.pr_description_scroll_offset = max_scroll;
    }

    let scroll_info = if total_lines > content_height {
        format!(
            " ({}/{})",
            app.pr_description_scroll_offset + 1,
            max_scroll + 1
        )
    } else {
        String::new()
    };

    let body = Paragraph::new(lines)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .title(format!("Description{}", scroll_info)),
        )
        .wrap(Wrap { trim: false })
        .scroll((app.pr_description_scroll_offset as u16, 0));
    frame.render_widget(body, area);

    if total_lines > content_height {
        let mut scrollbar_state =
            ScrollbarState::new(max_scroll + 1).position(app.pr_description_scroll_offset);
        let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
            .begin_symbol(None)
            .end_symbol(None);
        frame.render_stateful_widget(
            scrollbar,
            area.inner(Margin {
                vertical: 1,
                horizontal: 0,
            }),
            &mut scrollbar_state,
        );
    }
}

fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
    let help_text = super::footer::footer_hint_back(&app.config.keybindings);
    let footer = Paragraph::new(Line::from(Span::styled(
        format!(" {}", help_text),
        Style::default().fg(Color::DarkGray),
    )));
    frame.render_widget(footer, area);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::App;
    use insta::assert_snapshot;
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    fn render_full(app: &mut App) -> String {
        let backend = TestBackend::new(100, 24);
        let mut terminal = Terminal::new(backend).unwrap();
        terminal
            .draw(|frame| {
                render(frame, app);
            })
            .unwrap();
        let buf = terminal.backend().buffer();
        let mut lines = Vec::new();
        for y in 0..24u16 {
            let mut line = String::new();
            for x in 0..100u16 {
                let cell = &buf[(x, y)];
                line.push_str(cell.symbol());
            }
            lines.push(line.trim_end().to_string());
        }
        lines.join("\n")
    }

    #[test]
    fn test_empty_no_description_cache() {
        let mut app = App::new_for_test();
        app.state = crate::app::AppState::PrDescription;
        assert_snapshot!(render_full(&mut app), @"
        ┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
        │PR Description - PR #1                                                                            │
        └──────────────────────────────────────────────────────────────────────────────────────────────────┘
        ┌Description───────────────────────────────────────────────────────────────────────────────────────┐
        │  No description provided.                                                                        │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        │                                                                                                  │
        └──────────────────────────────────────────────────────────────────────────────────────────────────┘
         ? Help | ! Shell | q/Esc Back
        ");
    }
}