use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, List, ListItem, Paragraph},
};
use super::super::diff::DiffView;
use super::theme::ThemePalette;
pub fn render_diff_view(f: &mut Frame, diff: &mut DiffView, palette: &ThemePalette) {
let area = f.area();
let chunks = Layout::vertical([
Constraint::Min(1), Constraint::Length(1), ])
.split(area);
let has_files = !diff.file_list.is_empty();
let content_chunks = if has_files {
Layout::horizontal([
Constraint::Min(40), Constraint::Percentage(25), ])
.split(chunks[0])
} else {
Layout::horizontal([Constraint::Percentage(100)]).split(chunks[0])
};
let diff_area = content_chunks[0];
let file_list_area = if has_files {
Some(content_chunks[1])
} else {
None
};
diff.viewport_height = diff_area.height.saturating_sub(2);
if diff.patch_mode {
render_patch_mode(f, diff, diff_area, chunks[1], palette);
if let Some(file_area) = file_list_area {
render_file_list(f, diff, file_area, palette);
}
} else {
render_normal_diff(f, diff, diff_area, chunks[1], palette);
if let Some(file_area) = file_list_area {
render_file_list(f, diff, file_area, palette);
}
}
}
fn get_current_file_index(diff: &DiffView) -> Option<usize> {
if diff.file_list.is_empty() {
return None;
}
if diff.patch_mode && !diff.hunks.is_empty() {
let current_filename = &diff.hunks[diff.current_hunk].filename;
return diff
.file_list
.iter()
.position(|f| &f.filename == current_filename);
}
let mut current_idx = 0;
for (idx, file) in diff.file_list.iter().enumerate() {
if file.start_line <= diff.scroll {
current_idx = idx;
} else {
break;
}
}
Some(current_idx)
}
fn render_file_list(f: &mut Frame, diff: &DiffView, area: Rect, palette: &ThemePalette) {
let current_file_idx = get_current_file_index(diff);
let block = Block::bordered()
.title(format!(" Files ({}) ", diff.file_list.len()))
.title_style(Style::default().fg(palette.header))
.border_style(Style::default().fg(palette.dimmed));
let inner_width = area.width.saturating_sub(2) as usize;
let mut items: Vec<ListItem> = Vec::new();
for (idx, file) in diff.file_list.iter().enumerate() {
let is_current = current_file_idx == Some(idx);
let (status_char, status_color) = if file.is_new {
("A", palette.success)
} else if file.lines_added == 0 && file.lines_removed > 0 {
("D", palette.danger)
} else {
("M", palette.warning)
};
let stats = match (file.lines_added, file.lines_removed) {
(0, 0) => String::new(),
(a, 0) => format!("+{}", a),
(0, r) => format!("-{}", r),
(a, r) => format!("+{} -{}", a, r),
};
let stats_width = if stats.is_empty() { 0 } else { stats.len() + 1 };
let path_max_width = inner_width.saturating_sub(2 + stats_width);
let (dir, basename) = match file.filename.rsplit_once('/') {
Some((d, b)) => (Some(d), b),
None => (None, file.filename.as_str()),
};
let full_path_len = file.filename.len();
let (display_dir, display_basename) = if full_path_len > path_max_width {
if basename.len() >= path_max_width {
let trunc_len = path_max_width.saturating_sub(1); (
None,
format!(
"...{}",
&basename[basename.len().saturating_sub(trunc_len)..]
),
)
} else {
let dir_chars = path_max_width.saturating_sub(2 + basename.len());
match dir {
Some(d) if dir_chars > 0 => {
let start = d.len().saturating_sub(dir_chars);
let truncated = format!("...{}", &d[start..]);
(Some(truncated), basename.to_string())
}
Some(_) => (Some("...".to_string()), basename.to_string()),
None => (None, basename.to_string()),
}
}
} else {
(dir.map(|d| d.to_string()), basename.to_string())
};
let path_len = match &display_dir {
Some(d) => d.len() + 1 + display_basename.len(), None => display_basename.len(),
};
let padding = inner_width
.saturating_sub(2) .saturating_sub(path_len)
.saturating_sub(stats.len())
.max(1);
let mut spans = vec![Span::styled(
format!("{} ", status_char),
Style::default().fg(status_color),
)];
let basename_style = if is_current {
Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
if let Some(d) = display_dir {
spans.push(Span::styled(
format!("{}/", d),
Style::default().fg(palette.dimmed),
));
}
spans.push(Span::styled(display_basename, basename_style));
if !stats.is_empty() {
spans.push(Span::raw(" ".repeat(padding)));
if file.lines_added > 0 && file.lines_removed > 0 {
spans.push(Span::styled(
format!("+{}", file.lines_added),
Style::default().fg(palette.success),
));
spans.push(Span::styled(
format!(" -{}", file.lines_removed),
Style::default().fg(palette.danger),
));
} else if file.lines_added > 0 {
spans.push(Span::styled(stats, Style::default().fg(palette.success)));
} else {
spans.push(Span::styled(stats, Style::default().fg(palette.danger)));
}
}
items.push(ListItem::new(Line::from(spans)));
}
let list = List::new(items).block(block);
f.render_widget(list, area);
}
fn render_normal_diff(
f: &mut Frame,
diff: &DiffView,
content_area: Rect,
footer_area: Rect,
palette: &ThemePalette,
) {
let title = Line::from(vec![
Span::styled(
format!(" {} ", diff.title),
Style::default()
.fg(palette.header)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("+{}", diff.lines_added),
Style::default().fg(palette.success),
),
Span::raw(" "),
Span::styled(
format!("-{}", diff.lines_removed),
Style::default().fg(palette.danger),
),
Span::raw(" "),
]);
let block = Block::bordered()
.title(title)
.border_style(Style::default().fg(palette.border));
let inner_height = content_area.height.saturating_sub(2) as usize;
let max_start = diff.parsed_lines.len().saturating_sub(1);
let start = diff.scroll.min(max_start);
let end = (start + inner_height).min(diff.parsed_lines.len());
let visible_lines: Vec<Line> = diff.parsed_lines[start..end].to_vec();
let text = Text::from(visible_lines);
let paragraph = Paragraph::new(text).block(block);
f.render_widget(paragraph, content_area);
let (wip_style, review_style) = if diff.is_branch_diff {
(
Style::default().fg(palette.dimmed),
Style::default().fg(palette.success),
)
} else {
(
Style::default().fg(palette.success),
Style::default().fg(palette.dimmed),
)
};
let dimmed = Style::default().fg(palette.dimmed);
let bold_text = Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD);
let pipe = || -> Span<'_> { Span::styled(" \u{2502} ", Style::default().fg(palette.border)) };
let mut footer_spans = vec![
Span::raw(" "),
Span::styled("Tab", dimmed),
Span::raw(" "),
Span::styled("WIP", wip_style),
Span::styled(" | ", dimmed),
Span::styled("review", review_style),
];
if !diff.is_branch_diff && (diff.lines_added > 0 || diff.lines_removed > 0) {
footer_spans.push(pipe());
footer_spans.push(Span::styled("a", dimmed));
footer_spans.push(Span::styled(" Patch", bold_text));
}
footer_spans.push(pipe());
footer_spans.push(Span::styled("j/k", dimmed));
footer_spans.push(Span::styled(" Scroll", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("c", dimmed));
footer_spans.push(Span::styled(" Commit", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("m", dimmed));
footer_spans.push(Span::styled(" Merge", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("q", dimmed));
footer_spans.push(Span::styled(" Close", bold_text));
let footer = Paragraph::new(Line::from(footer_spans));
f.render_widget(footer, footer_area);
}
fn render_patch_mode(
f: &mut Frame,
diff: &DiffView,
content_area: Rect,
footer_area: Rect,
palette: &ThemePalette,
) {
let hunk = &diff.hunks[diff.current_hunk];
let title = Line::from(vec![
Span::styled(
" PATCH ",
Style::default()
.fg(palette.current_row_bg)
.bg(palette.accent)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
&hunk.filename,
Style::default()
.fg(palette.header)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!(
"[{}/{}]",
diff.hunks_processed + diff.current_hunk + 1,
diff.hunks_total
),
Style::default().fg(palette.keycap),
),
Span::raw(" "),
Span::styled(
format!("+{}", hunk.lines_added),
Style::default().fg(palette.success),
),
Span::raw(" "),
Span::styled(
format!("-{}", hunk.lines_removed),
Style::default().fg(palette.danger),
),
Span::raw(" "),
]);
let block = Block::bordered()
.title(title)
.border_style(Style::default().fg(palette.accent));
let inner_height = content_area.height.saturating_sub(2) as usize;
let max_start = hunk.parsed_lines.len().saturating_sub(1);
let start = diff.scroll.min(max_start);
let end = (start + inner_height).min(hunk.parsed_lines.len());
let visible_lines: Vec<Line> = hunk.parsed_lines[start..end].to_vec();
let text = Text::from(visible_lines);
let paragraph = Paragraph::new(text).block(block);
f.render_widget(paragraph, content_area);
let dimmed = Style::default().fg(palette.dimmed);
let bold_text = Style::default()
.fg(palette.text)
.add_modifier(Modifier::BOLD);
let pipe = || -> Span<'_> { Span::styled(" \u{2502} ", Style::default().fg(palette.border)) };
if let Some(ref input) = diff.comment_input {
let mut spans = vec![
Span::raw(" "),
Span::styled("Enter", dimmed),
Span::styled(" Send", bold_text),
pipe(),
Span::styled("Esc", dimmed),
Span::styled(" Cancel", bold_text),
Span::raw(" "),
Span::styled("\u{2502} ", Style::default().fg(palette.border)),
];
if input.is_empty() {
spans.push(Span::styled("|", Style::default().fg(palette.text)));
spans.push(Span::styled("Type your comment...", dimmed));
} else {
spans.push(Span::raw(input));
spans.push(Span::styled("|", Style::default().fg(palette.text)));
}
let footer = Paragraph::new(Line::from(spans));
f.render_widget(footer, footer_area);
} else {
let mut footer_spans = vec![
Span::raw(" "),
Span::styled("y", dimmed),
Span::styled(" Stage", bold_text),
pipe(),
Span::styled("n", dimmed),
Span::styled(" Skip", bold_text),
];
if !diff.staged_hunks.is_empty() {
footer_spans.push(pipe());
footer_spans.push(Span::styled("u", dimmed));
footer_spans.push(Span::styled(" Undo", bold_text));
}
footer_spans.push(pipe());
footer_spans.push(Span::styled("s", dimmed));
footer_spans.push(Span::styled(" Split", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("o", dimmed));
footer_spans.push(Span::styled(" Comment", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("j/k", dimmed));
footer_spans.push(Span::styled(" Nav", bold_text));
footer_spans.push(pipe());
footer_spans.push(Span::styled("q", dimmed));
footer_spans.push(Span::styled(" Quit", bold_text));
let footer = Paragraph::new(Line::from(footer_spans));
f.render_widget(footer, footer_area);
}
}