use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Padding, Paragraph, Wrap},
};
use crate::app::App;
use crate::github::detail::IssueDetail;
use crate::ui::markdown::{render_comment_markdown, render_markdown};
use crate::ui::util::{humanize_delta, render_detail_header, section_header};
fn issue_state_label(
detail: &IssueDetail,
p: &crate::theme::Palette,
) -> (String, ratatui::style::Color) {
match detail.state.as_str() {
"OPEN" => ("OPEN".to_owned(), p.success),
"CLOSED" => ("CLOSED".to_owned(), p.accent_alt),
other => (other.to_owned(), p.dim),
}
}
pub fn build_header(detail: &IssueDetail, p: &crate::theme::Palette) -> Vec<Line<'static>> {
let (state_text, state_color) = issue_state_label(detail, p);
let age = humanize_delta(&detail.created_at);
let line1 = Line::from(vec![
Span::styled(
format!("{} #{}", detail.repo, detail.number),
Style::default().fg(p.foreground).add_modifier(Modifier::BOLD),
),
Span::styled(" \u{00B7} ", Style::default().fg(p.dim)),
Span::styled(state_text, Style::default().fg(state_color).add_modifier(Modifier::BOLD)),
Span::styled(" \u{00B7} ", Style::default().fg(p.dim)),
Span::styled(format!("@{}", detail.author), Style::default().fg(p.foreground)),
Span::styled(format!(" opened {age}"), Style::default().fg(p.dim)),
]);
let line2 = Line::from(Span::styled(
detail.title.clone(),
Style::default().fg(p.foreground).add_modifier(Modifier::BOLD),
));
let labels_str = if detail.labels.is_empty() {
String::new()
} else {
let names: Vec<&str> = detail.labels.iter().map(|l| l.name.as_str()).collect();
format!(" \u{00B7} labels: {}", names.join(", "))
};
let line3 = Line::from(Span::styled(
format!("{} comments{}", detail.comments.len(), labels_str),
Style::default().fg(p.dim),
));
vec![line1, line2, line3]
}
pub fn build_content(
detail: &IssueDetail,
comments_expanded: bool,
p: &crate::theme::Palette,
_ascii: bool,
) -> (Vec<Line<'static>>, Vec<u16>) {
let mut all_lines: Vec<Line<'static>> = Vec::new();
let mut section_anchors: Vec<u16> = Vec::new();
#[allow(clippy::cast_possible_truncation)]
let body_anchor = all_lines.len() as u16;
section_anchors.push(body_anchor);
if !detail.body_markdown.is_empty() {
let body_lines = render_markdown(&detail.body_markdown, p);
all_lines.extend(body_lines);
all_lines.push(Line::from(""));
}
if !detail.comments.is_empty() {
#[allow(clippy::cast_possible_truncation)]
let comments_anchor = all_lines.len() as u16;
section_anchors.push(comments_anchor);
all_lines.push(section_header(&format!("COMMENTS ({})", detail.comments.len()), p));
for comment in &detail.comments {
let age = humanize_delta(&comment.created_at);
all_lines.push(Line::from(vec![
Span::styled(
format!("@{}", comment.author),
Style::default().fg(p.foreground).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {age}"), Style::default().fg(p.dim)),
]));
let body = comment.body_markdown.trim();
let (visible_rendered, truncated) = render_comment_markdown(body, p, comments_expanded);
for mut line in visible_rendered {
line.spans.insert(0, Span::raw(" "));
all_lines.push(line);
}
if truncated {
all_lines
.push(Line::from(Span::styled(" [m] expand", Style::default().fg(p.dim))));
}
all_lines.push(Line::from("")); }
}
(all_lines, section_anchors)
}
pub fn draw(f: &mut Frame, app: &App, area: Rect) {
let p = &app.palette;
if app.detail_fetching && app.issue_detail.is_none() {
let widget = Paragraph::new(Line::from(Span::styled(
"Fetching issue\u{2026}",
Style::default().fg(p.dim),
)))
.block(Block::default().style(Style::default().bg(p.background)))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(widget, area);
return;
}
if let Some(err) = &app.detail_error
&& app.issue_detail.is_none()
{
let lines = vec![
Line::from(Span::styled(format!("\u{2716} {err}"), Style::default().fg(p.danger))),
Line::from(""),
Line::from(Span::styled(
"Press Esc to go back, r to retry",
Style::default().fg(p.dim),
)),
];
let widget = Paragraph::new(lines)
.block(Block::default().style(Style::default().bg(p.background)))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(widget, area);
return;
}
let Some(detail) = &app.issue_detail else {
return;
};
let header_lines = build_header(detail, p);
#[allow(clippy::cast_possible_truncation)]
let header_rows = (header_lines.len() + 2) as u16; let header_rows = header_rows.min(area.height);
let splits = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Length(header_rows),
ratatui::layout::Constraint::Min(1),
])
.split(area);
let header_area = splits[0];
let body_area = splits[1];
render_detail_header(f, header_lines, header_area, p);
let (content_lines, _section_anchors) =
build_content(detail, app.detail_comments_expanded, p, app.config.show_ascii_glyphs);
let block = Block::default()
.style(Style::default().bg(p.background).fg(p.foreground))
.padding(Padding::new(2, 2, 0, 0));
let inner = block.inner(body_area);
app.pr_detail_viewport.set(inner);
let scroll = app.scroll_for(crate::ui::pr_detail::DetailSection::Description);
let lines_to_render = if app.copy_mode.active {
crate::ui::copy_mode::apply_overlay(&content_lines, &app.copy_mode, p)
} else {
content_lines
};
let widget = Paragraph::new(lines_to_render)
.block(block)
.style(Style::default().bg(p.background).fg(p.foreground))
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
f.render_widget(widget, body_area);
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use crate::github::detail::IssueComment;
use crate::github::types::Label;
use crate::theme::Palette;
use chrono::Utc;
fn fixture_issue_detail(num_comments: usize) -> IssueDetail {
let now = Utc::now();
let comments = (0..num_comments)
.map(|i| IssueComment {
node_id: "COMMENT_node".to_owned(),
author: format!("user-{i}"),
body_markdown: format!("Comment body {i}"),
created_at: now,
})
.collect();
IssueDetail {
node_id: "ISSUE_node".to_owned(),
repo: "owner/repo".to_owned(),
number: 7,
title: "Test Issue".to_owned(),
url: "https://github.com/owner/repo/issues/7".to_owned(),
author: "dave".to_owned(),
body_markdown: "Reproducible with an empty config.".to_owned(),
state: "OPEN".to_owned(),
updated_at: now,
created_at: now,
labels: vec![Label { name: "bug".to_owned(), color: "ee0701".to_owned() }],
assignees: vec!["alice".to_owned()],
comments,
}
}
#[test]
fn issue_detail_anchors_start_at_zero() {
let detail = fixture_issue_detail(3);
let p = Palette::default();
let (_, anchors) = build_content(&detail, false, &p, false);
assert!(!anchors.is_empty(), "should have at least one anchor");
assert_eq!(anchors[0], 0, "body anchor should be at 0");
}
#[test]
fn build_header_carries_context() {
let detail = fixture_issue_detail(2);
let p = Palette::default();
let lines = build_header(&detail, &p);
let text: String =
lines.iter().flat_map(|l| l.spans.iter()).map(|s| s.content.as_ref()).collect();
assert!(text.contains("owner/repo #7"), "repo/number missing: {text}");
assert!(text.contains("OPEN"), "state label missing: {text}");
assert!(text.contains("Test Issue"), "title missing: {text}");
assert!(text.contains("2 comments"), "comment count missing: {text}");
}
#[test]
fn issue_detail_anchors_monotone() {
let detail = fixture_issue_detail(5);
let p = Palette::default();
let (_, anchors) = build_content(&detail, false, &p, false);
for window in anchors.windows(2) {
assert!(window[1] >= window[0], "anchors not monotone: {anchors:?}");
}
}
#[test]
fn issue_detail_no_comments_one_anchor() {
let detail = fixture_issue_detail(0);
let p = Palette::default();
let (_, anchors) = build_content(&detail, false, &p, false);
assert_eq!(anchors.len(), 1, "no comments => only title anchor");
}
#[test]
fn issue_comment_body_renders_markdown_styles() {
let now = Utc::now();
let p = Palette::default();
let detail = IssueDetail {
node_id: "ISSUE_node".to_owned(),
repo: "owner/repo".to_owned(),
number: 1,
title: "Issue".to_owned(),
url: "u".to_owned(),
author: "dave".to_owned(),
body_markdown: String::new(),
state: "OPEN".to_owned(),
updated_at: now,
created_at: now,
labels: vec![],
assignees: vec![],
comments: vec![IssueComment {
node_id: "COMMENT_node".to_owned(),
author: "eve".to_owned(),
body_markdown: "**critical** and `fix_it()`".to_owned(),
created_at: now,
}],
};
let (lines, _) = build_content(&detail, true, &p, false);
let has_bold = lines.iter().flat_map(|l| l.spans.iter()).any(|s| {
s.content.contains("critical") && s.style.add_modifier.contains(Modifier::BOLD)
});
let has_code = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| s.content.contains("fix_it()") && s.style.bg == Some(p.code_bg));
assert!(has_bold, "issue comment **bold** must produce BOLD modifier span");
assert!(has_code, "issue comment `code` must produce code_bg span");
}
#[test]
fn issue_comment_collapsed_shows_expand_hint() {
let now = Utc::now();
let p = Palette::default();
let long_body = (0..10).map(|i| format!("Para {i}.")).collect::<Vec<_>>().join("\n\n");
let detail = IssueDetail {
node_id: "ISSUE_node".to_owned(),
repo: "owner/repo".to_owned(),
number: 1,
title: "Issue".to_owned(),
url: "u".to_owned(),
author: "dave".to_owned(),
body_markdown: String::new(),
state: "OPEN".to_owned(),
updated_at: now,
created_at: now,
labels: vec![],
assignees: vec![],
comments: vec![IssueComment {
node_id: "COMMENT_node".to_owned(),
author: "frank".to_owned(),
body_markdown: long_body,
created_at: now,
}],
};
let (lines, _) = build_content(&detail, false, &p, false);
let has_hint =
lines.iter().any(|l| l.spans.iter().any(|s| s.content.contains("[m] expand")));
assert!(has_hint, "collapsed long issue comment must show [m] expand hint");
}
}