use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use crate::github::detail::PrDetail;
use crate::theme::Palette;
use crate::ui::diff::{parse_unified_diff, render_diff};
use crate::ui::markdown::render_comment_markdown;
use crate::ui::util::humanize_delta;
use crate::ui::util::section_header;
use super::files::push_alt_range;
const DIFF_HUNK_EXCERPT_MAX_ROWS: usize = 12;
const DIVIDER_WIDTH: usize = 60;
pub(super) fn render_thread_body(
thread: &crate::github::detail::ReviewThread,
expanded: bool,
gutter: &'static str,
reply_glyph: &'static str,
p: &Palette,
ascii: bool,
) -> Vec<Line<'static>> {
let mut out: Vec<Line<'static>> = Vec::new();
out.push(thread_header_line(thread, p, ascii));
let hunk_lines = diff_hunk_excerpt(thread.diff_hunk.as_deref(), p);
if !hunk_lines.is_empty() {
out.extend(hunk_lines);
out.push(Line::from(""));
}
for (idx, comment) in thread.comments.iter().enumerate() {
let age = humanize_delta(&comment.created_at);
let is_reply = idx > 0;
let gutter_fg = if is_reply { p.accent_alt } else { p.block_quote_border };
let author_line = if is_reply {
Line::from(vec![
Span::styled(gutter, Style::default().fg(gutter_fg)),
Span::styled(
reply_glyph,
Style::default().fg(p.accent_alt).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("@{}", comment.author),
Style::default().fg(p.accent_alt).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {age}"), Style::default().fg(p.dim)),
])
} else {
Line::from(vec![
Span::styled(gutter, Style::default().fg(gutter_fg)),
Span::styled(
format!("@{}", comment.author),
Style::default().fg(p.foreground).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {age}"), Style::default().fg(p.dim)),
])
};
out.push(author_line);
let body = comment.body_markdown.trim();
let (visible_rendered, truncated) = render_comment_markdown(body, p, expanded);
let body_lines = if is_reply {
gutter_lines(indent_lines(visible_rendered, " "), gutter_fg, ascii)
} else {
gutter_lines(visible_rendered, gutter_fg, ascii)
};
out.extend(body_lines);
if truncated {
out.push(Line::from(vec![
Span::styled(gutter, Style::default().fg(gutter_fg)),
Span::styled("[m] expand", Style::default().fg(p.dim)),
]));
}
if idx + 1 < thread.comments.len() {
out.push(Line::from(vec![Span::styled(gutter, Style::default().fg(p.accent_alt))]));
}
}
out
}
fn mute_lines(lines: Vec<Line<'static>>, muted: ratatui::style::Color) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|mut line| {
for span in &mut line.spans {
span.style = span.style.fg(muted);
}
line
})
.collect()
}
fn section_divider(
label: &str,
count: usize,
rule_glyph: char,
rule_color: ratatui::style::Color,
ascii: bool,
) -> Line<'static> {
let rule = if ascii { '-' } else { rule_glyph };
let label_text = format!(" {label} ({count}) ");
let rule_width = DIVIDER_WIDTH.saturating_sub(label_text.chars().count()) / 2;
let rule_str: String = std::iter::repeat_n(rule, rule_width).collect();
Line::from(vec![
Span::styled(rule_str.clone(), Style::default().fg(rule_color)),
Span::styled(label_text, Style::default().fg(rule_color).add_modifier(Modifier::BOLD)),
Span::styled(rule_str, Style::default().fg(rule_color)),
])
}
fn diff_hunk_excerpt(hunk: Option<&str>, p: &Palette) -> Vec<Line<'static>> {
let Some(text) = hunk.map(str::trim).filter(|s| !s.is_empty()) else {
return Vec::new();
};
let parsed = parse_unified_diff(text);
if parsed.hunks.is_empty() {
return Vec::new();
}
let rendered = render_diff(&parsed, p);
let indent = " ";
let truncated = rendered.len() > DIFF_HUNK_EXCERPT_MAX_ROWS;
let visible_rows = rendered.len().min(DIFF_HUNK_EXCERPT_MAX_ROWS);
let mut out: Vec<Line<'static>> = Vec::with_capacity(visible_rows + usize::from(truncated));
for mut line in rendered.into_iter().take(visible_rows) {
line.spans.insert(0, Span::raw(indent));
out.push(line);
}
if truncated {
out.push(Line::from(Span::styled(
format!("{indent}\u{2026} hunk truncated"),
Style::default().fg(p.dim),
)));
}
out
}
const THREAD_GUTTER_UNICODE: &str = " \u{2502} ";
const THREAD_GUTTER_ASCII: &str = " | ";
pub(super) fn thread_gutter(ascii: bool) -> &'static str {
if ascii { THREAD_GUTTER_ASCII } else { THREAD_GUTTER_UNICODE }
}
pub(super) fn gutter_lines(
md_lines: Vec<Line<'static>>,
gutter_fg: ratatui::style::Color,
ascii: bool,
) -> Vec<Line<'static>> {
md_lines
.into_iter()
.map(|mut line| {
let inherited_bg = line.spans.first().and_then(|s| s.style.bg);
let mut style = Style::default().fg(gutter_fg);
if let Some(bg) = inherited_bg {
style = style.bg(bg);
}
let gutter_span = Span::styled(thread_gutter(ascii), style);
line.spans.insert(0, gutter_span);
line
})
.collect()
}
pub(super) fn indent_lines(
md_lines: Vec<Line<'static>>,
prefix: &'static str,
) -> Vec<Line<'static>> {
md_lines
.into_iter()
.map(|mut line| {
line.spans.insert(0, Span::raw(prefix));
line
})
.collect()
}
pub(super) fn thread_header_line(
thread: &crate::github::detail::ReviewThread,
p: &Palette,
ascii: bool,
) -> Line<'static> {
let (glyph, glyph_color, status_text) = if thread.is_outdated {
(if ascii { "D" } else { "\u{25C6}" }, p.muted, "outdated")
} else if thread.is_resolved {
(if ascii { "+" } else { "\u{2713}" }, p.muted, "resolved")
} else {
(if ascii { "!" } else { "\u{2691}" }, p.warning, "unresolved")
};
let location =
thread.line.map_or_else(|| thread.path.clone(), |ln| format!("{}:{ln}", thread.path));
let n = thread.comments.len();
let count_str = format!(" \u{00B7} {n} comment{}", if n == 1 { "" } else { "s" });
let status_str = format!(" \u{00B7} {status_text}");
let mut spans = vec![
Span::styled(format!(" {glyph} "), Style::default().fg(glyph_color)),
Span::styled(location, Style::default().fg(p.accent)),
Span::styled(count_str, Style::default().fg(p.dim)),
];
if thread.is_outdated {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"[OUTDATED]".to_owned(),
Style::default().fg(p.danger).add_modifier(Modifier::BOLD),
));
}
spans.push(Span::styled(status_str, Style::default().fg(p.dim)));
Line::from(spans)
}
#[allow(clippy::too_many_lines)]
pub(super) fn comments_lines(
detail: &PrDetail,
expanded: bool,
show_outdated: bool,
scope: Option<&str>,
p: &Palette,
ascii: bool,
) -> (Vec<Line<'static>>, Vec<u16>, Vec<(u16, u16)>) {
let gutter = thread_gutter(ascii);
let reply_glyph = if ascii { "> " } else { "\u{21b3} " };
let total_threads = detail.review_threads.len();
let scoped_threads: Vec<&crate::github::detail::ReviewThread> = if let Some(sha) = scope {
detail.review_threads.iter().filter(|t| t.originating_commit_sha() == Some(sha)).collect()
} else {
detail.review_threads.iter().collect()
};
let scoped_thread_count = scoped_threads.len();
let unresolved_count =
scoped_threads.iter().filter(|t| !t.is_resolved && !t.is_outdated).count();
let total_comments = detail.issue_comments.len();
let mut lines = Vec::new();
let mut unresolved_offsets: Vec<u16> = Vec::new();
let mut alt_bg_ranges: Vec<(u16, u16)> = Vec::new();
let (mut active, mut outdated): (
Vec<&crate::github::detail::ReviewThread>,
Vec<&crate::github::detail::ReviewThread>,
) = scoped_threads.into_iter().partition(|t| !t.is_outdated);
active.sort_by_key(|t| t.is_resolved);
outdated.sort_by_key(|t| t.is_resolved);
let max_items = if expanded { usize::MAX } else { 10 };
let mut items_shown = 0;
let mut alt_on = false;
let render_section = |threads: &[&crate::github::detail::ReviewThread],
is_outdated_section: bool,
lines: &mut Vec<Line<'static>>,
alt_bg_ranges: &mut Vec<(u16, u16)>,
unresolved_offsets: &mut Vec<u16>,
items_shown: &mut usize,
alt_on: &mut bool| {
for thread in threads {
if *items_shown >= max_items {
break;
}
if !is_outdated_section && !thread.is_resolved {
#[allow(clippy::cast_possible_truncation)]
unresolved_offsets.push(lines.len() as u16);
}
let alt_start = lines.len();
let thread_body = render_thread_body(thread, expanded, gutter, reply_glyph, p, ascii);
let thread_body =
if is_outdated_section { mute_lines(thread_body, p.muted) } else { thread_body };
lines.extend(thread_body);
push_alt_range(alt_bg_ranges, alt_start, lines.len(), *alt_on);
*alt_on = !*alt_on;
lines.push(Line::from("")); *items_shown += 1;
}
};
if !active.is_empty() {
lines.push(section_divider("ACTIVE", active.len(), '\u{2501}', p.border_focused, ascii));
render_section(
&active,
false,
&mut lines,
&mut alt_bg_ranges,
&mut unresolved_offsets,
&mut items_shown,
&mut alt_on,
);
}
if !outdated.is_empty() {
if show_outdated {
lines.push(section_divider("OUTDATED", outdated.len(), '\u{254C}', p.muted, ascii));
render_section(
&outdated,
true,
&mut lines,
&mut alt_bg_ranges,
&mut unresolved_offsets,
&mut items_shown,
&mut alt_on,
);
} else {
lines.push(section_divider("OUTDATED", outdated.len(), '\u{254C}', p.muted, ascii));
lines.push(Line::from(Span::styled(
format!(
" {} outdated thread{} hidden \u{00B7} [z] show",
outdated.len(),
if outdated.len() == 1 { "" } else { "s" }
),
Style::default().fg(p.muted),
)));
lines.push(Line::from(""));
}
}
for comment in &detail.issue_comments {
if items_shown >= max_items {
break;
}
let age = humanize_delta(&comment.created_at);
let alt_start = lines.len();
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, expanded);
lines.extend(indent_lines(visible_rendered, " "));
if truncated {
lines.push(Line::from(Span::styled(" [m] expand", Style::default().fg(p.dim))));
}
push_alt_range(&mut alt_bg_ranges, alt_start, lines.len(), alt_on);
alt_on = !alt_on;
lines.push(Line::from("")); items_shown += 1;
}
if scope.is_some() && scoped_thread_count == 0 {
lines.insert(
0,
Line::from(Span::styled(
" No review threads originated on this commit \u{00B7} issue comments still shown below",
Style::default().fg(p.muted),
)),
);
}
let total_items = total_threads + total_comments;
if !expanded && total_items > 10 {
lines.push(Line::from(Span::styled(
format!(" ... {} more [m] to expand", total_items - items_shown),
Style::default().fg(p.dim),
)));
}
let header = section_header(
&format!("COMMENTS ({total_threads} threads \u{00B7} {unresolved_count} unresolved)"),
p,
);
let scope_hint: Option<Line<'static>> = scope.map(|sha| {
let short = sha.chars().take(7).collect::<String>();
let glyph = if ascii { "@" } else { "\u{25c8}" }; Line::from(Span::styled(
format!(
" {glyph} Scoped to {short} \u{00B7} showing {scoped_thread_count} of {total_threads} threads \u{00B7} H returns to HEAD"
),
Style::default().fg(p.warning),
))
});
let prefix_count = 1 + scope_hint.as_ref().map_or(0usize, |_| 1);
let mut all_lines = vec![header];
if let Some(hint) = scope_hint {
all_lines.push(hint);
}
all_lines.extend(lines);
#[allow(clippy::cast_possible_truncation)]
let shift = prefix_count as u16;
let shifted_offsets = unresolved_offsets.iter().map(|&o| o + shift).collect();
let shifted_alt_ranges: Vec<(u16, u16)> =
alt_bg_ranges.into_iter().map(|(a, b)| (a + shift, b + shift)).collect();
(all_lines, shifted_offsets, shifted_alt_ranges)
}
pub(super) fn build_comments(
detail: &PrDetail,
comments_expanded: bool,
show_outdated: bool,
scope: Option<&str>,
p: &Palette,
ascii: bool,
) -> (Vec<Line<'static>>, Vec<(u16, u16)>) {
let has_comments = !detail.review_threads.is_empty() || !detail.issue_comments.is_empty();
if !has_comments {
return (Vec::new(), Vec::new());
}
let (comment_lines, _unresolved, alt_ranges) =
comments_lines(detail, comments_expanded, show_outdated, scope, p, ascii);
(comment_lines, alt_ranges)
}