use ag_forge::RequestedReview;
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table};
use crate::app::RequestedReviewState;
use crate::ui::state::help_action;
use crate::ui::util::{first_table_column_width, inline_text, truncate_spans_with_ellipsis};
use crate::ui::{Page, layout, style};
const TABLE_COLUMN_SPACING: u16 = 2;
const REQUESTED_REVIEW_DISPLAY_LIMIT: usize = 100;
pub struct ReviewListPage<'a> {
requested_reviews: &'a RequestedReviewState,
}
impl<'a> ReviewListPage<'a> {
pub fn new(requested_reviews: &'a RequestedReviewState) -> Self {
Self { requested_reviews }
}
}
impl Page for ReviewListPage<'_> {
fn render(&mut self, f: &mut Frame, area: Rect) {
let areas = layout::tab_page_areas(area);
let show_limit_hint = is_at_display_limit(self.requested_reviews);
match self.requested_reviews {
RequestedReviewState::Loaded { items, .. } if !items.is_empty() => {
render_review_table(f, areas.main_area, items);
}
RequestedReviewState::Loaded { .. } => {
render_message(
f,
areas.main_area,
"No PRs or MRs currently request your review.",
);
}
RequestedReviewState::Failed { message, .. } => {
render_message(
f,
areas.main_area,
&format!("Failed to load requested reviews: {message}"),
);
}
RequestedReviewState::Idle | RequestedReviewState::Loading { .. } => {
render_message(f, areas.main_area, "Loading requested reviews...");
}
}
let help = Paragraph::new(review_footer_line(show_limit_hint));
f.render_widget(help, areas.footer_area);
}
}
fn render_review_table(f: &mut Frame, area: Rect, items: &[RequestedReview]) {
let header_style = Style::default()
.bg(style::palette::surface())
.fg(style::palette::text_muted())
.add_modifier(Modifier::BOLD);
let header_cells = ["Review", "Repository", "Status", "Updated"]
.iter()
.map(|header| Cell::from(*header));
let header = Row::new(header_cells)
.style(header_style)
.height(1)
.bottom_margin(1);
let block = review_block();
let constraints = [
Constraint::Fill(1),
repository_column_width(items),
status_column_width(items),
Constraint::Length(12),
];
let title_column_width = first_table_column_width(
block.inner(area).width,
&constraints,
TABLE_COLUMN_SPACING,
0,
);
let rows = items
.iter()
.map(|item| review_row(item, title_column_width));
let table = Table::new(rows, constraints)
.column_spacing(TABLE_COLUMN_SPACING)
.header(header)
.block(block);
f.render_widget(table, area);
}
fn render_message(f: &mut Frame, area: Rect, message: &str) {
let paragraph = Paragraph::new(message.to_string())
.block(review_block())
.style(Style::default().fg(style::palette::text_muted()));
f.render_widget(paragraph, area);
}
fn is_at_display_limit(requested_reviews: &RequestedReviewState) -> bool {
matches!(
requested_reviews,
RequestedReviewState::Loaded { items, .. } if items.len() >= REQUESTED_REVIEW_DISPLAY_LIMIT
)
}
fn review_footer_line(show_limit_hint: bool) -> Line<'static> {
let mut line = help_action::footer_line(&help_action::review_actions());
line.spans.push(help_action::footer_separator_span());
line.spans
.push(help_action::footer_muted_span("read-only forge list"));
if show_limit_hint {
line.spans.push(help_action::footer_separator_span());
line.spans.push(help_action::footer_muted_span(format!(
"showing first {REQUESTED_REVIEW_DISPLAY_LIMIT}"
)));
}
line
}
fn review_block() -> Block<'static> {
Block::default()
.borders(Borders::ALL)
.title("Review Requests")
.border_style(style::border_style())
}
fn review_row(item: &RequestedReview, title_column_width: usize) -> Row<'static> {
let review_label = format!(
"{} {} {}",
item.forge_kind.review_request_short_name(),
item.display_id,
item.title
);
let review_spans = vec![Span::raw(inline_text(&review_label))];
let review_line = Line::from(truncate_spans_with_ellipsis(
review_spans,
title_column_width,
));
let status = item
.status_summary
.clone()
.unwrap_or_else(|| "Review requested".to_string());
Row::new(vec![
Cell::from(review_line),
Cell::from(item.repository.clone()),
Cell::from(status),
Cell::from(updated_display(item.updated_at.as_deref())),
])
.style(Style::default().fg(style::palette::text()))
.height(1)
}
fn updated_display(updated_at: Option<&str>) -> String {
updated_at
.and_then(|value| value.split_once('T').map(|(date, _)| date.to_string()))
.or_else(|| updated_at.map(str::to_string))
.unwrap_or_else(|| "-".to_string())
}
fn repository_column_width(items: &[RequestedReview]) -> Constraint {
let width = items
.iter()
.map(|item| item.repository.chars().count())
.max()
.unwrap_or("Repository".len())
.max("Repository".len());
let width = u16::try_from(width).unwrap_or(u16::MAX).min(32);
Constraint::Length(width)
}
fn status_column_width(items: &[RequestedReview]) -> Constraint {
let width = items
.iter()
.map(|item| {
item.status_summary
.as_deref()
.unwrap_or("Review requested")
.chars()
.count()
})
.max()
.unwrap_or("Status".len())
.max("Status".len());
let width = u16::try_from(width).unwrap_or(u16::MAX).min(28);
Constraint::Length(width)
}
#[cfg(test)]
mod tests {
use ag_forge::ForgeKind;
use ratatui::backend::TestBackend;
use super::*;
use crate::domain::theme::ColorTheme;
#[test]
fn test_render_loaded_reviews_shows_pr_and_mr_rows() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(100, 10);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let state = RequestedReviewState::Loaded {
items: vec![
requested_review(ForgeKind::GitHub, "#42", "Add review tab"),
requested_review(ForgeKind::GitLab, "!7", "Polish merge request"),
],
project_id: 1,
};
terminal
.draw(|frame| {
ReviewListPage::new(&state).render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("Review Requests"));
assert!(text.contains("PR #42 Add review tab"));
assert!(text.contains("MR !7 Polish merge request"));
}
#[test]
fn test_render_empty_reviews_shows_empty_state() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(80, 8);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let state = RequestedReviewState::Loaded {
items: Vec::new(),
project_id: 1,
};
terminal
.draw(|frame| {
ReviewListPage::new(&state).render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("No PRs or MRs currently request your review."));
}
#[test]
fn test_render_reviews_at_limit_shows_truncation_hint() {
let _theme_scope = style::scoped_active_theme(ColorTheme::Current);
let backend = TestBackend::new(110, 12);
let mut terminal = ratatui::Terminal::new(backend).expect("failed to create terminal");
let state = RequestedReviewState::Loaded {
items: (0..REQUESTED_REVIEW_DISPLAY_LIMIT)
.map(|index| {
requested_review(ForgeKind::GitHub, &format!("#{index}"), "Needs review")
})
.collect(),
project_id: 1,
};
terminal
.draw(|frame| {
ReviewListPage::new(&state).render(frame, frame.area());
})
.expect("failed to draw");
let text = buffer_text(terminal.backend().buffer());
assert!(text.contains("showing first 100"));
assert!(text.contains("read-only forge list"));
}
fn requested_review(forge_kind: ForgeKind, display_id: &str, title: &str) -> RequestedReview {
RequestedReview {
display_id: display_id.to_string(),
forge_kind,
repository: "agentty-xyz/agentty".to_string(),
status_summary: Some("Review requested".to_string()),
title: title.to_string(),
updated_at: Some("2026-04-27T21:30:00Z".to_string()),
web_url: format!("https://example.com/{display_id}"),
}
}
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
buffer
.content()
.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<Vec<_>>()
.join("")
}
}