use std::cell::RefCell;
use std::collections::{HashMap, HashSet};
use ratatui::{
style::{Modifier, Style},
text::{Line, Span},
};
use crate::github::detail::{FileChange, FileChangeKind, PrDetail, ReviewThread};
use crate::theme::Palette;
use crate::ui::diff::{DiffFile, DiffLineKind, parse_hunk_header};
use crate::ui::util::truncate;
use super::ThreadIndex;
use super::thread_card::render_thread_card;
const DIFF_HUNK_EXCERPT_ROW_CAP: usize = 12;
pub(super) fn file_kind_glyph(kind: FileChangeKind) -> &'static str {
match kind {
FileChangeKind::Added => "\u{271A}", FileChangeKind::Modified => "\u{270E}", FileChangeKind::Deleted => "\u{2702}", FileChangeKind::Renamed => "\u{2192}", FileChangeKind::Copied | FileChangeKind::Changed => "\u{00B7}", }
}
pub(super) fn push_alt_range(ranges: &mut Vec<(u16, u16)>, start: usize, end: usize, alt_on: bool) {
if !alt_on || end <= start {
return;
}
let start = u16::try_from(start).unwrap_or(u16::MAX);
let end = u16::try_from(end).unwrap_or(u16::MAX);
ranges.push((start, end));
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_files(
detail: &PrDetail,
files_cursor: usize,
show_diff: bool,
thread_index: Option<&ThreadIndex>,
expanded: &HashSet<(String, u32)>,
diff_cursor: &RefCell<Option<(String, u32)>>,
scoped_patches: Option<&HashMap<String, Option<String>>>,
p: &Palette,
ascii: bool,
) -> (Vec<Line<'static>>, Vec<(u16, u16)>) {
let effective_empty = scoped_patches.map_or(detail.files.is_empty(), |patches| {
!detail.files.iter().any(|f| patches.contains_key(&f.path))
});
if effective_empty {
return (
vec![Line::from(Span::styled(
if scoped_patches.is_some() {
"No files changed in this commit".to_owned()
} else {
"No files changed".to_owned()
},
Style::default().fg(p.dim),
))],
Vec::new(),
);
}
if !show_diff {
return build_files_overview_scoped(detail, files_cursor, thread_index, scoped_patches, p);
}
build_files_diff(
detail,
files_cursor,
thread_index,
expanded,
diff_cursor,
scoped_patches,
p,
ascii,
)
}
pub(super) fn files_row_count(
detail: &PrDetail,
files_cursor: usize,
show_diff: bool,
thread_index: Option<&ThreadIndex>,
expanded: &HashSet<(String, u32)>,
scoped_patches: Option<&HashMap<String, Option<String>>>,
) -> usize {
let effective_empty = scoped_patches.map_or(detail.files.is_empty(), |patches| {
!detail.files.iter().any(|f| patches.contains_key(&f.path))
});
if effective_empty {
return 1;
}
if !show_diff {
let visible_files =
detail.files.iter().filter(|f| scoped_patches.is_none_or(|p| p.contains_key(&f.path)));
return visible_files.count() + 1;
}
files_diff_row_count(detail, files_cursor, thread_index, expanded, scoped_patches)
}
pub(super) fn build_files_overview_scoped(
detail: &PrDetail,
files_cursor: usize,
thread_index: Option<&super::ThreadIndex>,
scoped_patches: Option<&HashMap<String, Option<String>>>,
p: &Palette,
) -> (Vec<Line<'static>>, Vec<(u16, u16)>) {
let mut sorted: Vec<&crate::github::detail::FileChange> = detail
.files
.iter()
.filter(|f| scoped_patches.is_none_or(|p| p.contains_key(&f.path)))
.collect();
sorted.sort_by_key(|f| std::cmp::Reverse(f.additions + f.deletions));
let cursor = files_cursor.min(sorted.len().saturating_sub(1));
let mut lines = Vec::with_capacity(sorted.len() + 1);
for (idx, file) in sorted.iter().enumerate() {
let glyph = file_kind_glyph(file.change_kind);
let glyph_color = match file.change_kind {
FileChangeKind::Added => p.success,
FileChangeKind::Modified => p.warning,
FileChangeKind::Deleted => p.danger,
FileChangeKind::Renamed => p.accent,
FileChangeKind::Copied | FileChangeKind::Changed => p.muted,
};
let is_cursor = idx == cursor;
let row_bg_style = if is_cursor {
Style::default().bg(p.selection_bg).fg(p.selection_fg)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(format!("{glyph} "), Style::default().fg(glyph_color)),
Span::styled(file.path.clone(), row_bg_style.fg(p.foreground)),
Span::styled(" ".to_owned(), row_bg_style),
Span::styled(format!("+{}", file.additions), row_bg_style.fg(p.git_new)),
Span::styled(" ".to_owned(), row_bg_style),
Span::styled(format!("\u{2212}{}", file.deletions), row_bg_style.fg(p.danger)),
];
if let Some(idx) = thread_index {
let total = idx.total_for(&file.path);
if total > 0 {
let unresolved = idx.unresolved_for(&file.path);
let (glyph, fg) = if unresolved > 0 {
("\u{2691}", p.warning) } else {
("\u{2713}", p.muted) };
spans.push(Span::styled(" ".to_owned(), row_bg_style));
spans.push(Span::styled(format!("{glyph} {total}"), row_bg_style.fg(fg)));
}
}
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::styled(
"$ overview \u{00B7} F open diff \u{00B7} J/K cycle file \u{00B7} click a file to open"
.to_owned(),
Style::default().fg(p.dim),
)));
(lines, Vec::new())
}
fn files_diff_row_count(
detail: &PrDetail,
files_cursor: usize,
thread_index: Option<&ThreadIndex>,
expanded: &HashSet<(String, u32)>,
scoped_patches: Option<&HashMap<String, Option<String>>>,
) -> usize {
let effective_files: Vec<&FileChange> = detail
.files
.iter()
.filter(|f| scoped_patches.is_none_or(|patches| patches.contains_key(&f.path)))
.collect();
if effective_files.is_empty() {
return 1;
}
let idx = files_cursor.min(effective_files.len() - 1);
let file = effective_files[idx];
let effective_patch: Option<&str> = if let Some(patches) = scoped_patches {
patches.get(&file.path).and_then(|p| p.as_deref())
} else {
file.patch.as_deref()
};
let thread_hint_rows = if scoped_patches.is_none() {
usize::from(thread_index.is_some_and(|tidx| tidx.total_for(&file.path) > 0))
} else {
0
};
let header_rows = 2 + thread_hint_rows + 1;
let body_rows = match effective_patch {
Some(patch) => {
if let Some(index) = thread_index.filter(|_| scoped_patches.is_none()) {
diff_patch_row_count_with_threads(
patch,
index,
expanded,
&file.path,
&detail.review_threads,
)
} else {
diff_patch_row_count(patch)
}
}
None => 1,
};
header_rows + body_rows
}
fn diff_patch_row_count(patch: &str) -> usize {
let mut rows = 0usize;
let mut hunk_count = 0usize;
let mut in_hunk = false;
for raw_line in patch.lines() {
if parse_hunk_header(raw_line).is_some() {
if hunk_count > 0 {
rows += 1; }
rows += 1; hunk_count += 1;
in_hunk = true;
continue;
}
if in_hunk {
rows += 1;
}
}
rows.max(1)
}
fn diff_patch_row_count_with_threads(
patch: &str,
index: &ThreadIndex,
expanded: &HashSet<(String, u32)>,
file_path: &str,
all_threads: &[ReviewThread],
) -> usize {
let mut rows = 0usize;
let mut hunk_count = 0usize;
let mut new_cursor = 0u32;
let mut in_hunk = false;
for raw_line in patch.lines() {
if let Some((_old_start, _old_count, new_start, _new_count, _section)) =
parse_hunk_header(raw_line)
{
if hunk_count > 0 {
rows += 1;
}
rows += 1;
hunk_count += 1;
new_cursor = new_start;
in_hunk = true;
continue;
}
if !in_hunk {
continue;
}
rows += 1;
let prefix = raw_line.chars().next();
let new_lineno = match prefix {
Some(' ' | '+') => {
let lineno = Some(new_cursor);
new_cursor += 1;
lineno
}
Some('-' | '\\') => None,
_ => Some(new_cursor),
};
let Some(lineno) = new_lineno else {
continue;
};
let thread_indices = index.active_at(file_path, lineno);
if thread_indices.is_empty() {
continue;
}
if expanded.contains(&(file_path.to_owned(), lineno)) {
let threads: Vec<&ReviewThread> =
thread_indices.iter().filter_map(|&i| all_threads.get(i)).collect();
rows += expanded_thread_cards_row_count(&threads);
} else {
rows += 1;
}
}
let overflow_count = index.overflow(file_path).len();
if overflow_count > 0 {
rows += 2 + overflow_count; }
rows.max(1)
}
fn expanded_thread_cards_row_count(threads: &[&ReviewThread]) -> usize {
threads
.iter()
.enumerate()
.map(|(idx, thread)| usize::from(idx > 0) + expanded_thread_body_row_count(thread))
.sum()
}
fn expanded_thread_body_row_count(thread: &ReviewThread) -> usize {
let hunk_rows = thread.diff_hunk.as_deref().map_or(0, |hunk| {
let rows = diff_patch_row_count(hunk).min(DIFF_HUNK_EXCERPT_ROW_CAP);
if rows == 0 { 0 } else { rows + 1 }
});
let comment_rows: usize = thread
.comments
.iter()
.enumerate()
.map(|(idx, comment)| {
let body_rows = comment.body_markdown.trim().lines().count().max(1);
usize::from(idx > 0) + 1 + body_rows
})
.sum();
1 + hunk_rows + comment_rows
}
#[allow(clippy::too_many_arguments)]
pub(super) fn build_files_diff(
detail: &PrDetail,
files_cursor: usize,
thread_index: Option<&ThreadIndex>,
expanded: &HashSet<(String, u32)>,
diff_cursor: &RefCell<Option<(String, u32)>>,
scoped_patches: Option<&HashMap<String, Option<String>>>,
p: &Palette,
ascii: bool,
) -> (Vec<Line<'static>>, Vec<(u16, u16)>) {
let effective_files: Vec<&FileChange> = detail
.files
.iter()
.filter(|f| scoped_patches.is_none_or(|patches| patches.contains_key(&f.path)))
.collect();
if effective_files.is_empty() {
return (
vec![Line::from(Span::styled(
"No files changed".to_owned(),
Style::default().fg(p.dim),
))],
Vec::new(),
);
}
let idx = files_cursor.min(effective_files.len() - 1);
let file = effective_files[idx];
let total = effective_files.len();
let header = Line::from(vec![
Span::styled(format!("[{}/{}] ", idx + 1, total), Style::default().fg(p.dim)),
Span::styled(
file.path.clone(),
Style::default().fg(p.foreground).add_modifier(Modifier::BOLD),
),
Span::styled(" ".to_owned(), Style::default()),
Span::styled(format!("+{}", file.additions), Style::default().fg(p.git_new)),
Span::styled(" ".to_owned(), Style::default()),
Span::styled(format!("\u{2212}{}", file.deletions), Style::default().fg(p.danger)),
]);
let base_hint = if total > 1 {
"J / K: next / previous file \u{00B7} j / k: scroll diff"
} else {
"j / k: scroll diff"
};
let nav_hint_line = Line::from(Span::styled(base_hint.to_owned(), Style::default().fg(p.dim)));
let thread_hint_line = if scoped_patches.is_none() {
thread_index.and_then(|tidx| {
let total_threads = tidx.total_for(&file.path);
if total_threads == 0 {
return None;
}
let unresolved = tidx.unresolved_for(&file.path);
let count_label = if unresolved > 0 {
format!("{total_threads} threads \u{00B7} {unresolved} unresolved")
} else {
format!("{total_threads} threads")
};
let count_color = if unresolved > 0 { p.warning } else { p.muted };
Some(Line::from(vec![
Span::styled(count_label, Style::default().fg(count_color)),
Span::styled(
" \u{00B7} [t] expand at cursor \u{00B7} [T] collapse all".to_owned(),
Style::default().fg(p.dim),
),
]))
})
} else {
None
};
let mut lines = vec![header, nav_hint_line];
if let Some(line) = thread_hint_line {
lines.push(line);
}
lines.push(Line::from(""));
let effective_patch: Option<&str> = if let Some(patches) = scoped_patches {
patches.get(&file.path).and_then(|p| p.as_deref())
} else {
file.patch.as_deref()
};
match effective_patch {
Some(patch) => {
let diff_file = crate::ui::diff::parse_unified_diff(patch);
let effective_thread_index = if scoped_patches.is_some() { None } else { thread_index };
let body = if let Some(index) = effective_thread_index {
render_diff_with_threads(
&diff_file,
index,
expanded,
diff_cursor,
&file.path,
&detail.review_threads,
p,
ascii,
)
} else {
crate::ui::diff::render_diff(&diff_file, p)
};
lines.extend(body);
}
None => {
lines.push(Line::from(Span::styled(
"Patch unavailable — binary file, diff too large, or file-list fetch failed."
.to_owned(),
Style::default().fg(p.dim),
)));
}
}
(lines, Vec::new())
}
#[allow(clippy::too_many_arguments)]
fn render_diff_with_threads(
file: &DiffFile,
index: &ThreadIndex,
expanded: &HashSet<(String, u32)>,
diff_cursor: &RefCell<Option<(String, u32)>>,
file_path: &str,
all_threads: &[ReviewThread],
palette: &Palette,
ascii: bool,
) -> Vec<Line<'static>> {
use crate::ui::diff::render_diff_line;
if file.hunks.is_empty() {
return vec![Line::from(Span::styled(
"(no changes to show)".to_owned(),
Style::default().fg(palette.dim),
))];
}
let mut output: Vec<Line<'static>> = Vec::new();
*diff_cursor.borrow_mut() = None;
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
if hunk_idx > 0 {
output.push(Line::default());
}
let header_coords = format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
);
let mut header_spans = vec![Span::styled(
header_coords,
ratatui::style::Style::default()
.fg(palette.accent)
.add_modifier(ratatui::style::Modifier::BOLD),
)];
if !hunk.section.is_empty() {
header_spans.push(Span::raw(" "));
header_spans.push(Span::styled(
hunk.section.clone(),
ratatui::style::Style::default().fg(palette.dim),
));
}
output.push(Line::from(header_spans));
for diff_line in &hunk.lines {
output.push(render_diff_line(diff_line, palette));
if diff_line.kind == DiffLineKind::NoNewline {
continue;
}
let Some(lineno) = diff_line.new_lineno else {
continue;
};
let thread_indices = index.active_at(file_path, lineno);
if thread_indices.is_empty() {
continue;
}
let threads: Vec<&ReviewThread> =
thread_indices.iter().filter_map(|&i| all_threads.get(i)).collect();
if threads.is_empty() {
continue;
}
*diff_cursor.borrow_mut() = Some((file_path.to_owned(), lineno));
let is_expanded = expanded.contains(&(file_path.to_owned(), lineno));
let card = render_thread_card(&threads, is_expanded, palette, ascii);
output.extend(card);
}
}
let overflow_indices = index.overflow(file_path);
if !overflow_indices.is_empty() {
let overflow_threads: Vec<&ReviewThread> =
overflow_indices.iter().filter_map(|&i| all_threads.get(i)).collect();
if !overflow_threads.is_empty() {
output.push(Line::default());
let rule = if ascii { '-' } else { '\u{254C}' }; let label = format!(" File-level & outdated threads ({}) ", overflow_threads.len());
let rule_str: String = std::iter::repeat_n(rule, 4).collect();
output.push(Line::from(vec![
Span::styled(rule_str.clone(), Style::default().fg(palette.dim)),
Span::styled(label, Style::default().fg(palette.dim)),
Span::styled(rule_str, Style::default().fg(palette.dim)),
]));
for thread in &overflow_threads {
let card = render_thread_card(
std::slice::from_ref(thread),
false, palette,
ascii,
);
output.extend(card);
}
}
}
output
}
pub(super) fn sidebar_file_lines(
detail: &crate::github::detail::PrDetail,
files_cursor: usize,
selected_is_files: bool,
sidebar_inner_width: usize,
thread_index: Option<&super::ThreadIndex>,
p: &Palette,
) -> Vec<Line<'static>> {
let mut sorted_files: Vec<&crate::github::detail::FileChange> = detail.files.iter().collect();
sorted_files.sort_by_key(|f| std::cmp::Reverse(f.additions + f.deletions));
let mut lines = Vec::with_capacity(sorted_files.len());
for (idx, file) in sorted_files.iter().enumerate() {
let glyph = file_kind_glyph(file.change_kind);
let glyph_color = match file.change_kind {
FileChangeKind::Added => p.success,
FileChangeKind::Modified => p.warning,
FileChangeKind::Deleted => p.danger,
FileChangeKind::Renamed => p.accent,
FileChangeKind::Copied | FileChangeKind::Changed => p.muted,
};
let thread_badge: Option<(&'static str, ratatui::style::Color)> =
thread_index.and_then(|idx| {
let total = idx.total_for(&file.path);
if total == 0 {
None
} else if idx.unresolved_for(&file.path) > 0 {
Some(("\u{2691}", p.warning))
} else {
Some(("\u{2713}", p.muted))
}
});
let badge_cols = if thread_badge.is_some() { 2 } else { 0 }; let path_budget = sidebar_inner_width.saturating_sub(2).saturating_sub(badge_cols);
let path = truncate(&file.path, path_budget);
let is_active_file = selected_is_files && idx == files_cursor;
let line_style = if is_active_file {
Style::default().bg(p.selection_bg).fg(p.foreground)
} else {
Style::default()
};
let mut spans = vec![
Span::styled(format!("{glyph} "), Style::default().fg(glyph_color)),
Span::styled(path, line_style.fg(p.foreground)),
];
if let Some((glyph, fg)) = thread_badge {
spans.push(Span::styled(format!(" {glyph}"), line_style.fg(fg)));
}
lines.push(Line::from(spans));
}
lines
}