mod checks;
mod comments;
mod commits;
mod files;
mod header;
mod reviews;
mod sections;
mod thread_card;
mod thread_index;
pub(crate) use thread_index::{ThreadIndex, build_for as build_thread_index};
#[cfg(test)]
pub(crate) mod tests;
use ratatui::{
Frame,
layout::{Constraint, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Padding, Paragraph, Wrap},
};
use crate::app::App;
use crate::github::detail::PrDetail;
use crate::ui::util::render_detail_header;
pub use header::build_header;
pub use sections::{build_section, cheap_section_row_count};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum DetailSection {
#[default]
Description,
Checks,
Reviews,
Files,
Comments,
Commits,
}
impl DetailSection {
pub const ALL: [DetailSection; 6] = [
DetailSection::Description,
DetailSection::Checks,
DetailSection::Reviews,
DetailSection::Files,
DetailSection::Comments,
DetailSection::Commits,
];
pub fn label(self) -> &'static str {
match self {
DetailSection::Description => "Description",
DetailSection::Checks => "Checks",
DetailSection::Reviews => "Reviews",
DetailSection::Files => "Files",
DetailSection::Comments => "Comments",
DetailSection::Commits => "Commits",
}
}
pub fn has_content(self, detail: &PrDetail) -> bool {
match self {
DetailSection::Description => true, DetailSection::Checks => !detail.check_runs.is_empty(),
DetailSection::Reviews => !detail.reviews.is_empty(),
DetailSection::Files => !detail.files.is_empty(),
DetailSection::Comments => {
!detail.review_threads.is_empty() || !detail.issue_comments.is_empty()
}
DetailSection::Commits => !detail.commits.is_empty(),
}
}
}
#[allow(clippy::too_many_lines)]
pub fn draw(f: &mut Frame, app: &App, area: Rect) {
let p = &app.palette;
if app.detail_fetching && app.pr_detail.is_none() {
let widget = Paragraph::new(Line::from(Span::styled(
"Fetching pull request\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.pr_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.pr_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 vsplits =
ratatui::layout::Layout::vertical([Constraint::Length(header_rows), Constraint::Min(1)])
.split(area);
let header_area = vsplits[0];
let body_area = vsplits[1];
render_detail_header(f, header_lines, header_area, p);
let (sidebar_area, right_area) = if app.sidebar_hidden {
let dummy = ratatui::layout::Rect { width: 0, ..body_area };
(dummy, body_area)
} else {
let hsplits = ratatui::layout::Layout::horizontal([
Constraint::Length(app.sidebar_width),
Constraint::Min(20),
])
.split(body_area);
(hsplits[0], hsplits[1])
};
let sidebar_sections_height: u16 = 8;
let vsidebar = ratatui::layout::Layout::vertical([
Constraint::Length(sidebar_sections_height.min(sidebar_area.height)),
Constraint::Min(0),
])
.split(sidebar_area);
let sections_area = vsidebar[0];
let files_area = vsidebar[1];
app.pr_detail_sidebar_rects.set((sections_area, files_area));
let selected_section = app.pr_detail_selected_section;
let commit_diff_cache_counts = app.commit_diff_cache_counts();
if !app.sidebar_hidden {
let indicator = if app.config.show_ascii_glyphs { "> " } else { "\u{25b6} " }; let placeholder = " ";
let mut section_lines: Vec<Line<'static>> = Vec::new();
section_lines.push(Line::from(Span::styled(
"SECTIONS".to_owned(),
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
)));
for sec in DetailSection::ALL {
let is_selected = sec == selected_section;
let prefix = if is_selected { indicator } else { placeholder };
let commits_warming = sec == DetailSection::Commits
&& commit_diff_cache_counts.is_some_and(|(ready, total, _)| ready < total);
let style = if is_selected {
Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
} else if commits_warming {
Style::default().fg(p.warning)
} else if sec.has_content(detail) {
Style::default().fg(p.foreground)
} else {
Style::default().fg(p.dim)
};
let mut spans = vec![Span::styled(format!("{prefix}{}", sec.label()), style)];
if commits_warming && let Some((ready, total, in_flight)) = commit_diff_cache_counts {
let marker = if app.config.show_ascii_glyphs {
if in_flight > 0 { "~" } else { "!" }
} else if in_flight > 0 {
"\u{21bb}" } else {
"!"
};
spans.push(Span::styled(
format!(" {ready}/{total}{marker}"),
Style::default().fg(p.warning),
));
}
section_lines.push(Line::from(spans));
}
let sections_block = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.background))
.padding(Padding::new(1, 0, 0, 0));
let sections_inner = sections_block.inner(sections_area);
f.render_widget(Paragraph::new(section_lines).block(sections_block), sections_area);
let mut file_list_lines: Vec<Line<'static>> = Vec::new();
let files_header = format!("FILES CHANGED ({})", detail.changed_files_count);
file_list_lines.push(Line::from(Span::styled(
files_header,
Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
)));
let sidebar_inner_width = usize::from(sections_inner.width).saturating_sub(1);
let files_cursor = app.pr_detail_files_cursor;
let selected_is_files = selected_section == DetailSection::Files;
file_list_lines.extend(files::sidebar_file_lines(
detail,
files_cursor,
selected_is_files,
sidebar_inner_width,
app.thread_index.as_ref(),
p,
));
let files_block = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(p.border_focused))
.style(Style::default().bg(p.background))
.padding(Padding::new(1, 0, 0, 0));
let files_scroll = app.pr_detail_sidebar_scroll;
f.render_widget(
Paragraph::new(file_list_lines).block(files_block).scroll((files_scroll, 0)),
files_area,
);
}
let scoped_commit: Option<&crate::github::detail::PrCommit> =
app.selected_commit.and_then(|idx| detail.commits.get(idx));
let scoped_patches: Option<&std::collections::HashMap<String, Option<String>>> = scoped_commit
.and_then(|c| {
app.detail_cache.get_commit_patches(&detail.repo, &c.sha).map(|cached| &cached.data)
});
let indicator_height: u16 = u16::from(scoped_commit.is_some());
let (indicator_area, content_right_area) = if indicator_height > 0 && right_area.height > 1 {
let vsplit = ratatui::layout::Layout::vertical([
Constraint::Length(indicator_height),
Constraint::Min(0),
])
.split(right_area);
(Some(vsplit[0]), vsplit[1])
} else {
(None, right_area)
};
if let (Some(strip_area), Some(commit)) = (indicator_area, scoped_commit) {
let short_sha = &commit.short_sha;
let max_headline = usize::from(strip_area.width).saturating_sub(40);
let headline = crate::ui::util::truncate(&commit.headline, max_headline.max(10));
let glyph = if app.config.show_ascii_glyphs { "@" } else { "\u{25c8}" }; let strip_text = format!(
" {glyph} Scoped to {short_sha} \u{2014} \"{headline}\" \u{00b7} H returns to HEAD "
);
let strip_line =
Line::from(Span::styled(strip_text, Style::default().fg(p.warning).bg(p.help_bg)));
f.render_widget(
Paragraph::new(strip_line).style(Style::default().bg(p.help_bg)),
strip_area,
);
}
let comments_scope_sha: Option<&str> = scoped_commit.map(|c| c.sha.as_str());
let commit_scope_pending = selected_section == DetailSection::Files
&& scoped_commit.is_some()
&& scoped_patches.is_none();
let (mut content_lines, alt_bg_ranges) = if commit_scope_pending {
(
vec![Line::from(Span::styled(
"Fetching commit diff...".to_owned(),
Style::default().fg(p.dim),
))],
Vec::new(),
)
} else {
build_section(
selected_section,
detail,
app.pr_detail_files_cursor,
app.pr_detail_files_show_diff,
app.detail_comments_expanded,
app.detail_show_outdated,
app.thread_index.as_ref(),
&app.pr_detail_expanded_threads,
&app.pr_detail_diff_cursor,
scoped_patches,
app.commits_cursor,
comments_scope_sha,
p,
app.config.show_ascii_glyphs,
)
};
if selected_section == DetailSection::Commits
&& let Some((ready, total, in_flight)) = commit_diff_cache_counts
&& ready < total
{
let status = if in_flight > 0 {
format!("Commit diffs warming: {ready}/{total} ready")
} else {
format!("Commit diffs not fully cached: {ready}/{total} ready")
};
let insert_at = content_lines.len().min(2);
content_lines
.insert(insert_at, Line::from(Span::styled(status, Style::default().fg(p.warning))));
content_lines.insert(insert_at + 1, Line::from(""));
}
let left_padding = if app.sidebar_hidden { 3 } else { 2 };
let block = Block::default()
.style(Style::default().bg(p.background).fg(p.foreground))
.padding(Padding::new(left_padding, 2, 0, 0));
let inner = block.inner(content_right_area);
app.pr_detail_viewport.set(inner);
app.pr_detail_right_viewport.set(inner);
let scroll = app.right_pane_scroll();
let tinted_lines = header::apply_alt_bg(
&content_lines,
&alt_bg_ranges,
p.help_bg,
inner.width,
!app.copy_mode.active,
);
if app.sidebar_hidden && inner.height > 0 {
let hint_area =
ratatui::layout::Rect { x: content_right_area.x, y: inner.y, width: 1, height: 1 };
f.render_widget(
Paragraph::new(Line::from(Span::styled(
"\u{203a}", Style::default().fg(p.dim),
))),
hint_area,
);
}
let lines_to_render = if app.copy_mode.active {
crate::ui::copy_mode::apply_overlay(&tinted_lines, &app.copy_mode, p)
} else {
tinted_lines
};
let should_wrap =
selected_section != DetailSection::Files && selected_section != DetailSection::Commits;
let mut widget = Paragraph::new(lines_to_render)
.block(block)
.style(Style::default().bg(p.background).fg(p.foreground))
.scroll((scroll, 0));
if should_wrap {
widget = widget.wrap(Wrap { trim: false });
}
f.render_widget(widget, content_right_area);
}