use std::time::{SystemTime, UNIX_EPOCH};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
};
use crate::app::{App, RowKind};
use crate::git::{DiffContent, FileDiff, FileStatus, Hunk, LineKind};
const SCROLL_ROW_LIMIT: usize = 2000;
#[cfg(test)]
const BG_ADDED: Color = Color::Rgb(10, 50, 10);
#[cfg(test)]
const BG_DELETED: Color = Color::Rgb(60, 10, 10);
pub fn render(frame: &mut Frame<'_>, app: &App) {
let area = frame.area();
let input_line: Option<(String, &str, usize)> = if let Some(state) = app.scar_comment.as_ref() {
Some((state.body.clone(), "> ", state.cursor_pos))
} else if let Some(input) = app.search_input.as_ref() {
Some((input.query.clone(), "/", input.cursor_pos))
} else {
None
};
let input_height: u16 = if let Some((ref text, prefix, _)) = input_line {
use unicode_width::UnicodeWidthStr;
let total_width = prefix.width() + text.width() + 1; let w = (area.width as usize).max(1);
total_width.div_ceil(w).max(1) as u16
} else {
0
};
let chunks = Layout::vertical([
Constraint::Min(0),
Constraint::Length(input_height),
Constraint::Length(1),
])
.split(area);
let main = chunks[0];
let input_area = chunks[1];
let footer = chunks[2];
if let Some(fv) = app.file_view.as_ref() {
let hl = app
.highlighter
.get_or_init(crate::highlight::Highlighter::new);
let effective_top = if let Some(anim) = &fv.anim {
let (v, _done) = anim.sample(fv.scroll_top as f32, std::time::Instant::now());
v.round() as usize
} else {
fv.scroll_top
};
render_file_view(frame, main, fv, Some(hl), effective_top);
} else if app.files.is_empty() {
render_empty(frame, main, app);
} else {
render_scroll(frame, main, app);
}
if let Some((text, prefix, cursor_pos)) = input_line {
render_input_line(frame, input_area, prefix, &text, cursor_pos);
}
render_footer(frame, footer, app);
if app.picker.is_some() {
render_picker(frame, area, app);
}
}
fn render_input_line(
frame: &mut Frame<'_>,
area: Rect,
prefix: &str,
text: &str,
cursor_pos: usize,
) {
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthStr;
let before_cursor: String = text.chars().take(cursor_pos).collect();
let cursor_char: String = text
.chars()
.nth(cursor_pos)
.map(|c| c.to_string())
.unwrap_or_default();
let after_cursor: String = text
.chars()
.skip(cursor_pos + cursor_char.chars().count())
.collect();
let mut spans = vec![
Span::styled(
prefix.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
),
Span::styled(before_cursor.clone(), Style::default().fg(Color::White)),
];
if cursor_char.is_empty() {
spans.push(Span::styled(
" ",
Style::default().fg(Color::Black).bg(Color::White),
));
} else {
spans.push(Span::styled(
cursor_char,
Style::default().fg(Color::Black).bg(Color::White),
));
spans.push(Span::styled(
after_cursor,
Style::default().fg(Color::White),
));
}
let paragraph = Paragraph::new(Line::from(spans)).wrap(Wrap { trim: false });
frame.render_widget(paragraph, area);
let cursor_display_offset = prefix.width() + before_cursor.width();
let w = area.width.max(1) as usize;
let cursor_y = area.y + (cursor_display_offset / w) as u16;
let cursor_x = area.x + (cursor_display_offset % w) as u16;
frame.set_cursor_position((
cursor_x.min(area.right().saturating_sub(1)),
cursor_y.min(area.bottom().saturating_sub(1)),
));
}
fn render_scroll(frame: &mut Frame<'_>, area: Rect, app: &App) {
let total_rows = app.layout.rows.len();
let selected = app.current_hunk();
let cursor_row = app.scroll;
let now = std::time::Instant::now();
let wrap_body_width: Option<usize> = if app.wrap_lines {
Some((area.width as usize).saturating_sub(6).max(1))
} else {
None
};
let nowrap_body_width: usize = (area.width as usize).saturating_sub(5).max(1);
let raw_body_height = area.height as usize;
let candidate_header = selected.and_then(|(file_idx, hunk_idx)| {
find_hunk_header_row(&app.layout.rows, file_idx, hunk_idx)
.map(|row| (row, file_idx, hunk_idx))
});
let (sticky, body_height, viewport_top, skip_visual) = match candidate_header {
Some((header_row, file_idx, hunk_idx)) if raw_body_height > 1 => {
let reduced = raw_body_height - 1;
let (top_reduced, skip_reduced) = app.viewport_placement(reduced, wrap_body_width, now);
if header_row < top_reduced {
(
Some((file_idx, hunk_idx)),
reduced,
top_reduced,
skip_reduced,
)
} else {
let (top_full, skip_full) =
app.viewport_placement(raw_body_height, wrap_body_width, now);
(None, raw_body_height, top_full, skip_full)
}
}
_ => {
let (top_full, skip_full) =
app.viewport_placement(raw_body_height, wrap_body_width, now);
(None, raw_body_height, top_full, skip_full)
}
};
let (header_area, content_area) = if sticky.is_some() {
let header = Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
};
let body = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: area.height - 1,
};
(Some(header), body)
} else {
(None, area)
};
let viewport_height = body_height;
app.last_body_height.set(viewport_height);
app.last_body_width.set(wrap_body_width);
let mut lines: Vec<Line<'static>> = Vec::with_capacity(viewport_height);
let mut row_idx = viewport_top;
let mut skip_remaining = skip_visual;
let mut cursor_viewport_line: Option<usize> = None;
while row_idx < total_rows && lines.len() < viewport_height {
let cursor_sub = if row_idx == cursor_row {
Some(app.cursor_sub_row)
} else {
None
};
let hl = app
.highlighter
.get_or_init(crate::highlight::Highlighter::new);
let ctx = RowRenderCtx {
files: &app.files,
selected_hunk: selected,
cursor_sub,
wrap_body_width,
nowrap_body_width,
seen_hunks: &app.seen_hunks,
hl: Some(hl),
bg_added: app.config.colors.bg_added_color(),
bg_deleted: app.config.colors.bg_deleted_color(),
};
let row_lines = render_row(&app.layout.rows[row_idx], &ctx);
let mut take = row_lines.into_iter();
let initial_skip = skip_remaining;
for _ in 0..skip_remaining {
if take.next().is_none() {
break;
}
}
skip_remaining = 0;
let row_start_line = lines.len();
for line in take {
if lines.len() >= viewport_height {
break;
}
lines.push(line);
}
if row_idx == cursor_row && cursor_viewport_line.is_none() {
let sub_in_view = app.cursor_sub_row.saturating_sub(initial_skip);
let candidate = row_start_line + sub_in_view;
if candidate < lines.len() {
cursor_viewport_line = Some(candidate);
}
}
row_idx += 1;
}
if total_rows > SCROLL_ROW_LIMIT && row_idx < total_rows {
let remaining = total_rows - row_idx;
if remaining > 0 && lines.len() < viewport_height {
lines.push(Line::from(Span::styled(
format!("[+{remaining} more rows]"),
Style::default().fg(Color::DarkGray),
)));
}
}
frame.render_widget(Paragraph::new(lines), content_area);
apply_cursor_gutter_tint(frame, content_area, cursor_viewport_line);
if let (Some(header_rect), Some((file_idx, hunk_idx))) = (header_area, sticky)
&& let DiffContent::Text(hunks) = &app.files[file_idx].content
{
let line = render_hunk_header(
&hunks[hunk_idx],
true,
false,
app.hunk_is_seen(file_idx, hunk_idx),
);
frame.render_widget(Paragraph::new(line), header_rect);
}
}
fn apply_cursor_gutter_tint(
frame: &mut Frame<'_>,
content_area: Rect,
cursor_viewport_line: Option<usize>,
) {
let Some(line_in_viewport) = cursor_viewport_line else {
return;
};
if line_in_viewport >= content_area.height as usize {
return;
}
let y = content_area.y + line_in_viewport as u16;
let end_x = content_area.x.saturating_add(content_area.width);
let buf = frame.buffer_mut();
for x in content_area.x..end_x {
if let Some(cell) = buf.cell_mut((x, y)) {
let darkened = darken_cursor_body_bg(cell.style().bg);
let new_style = cell.style().bg(darkened);
cell.set_style(new_style);
}
}
}
fn darken_cursor_body_bg(existing: Option<Color>) -> Color {
const FACTOR: f32 = 0.75;
const DEFAULT_DIM: Color = Color::Rgb(30, 30, 36);
match existing {
Some(Color::Rgb(r, g, b)) => Color::Rgb(
(r as f32 * FACTOR) as u8,
(g as f32 * FACTOR) as u8,
(b as f32 * FACTOR) as u8,
),
_ => DEFAULT_DIM,
}
}
fn find_hunk_header_row(rows: &[RowKind], file_idx: usize, hunk_idx: usize) -> Option<usize> {
rows.iter().position(|r| {
matches!(
r,
RowKind::HunkHeader {
file_idx: f,
hunk_idx: h,
} if *f == file_idx && *h == hunk_idx
)
})
}
struct RowRenderCtx<'a> {
files: &'a [FileDiff],
selected_hunk: Option<(usize, usize)>,
cursor_sub: Option<usize>,
wrap_body_width: Option<usize>,
nowrap_body_width: usize,
seen_hunks: &'a std::collections::BTreeSet<(std::path::PathBuf, usize)>,
hl: Option<&'a crate::highlight::Highlighter>,
bg_added: Color,
bg_deleted: Color,
}
fn render_row(row: &RowKind, ctx: &RowRenderCtx<'_>) -> Vec<Line<'static>> {
let files = ctx.files;
let selected_hunk = ctx.selected_hunk;
let cursor_sub = ctx.cursor_sub;
let wrap_body_width = ctx.wrap_body_width;
let nowrap_body_width = ctx.nowrap_body_width;
let seen_hunks = ctx.seen_hunks;
let hl = ctx.hl;
match row {
RowKind::FileHeader { file_idx } => {
vec![render_file_header(&files[*file_idx], cursor_sub.is_some())]
}
RowKind::HunkHeader { file_idx, hunk_idx } => {
let DiffContent::Text(hunks) = &files[*file_idx].content else {
return vec![Line::raw("")];
};
let is_selected = selected_hunk == Some((*file_idx, *hunk_idx));
vec![render_hunk_header(
&hunks[*hunk_idx],
is_selected,
cursor_sub.is_some(),
crate::app::is_hunk_seen(
seen_hunks,
&files[*file_idx].path,
hunks[*hunk_idx].old_start,
),
)]
}
RowKind::DiffLine {
file_idx,
hunk_idx,
line_idx,
} => {
let DiffContent::Text(hunks) = &files[*file_idx].content else {
return vec![Line::raw("")];
};
let is_selected = selected_hunk == Some((*file_idx, *hunk_idx));
let line = &hunks[*hunk_idx].lines[*line_idx];
let is_cursor = cursor_sub.is_some();
match wrap_body_width {
Some(width) => render_diff_line_wrapped(
line,
is_selected,
cursor_sub,
width,
hl,
Some(&files[*file_idx].path),
ctx.bg_added,
ctx.bg_deleted,
),
None => vec![render_diff_line(
line,
is_selected,
is_cursor,
nowrap_body_width,
hl,
Some(&files[*file_idx].path),
ctx.bg_added,
ctx.bg_deleted,
)],
}
}
RowKind::BinaryNotice { .. } => vec![Line::from(Span::styled(
if cursor_sub.is_some() {
" ▶ [binary file - diff suppressed]"
} else {
" [binary file - diff suppressed]"
},
Style::default().fg(Color::DarkGray),
))],
RowKind::Spacer => vec![Line::raw("")],
}
}
fn wrap_at_chars(content: &str, width: usize) -> Vec<&str> {
use unicode_width::UnicodeWidthChar;
if content.is_empty() || width == 0 {
return vec![content];
}
let mut chunks = Vec::new();
let mut chunk_start = 0usize;
let mut chunk_cells = 0usize;
for (idx, ch) in content.char_indices() {
let ch_cells = ch.width().unwrap_or(0);
if chunk_cells > 0 && chunk_cells + ch_cells > width {
chunks.push(&content[chunk_start..idx]);
chunk_start = idx;
chunk_cells = 0;
}
chunk_cells += ch_cells;
}
if chunk_start < content.len() {
chunks.push(&content[chunk_start..]);
}
if chunks.is_empty() {
chunks.push(content);
}
chunks
}
#[allow(clippy::too_many_arguments)]
fn render_diff_line_wrapped(
line: &crate::git::DiffLine,
is_selected: bool,
cursor_sub: Option<usize>,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
) -> Vec<Line<'static>> {
use unicode_width::UnicodeWidthStr;
let bg = match line.kind {
LineKind::Added => Some(bg_added),
LineKind::Deleted => Some(bg_deleted),
LineKind::Context => None,
};
let base_style = match (bg, is_selected) {
(Some(b), true) => Style::default().bg(b),
(Some(b), false) => Style::default().bg(b).add_modifier(Modifier::DIM),
(None, true) => Style::default(),
(None, false) => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
};
let marker_style = match bg {
Some(b) => Style::default()
.bg(b)
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
None => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
};
let tokens: Option<Vec<crate::highlight::HlToken>> =
if let (Some(hl), Some(path)) = (hl, file_path) {
let toks = hl.highlight_line(&line.content, path);
if toks.len() > 1 || toks.first().is_some_and(|t| t.fg != Color::Reset) {
Some(toks)
} else {
None
}
} else {
None
};
let char_colors: Vec<Color> = if let Some(ref toks) = tokens {
let mut colors = Vec::with_capacity(line.content.len());
for tok in toks {
for _ in tok.text.chars() {
colors.push(tok.fg);
}
}
colors
} else {
Vec::new()
};
let chunks = wrap_at_chars(&line.content, body_width.max(1));
let last_idx = chunks.len().saturating_sub(1);
let mut char_offset = 0usize;
chunks
.into_iter()
.enumerate()
.map(|(i, chunk)| {
let is_last = i == last_idx;
let cursor_line = cursor_sub.map(|s| s.min(last_idx));
let bar = if cursor_line == Some(i) {
Span::styled(
" ▶ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else if is_selected {
Span::styled(" ▎ ", Style::default().fg(Color::Yellow))
} else {
Span::raw(" ")
};
let marker_reserve = if is_last && line.has_trailing_newline {
1
} else {
0
};
let chunk_char_count = chunk.chars().count();
let chunk_cell_count = UnicodeWidthStr::width(chunk);
let pad = body_width.saturating_sub(chunk_cell_count + marker_reserve);
let mut spans = vec![bar];
if !char_colors.is_empty() {
let chunk_colors = &char_colors[char_offset..char_offset + chunk_char_count];
let mut run_start = 0usize;
let chunk_chars: Vec<char> = chunk.chars().collect();
while run_start < chunk_chars.len() {
let run_color = chunk_colors[run_start];
let run_end = (run_start + 1..chunk_chars.len())
.find(|&j| chunk_colors[j] != run_color)
.unwrap_or(chunk_chars.len());
let text: String = chunk_chars[run_start..run_end].iter().collect();
spans.push(Span::styled(text, base_style.fg(run_color)));
run_start = run_end;
}
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), base_style));
}
} else {
let padded_body: String =
chunk.chars().chain(std::iter::repeat_n(' ', pad)).collect();
spans.push(Span::styled(padded_body, base_style));
}
if is_last && line.has_trailing_newline {
spans.push(Span::styled("¶", marker_style));
}
char_offset += chunk_char_count;
Line::from(spans)
})
.collect()
}
fn render_file_header(file: &FileDiff, is_cursor: bool) -> Line<'static> {
let path_color = match file.status {
FileStatus::Modified => Color::Cyan,
FileStatus::Added => Color::Green,
FileStatus::Deleted => Color::Red,
FileStatus::Untracked => Color::Yellow,
};
let counts = match &file.content {
DiffContent::Binary => "bin".to_string(),
DiffContent::Text(_) => format!("+{} -{}", file.added, file.deleted),
};
let mtime = format_mtime(file.mtime);
let mut spans = vec![if is_cursor {
Span::styled(
"▶ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
}];
if let Some(prefix) = &file.header_prefix {
spans.push(Span::styled(
format!("{prefix} "),
Style::default().fg(Color::DarkGray),
));
}
spans.extend([
Span::styled(
file.path.display().to_string(),
Style::default().fg(path_color).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(mtime, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::raw(counts),
]);
spans.push(Span::raw(""));
Line::from(spans)
}
fn render_hunk_header(
hunk: &Hunk,
is_selected: bool,
is_cursor: bool,
is_seen: bool,
) -> Line<'static> {
let seen_mark = if is_seen { "• " } else { " " };
let cursor_mark = if is_cursor { " ▶ " } else { " " };
let added: usize = hunk
.lines
.iter()
.filter(|l| l.kind == LineKind::Added)
.count();
let deleted: usize = hunk
.lines
.iter()
.filter(|l| l.kind == LineKind::Deleted)
.count();
let counts = format!("+{added}/-{deleted}");
let line_range = if hunk.new_count > 1 {
format!(
"L{}-{}",
hunk.new_start,
hunk.new_start + hunk.new_count - 1
)
} else {
format!("L{}", hunk.new_start)
};
let body = match &hunk.context {
Some(ctx) => format!("{cursor_mark}{seen_mark}@@ {ctx} {line_range} {counts}"),
None => format!("{cursor_mark}{seen_mark}@@ {line_range} {counts}"),
};
let mut style = Style::default().fg(Color::Cyan);
if !is_selected {
style = style.add_modifier(Modifier::DIM);
}
Line::from(Span::styled(body, style))
}
#[allow(clippy::too_many_arguments)]
fn render_diff_line(
line: &crate::git::DiffLine,
is_selected: bool,
is_cursor: bool,
body_width: usize,
hl: Option<&crate::highlight::Highlighter>,
file_path: Option<&std::path::Path>,
bg_added: Color,
bg_deleted: Color,
) -> Line<'static> {
let bg = match line.kind {
LineKind::Added => Some(bg_added),
LineKind::Deleted => Some(bg_deleted),
LineKind::Context => None,
};
let bar = if is_cursor {
Span::styled(
" ▶ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else if is_selected {
Span::styled(" ▎ ", Style::default().fg(Color::Yellow))
} else {
Span::raw(" ")
};
let base_style = match (bg, is_selected) {
(Some(b), true) => Style::default().bg(b),
(Some(b), false) => Style::default().bg(b).add_modifier(Modifier::DIM),
(None, true) => Style::default(),
(None, false) => Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::DIM),
};
if let (Some(hl), Some(path)) = (hl, file_path) {
let tokens = hl.highlight_line(&line.content, path);
if tokens.len() > 1 || tokens.first().is_some_and(|t| t.fg != Color::Reset) {
let mut spans = vec![bar];
let mut cells_emitted = 0usize;
for token in &tokens {
let remaining = body_width.saturating_sub(cells_emitted);
if remaining == 0 {
break;
}
let (text, token_cells) = take_cells(&token.text, remaining);
if text.is_empty() {
break;
}
spans.push(Span::styled(text, base_style.fg(token.fg)));
cells_emitted += token_cells;
}
if cells_emitted < body_width {
spans.push(Span::styled(
" ".repeat(body_width - cells_emitted),
base_style,
));
}
return Line::from(spans);
}
}
use unicode_width::UnicodeWidthStr;
let content_cells = UnicodeWidthStr::width(line.content.as_str());
let padded_body: String = if content_cells >= body_width {
let (truncated, _) = take_cells(&line.content, body_width);
truncated
} else {
let pad = body_width - content_cells;
line.content
.chars()
.chain(std::iter::repeat_n(' ', pad))
.collect()
};
Line::from(vec![bar, Span::styled(padded_body, base_style)])
}
fn take_cells(s: &str, max_cells: usize) -> (String, usize) {
use unicode_width::UnicodeWidthChar;
let mut out = String::new();
let mut cells = 0usize;
for ch in s.chars() {
let w = ch.width().unwrap_or(0);
if cells + w > max_cells {
break;
}
out.push(ch);
cells += w;
}
(out, cells)
}
fn format_mtime(t: SystemTime) -> String {
if t == UNIX_EPOCH {
return "--:--".to_string();
}
let secs = t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0) as i64;
#[cfg(unix)]
{
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let time_t = secs as libc::time_t;
unsafe { libc::localtime_r(&time_t, &mut tm) };
format!("{:02}:{:02}", tm.tm_hour, tm.tm_min)
}
#[cfg(not(unix))]
{
let day_secs = (secs as u64) % 86_400;
let hour = (day_secs / 3600) as u32;
let minute = ((day_secs % 3600) / 60) as u32;
format!("{hour:02}:{minute:02}")
}
}
fn render_empty(frame: &mut Frame<'_>, area: Rect, app: &App) {
let short = app
.baseline_sha
.get(..7)
.unwrap_or(&app.baseline_sha)
.to_string();
let body = format!("No changes since baseline (baseline: {short})");
let mid = centered_line(area);
let p = Paragraph::new(body).alignment(Alignment::Center);
frame.render_widget(p, mid);
}
fn render_file_view(
frame: &mut Frame<'_>,
area: Rect,
fv: &crate::app::FileViewState,
hl: Option<&crate::highlight::Highlighter>,
effective_top: usize,
) {
let height = area.height as usize;
let width = area.width as usize;
let mut lines: Vec<Line<'static>> = Vec::with_capacity(height);
for i in 0..height {
let line_idx = effective_top + i;
if line_idx >= fv.lines.len() {
lines.push(Line::from(Span::styled(
"~",
Style::default().fg(Color::DarkGray),
)));
continue;
}
let is_cursor = line_idx == fv.cursor;
let bar = if is_cursor {
Span::styled(
" ▶ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
};
let content = &fv.lines[line_idx];
let body_width = width.saturating_sub(5).max(1);
let char_len = content.chars().count();
let padded: String = if char_len >= body_width {
content.chars().take(body_width).collect()
} else {
content
.chars()
.chain(std::iter::repeat_n(' ', body_width - char_len))
.collect()
};
let base_style = if let Some(&bg) = fv.line_bg.get(&line_idx) {
Style::default().bg(bg)
} else {
Style::default()
};
if let Some(hl) = hl {
let tokens = hl.highlight_line(content, &fv.path);
if tokens.len() > 1 || tokens.first().is_some_and(|t| t.fg != Color::Reset) {
let mut spans = vec![bar];
let mut chars_emitted = 0;
for token in &tokens {
let remaining = body_width.saturating_sub(chars_emitted);
if remaining == 0 {
break;
}
let take = token.text.chars().count().min(remaining);
let text: String = token.text.chars().take(take).collect();
spans.push(Span::styled(text, base_style.fg(token.fg)));
chars_emitted += take;
}
if chars_emitted < body_width {
spans.push(Span::styled(
" ".repeat(body_width - chars_emitted),
base_style,
));
}
lines.push(Line::from(spans));
continue;
}
}
lines.push(Line::from(vec![bar, Span::styled(padded, base_style)]));
}
frame.render_widget(Paragraph::new(lines), area);
}
pub fn format_local_time(timestamp_ms: u64) -> String {
let epoch_secs = (timestamp_ms / 1000) as i64;
#[cfg(unix)]
{
let mut tm: libc::tm = unsafe { std::mem::zeroed() };
let time_t = epoch_secs as libc::time_t;
unsafe { libc::localtime_r(&time_t, &mut tm) };
format!("{:02}:{:02}:{:02}", tm.tm_hour, tm.tm_min, tm.tm_sec)
}
#[cfg(not(unix))]
{
let secs = epoch_secs as u64;
let hours = (secs / 3600) % 24;
let mins = (secs / 60) % 60;
let s = secs % 60;
format!("{hours:02}:{mins:02}:{s:02}")
}
}
fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) {
let dim = Style::default().fg(Color::DarkGray);
let bold = Modifier::BOLD;
let sep = || Span::styled(" │ ", dim);
let (mode_text, mode_color) = if app.picker.is_some() {
("[picker]", Color::Magenta)
} else if app.scar_comment.is_some() {
("[scar]", Color::Magenta)
} else if app.revert_confirm.is_some() {
("[revert?]", Color::Red)
} else if app.search_input.is_some() {
("[search]", Color::Yellow)
} else if app.file_view.is_some() {
("[file view]", Color::Cyan)
} else if app.view_mode == crate::app::ViewMode::Stream {
("[stream]", Color::Blue)
} else if app.follow_mode {
("[follow]", Color::Green)
} else {
("[manual]", Color::Yellow)
};
let mode_span = Span::styled(
mode_text,
Style::default().fg(mode_color).add_modifier(bold),
);
let mut spans: Vec<Span<'static>> = vec![Span::raw(" "), mode_span, Span::raw(" ")];
if app.picker.is_some() {
spans.push(sep());
spans.push(Span::styled(
"type to filter",
Style::default().fg(Color::Yellow),
));
spans.push(Span::styled(" / ", dim));
spans.push(Span::styled(
"↑↓ Ctrl-n/p",
Style::default().fg(Color::Cyan),
));
spans.push(Span::raw(" "));
spans.push(Span::styled("move", dim));
spans.push(Span::styled(" / ", dim));
spans.push(Span::styled("Enter", Style::default().fg(Color::Green)));
spans.push(Span::raw(" "));
spans.push(Span::styled("jump", dim));
spans.push(Span::styled(" / ", dim));
spans.push(Span::styled("Esc", Style::default().fg(Color::Red)));
spans.push(Span::raw(" "));
spans.push(Span::styled("cancel", dim));
} else if let Some(fv) = app.file_view.as_ref() {
spans.push(sep());
spans.push(Span::styled(
fv.path.display().to_string(),
Style::default().fg(Color::Cyan).add_modifier(bold),
));
spans.push(Span::styled(
format!(" [{}/{}]", fv.cursor + 1, fv.lines.len()),
Style::default().fg(Color::DarkGray),
));
spans.push(sep());
spans.push(Span::styled("Enter", Style::default().fg(Color::Green)));
spans.push(Span::styled("/", dim));
spans.push(Span::styled("Esc", Style::default().fg(Color::Red)));
spans.push(Span::raw(" "));
spans.push(Span::styled("back", dim));
} else if app.search_input.is_some() {
spans.push(sep());
spans.push(Span::styled("Enter", Style::default().fg(Color::Green)));
spans.push(Span::raw(" "));
spans.push(Span::styled("find", dim));
spans.push(Span::styled(" / ", dim));
spans.push(Span::styled("Esc", Style::default().fg(Color::Red)));
spans.push(Span::raw(" "));
spans.push(Span::styled("cancel", dim));
} else if let Some(state) = app.revert_confirm.as_ref() {
spans.push(sep());
spans.push(Span::styled(
format!("revert hunk in {} ?", state.file_path.display()),
Style::default().fg(Color::Red).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled("(y/N)", Style::default().fg(Color::Yellow)));
} else if app.scar_comment.is_some() {
spans.push(sep());
spans.push(Span::styled("Enter", Style::default().fg(Color::Green)));
spans.push(Span::raw(" "));
spans.push(Span::styled("save", dim));
spans.push(Span::styled(" / ", dim));
spans.push(Span::styled("Esc", Style::default().fg(Color::Red)));
spans.push(Span::raw(" "));
spans.push(Span::styled("cancel", dim));
} else {
let current_path = app
.current_file_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "--".to_string());
let path_color = app
.current_file_idx()
.and_then(|i| app.files.get(i))
.map(|f| match f.status {
FileStatus::Modified => Color::Cyan,
FileStatus::Added => Color::Green,
FileStatus::Deleted => Color::Red,
FileStatus::Untracked => Color::Yellow,
})
.unwrap_or(Color::Reset);
spans.push(sep());
spans.push(Span::styled(
current_path,
Style::default().fg(path_color).add_modifier(bold),
));
let session_added: usize = app.files.iter().map(|f| f.added).sum();
let session_deleted: usize = app.files.iter().map(|f| f.deleted).sum();
spans.push(sep());
spans.push(Span::styled("session", dim));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("+{session_added}"),
Style::default().fg(Color::Green).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("-{session_deleted}"),
Style::default().fg(Color::Red).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{} files", app.files.len()),
Style::default().fg(Color::Cyan),
));
if app.head_dirty {
spans.push(Span::raw(" "));
spans.push(Span::styled(
"HEAD*",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
}
spans.push(sep());
spans.push(Span::styled("z", Style::default().fg(Color::Cyan)));
spans.push(Span::raw(" "));
spans.push(Span::styled(
app.cursor_placement.label(),
Style::default().fg(Color::Cyan).add_modifier(bold),
));
spans.push(sep());
spans.push(Span::styled("w", Style::default().fg(Color::Cyan)));
spans.push(Span::raw(" "));
spans.push(Span::styled(
if app.wrap_lines { "wrap" } else { "nowrap" },
Style::default().fg(Color::Cyan).add_modifier(bold),
));
spans.push(sep());
spans.push(Span::styled("s", Style::default().fg(Color::Magenta)));
spans.push(Span::raw(" "));
spans.push(Span::styled("picker", dim));
}
if let Some(msg) = app.watcher_health.summary() {
spans.push(sep());
spans.push(Span::styled(
"⚠ WATCHER",
Style::default().fg(Color::Red).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(msg, Style::default().fg(Color::Red)));
}
if let Some(msg) = &app.input_health {
spans.push(sep());
spans.push(Span::styled(
"⚠ INPUT",
Style::default().fg(Color::Red).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(msg.clone(), Style::default().fg(Color::Red)));
}
if let Some(err) = &app.last_error {
spans.push(sep());
spans.push(Span::styled(
"×",
Style::default().fg(Color::Red).add_modifier(bold),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(err.clone(), Style::default().fg(Color::Red)));
}
let line = Line::from(spans);
frame.render_widget(Paragraph::new(line), area);
}
fn pad_or_truncate_display(s: &str, target: usize) -> String {
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
let w = UnicodeWidthStr::width(s);
if w <= target {
let pad = target - w;
let mut out = String::with_capacity(s.len() + pad);
out.push_str(s);
for _ in 0..pad {
out.push(' ');
}
return out;
}
let keep = target.saturating_sub(1);
let mut acc = 0usize;
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if acc + cw > keep {
break;
}
acc += cw;
out.push(ch);
}
if target > acc {
out.push('…');
acc += 1;
}
while acc < target {
out.push(' ');
acc += 1;
}
out
}
fn render_picker(frame: &mut Frame<'_>, area: Rect, app: &App) {
let popup_area = centered_rect(60, 60, area);
let Some(picker) = &app.picker else { return };
let results = app.picker_results();
frame.render_widget(Clear, popup_area);
let block = Block::default().borders(Borders::ALL).title(format!(
" Files {}/{} ",
results.len(),
app.files.len()
));
let inner = block.inner(popup_area);
frame.render_widget(block, popup_area);
let chunks = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
let query_area = chunks[0];
let list_area = chunks[1];
let query_line = Line::from(vec![
Span::styled("> ", Style::default().fg(Color::Yellow)),
Span::raw(picker.query.clone()),
]);
frame.render_widget(Paragraph::new(query_line), query_area);
let list_width = list_area.width as usize;
const MTIME_WIDTH: usize = 5; const COUNTS_WIDTH: usize = 10; const GAP: usize = 1;
const GUTTER: usize = 2; let reserved = MTIME_WIDTH + GAP + COUNTS_WIDTH + GAP;
let path_width = list_width.saturating_sub(reserved + GUTTER).max(10);
let items: Vec<ListItem<'_>> = results
.iter()
.map(|&file_idx| {
let file = &app.files[file_idx];
let path_color = match file.status {
FileStatus::Modified => Color::Cyan,
FileStatus::Added => Color::Green,
FileStatus::Deleted => Color::Red,
FileStatus::Untracked => Color::Yellow,
};
let counts = match &file.content {
DiffContent::Binary => "bin".to_string(),
DiffContent::Text(_) => format!("+{} -{}", file.added, file.deleted),
};
let mtime = format_mtime(file.mtime);
let path_str = file.path.display().to_string();
let padded_path = pad_or_truncate_display(&path_str, path_width);
let padded_counts = format!("{counts:>width$}", width = COUNTS_WIDTH);
ListItem::new(Line::from(vec![
Span::styled(padded_path, Style::default().fg(path_color)),
Span::raw(" "),
Span::styled(mtime, Style::default().fg(Color::DarkGray)),
Span::raw(" "),
Span::raw(padded_counts),
]))
})
.collect();
let list = List::new(items)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED))
.highlight_symbol("▸ ");
let mut state = ListState::default();
if !results.is_empty() {
state.select(Some(picker.cursor.min(results.len() - 1)));
}
frame.render_stateful_widget(list, list_area, &mut state);
}
fn centered_line(area: Rect) -> Rect {
let row = area.y + area.height / 2;
Rect {
x: area.x,
y: row,
width: area.width,
height: 1,
}
}
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_width = area.width.saturating_mul(percent_x) / 100;
let popup_height = area.height.saturating_mul(percent_y) / 100;
Rect {
x: area.x + area.width.saturating_sub(popup_width) / 2,
y: area.y + area.height.saturating_sub(popup_height) / 2,
width: popup_width,
height: popup_height,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::{PickerState, ScrollLayout};
use crate::git::{DiffContent, DiffLine, FileDiff, FileStatus, Hunk, LineKind};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use std::path::PathBuf;
use std::time::{Duration, SystemTime};
fn diff_line(kind: LineKind, content: &str) -> DiffLine {
DiffLine {
kind,
content: content.to_string(),
has_trailing_newline: true,
}
}
fn hunk(old_start: usize, lines: Vec<DiffLine>) -> Hunk {
let added = lines.iter().filter(|l| l.kind == LineKind::Added).count();
let deleted = lines.iter().filter(|l| l.kind == LineKind::Deleted).count();
Hunk {
old_start,
old_count: deleted,
new_start: old_start,
new_count: added,
lines,
context: None,
}
}
fn make_file(name: &str, hunks: Vec<Hunk>, secs: u64) -> FileDiff {
let added: usize = hunks
.iter()
.flat_map(|h| h.lines.iter())
.filter(|l| l.kind == LineKind::Added)
.count();
let deleted: usize = hunks
.iter()
.flat_map(|h| h.lines.iter())
.filter(|l| l.kind == LineKind::Deleted)
.count();
FileDiff {
path: PathBuf::from(name),
status: FileStatus::Modified,
added,
deleted,
content: DiffContent::Text(hunks),
mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(secs),
header_prefix: None,
}
}
fn binary_file(name: &str) -> FileDiff {
FileDiff {
path: PathBuf::from(name),
status: FileStatus::Modified,
added: 0,
deleted: 0,
content: DiffContent::Binary,
mtime: SystemTime::UNIX_EPOCH,
header_prefix: None,
}
}
fn fake_app() -> App {
App {
root: PathBuf::from("/tmp/fake"),
git_dir: PathBuf::from("/tmp/fake/.git"),
common_git_dir: PathBuf::from("/tmp/fake/.git"),
current_branch_ref: Some("refs/heads/main".into()),
baseline_sha: "abcdef1234567890abcdef1234567890abcdef12".into(),
files: Vec::new(),
layout: ScrollLayout::default(),
scroll: 0,
cursor_sub_row: 0,
cursor_placement: crate::app::CursorPlacement::Centered,
anchor: None,
picker: None,
scar_comment: None,
revert_confirm: None,
file_view: None,
search_input: None,
search: None,
seen_hunks: std::collections::BTreeSet::new(),
follow_mode: true,
last_error: None,
input_health: None,
head_dirty: false,
should_quit: false,
last_body_height: std::cell::Cell::new(24),
last_body_width: std::cell::Cell::new(None),
visual_top: std::cell::Cell::new(0.0),
anim: None,
wrap_lines: false,
watcher_health: crate::app::WatcherHealth::default(),
highlighter: std::cell::OnceCell::new(),
config: crate::config::KizuConfig::default(),
view_mode: crate::app::ViewMode::default(),
saved_diff_scroll: 0,
saved_stream_scroll: 0,
stream_events: Vec::new(),
processed_event_paths: std::collections::HashSet::new(),
session_start_ms: 0,
bound_session_id: None,
diff_snapshots: crate::app::DiffSnapshots::default(),
scar_undo_stack: Vec::new(),
scar_focus: None,
pinned_cursor_y: None,
}
}
fn populated_app(files: Vec<FileDiff>) -> App {
let mut app = fake_app();
app.files = files;
app.files.sort_by_key(|a| a.mtime);
app.build_layout();
app.refresh_anchor();
app
}
fn render_to_string(app: &App, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
out.push_str(buffer[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn render_empty_state_when_no_files() {
let app = fake_app();
let view = render_to_string(&app, 70, 6);
assert!(
view.contains("No changes since baseline (baseline: abcdef1)"),
"expected empty state with short SHA, got:\n{view}"
);
assert!(view.contains("[follow]"));
}
#[test]
fn render_scroll_shows_file_header_hunk_header_and_diff_line() {
let app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(
10,
vec![
diff_line(LineKind::Context, "fn ok()"),
diff_line(LineKind::Added, "let x = 1;"),
diff_line(LineKind::Deleted, "let y = 2;"),
],
)],
100,
)]);
let view = render_to_string(&app, 80, 12);
assert!(view.contains("src/foo.rs"), "missing file header:\n{view}");
assert!(
view.contains("@@ L10"),
"missing hunk header line range:\n{view}"
);
assert!(
view.contains("+1/-1"),
"missing hunk header counts:\n{view}"
);
assert!(view.contains("let x = 1;"), "missing added line:\n{view}");
assert!(view.contains("let y = 2;"), "missing deleted line:\n{view}");
}
#[test]
fn render_scroll_lines_use_background_color_for_added_and_deleted() {
let app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(
1,
vec![
diff_line(LineKind::Added, "x"),
diff_line(LineKind::Deleted, "y"),
],
)],
100,
)]);
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut found_added_bg = false;
let mut found_deleted_bg = false;
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = &buffer[(x, y)];
if cell.symbol() == "x" && cell.style().bg == Some(BG_ADDED) {
found_added_bg = true;
}
if cell.symbol() == "y" && cell.style().bg == Some(BG_DELETED) {
found_deleted_bg = true;
}
}
}
assert!(
found_added_bg,
"expected an added 'x' cell with green background"
);
assert!(
found_deleted_bg,
"expected a deleted 'y' cell with red background"
);
}
#[test]
fn nowrap_added_row_background_extends_to_viewport_edge() {
let app = populated_app(vec![make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "tiny")])],
100,
)]);
let width: u16 = 40;
let backend = TestBackend::new(width, 12);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut tiny_y: Option<u16> = None;
for y in 0..buffer.area().height {
let row: String = (0..width)
.map(|x| buffer[(x, y)].symbol().chars().next().unwrap_or(' '))
.collect();
if row.contains("tiny") {
tiny_y = Some(y);
break;
}
}
let y = tiny_y.expect("tiny row must render somewhere");
for x in 5..width {
let cell = &buffer[(x, y)];
assert_eq!(
cell.style().bg,
Some(BG_ADDED),
"cell (x={x}, y={y}) lost the added background; symbol = {:?}",
cell.symbol()
);
}
}
#[test]
fn render_scroll_lines_omit_plus_minus_prefix() {
let app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(
1,
vec![
diff_line(LineKind::Added, "ADDED_LINE"),
diff_line(LineKind::Deleted, "DELETED_LINE"),
],
)],
100,
)]);
let view = render_to_string(&app, 80, 12);
assert!(
!view.contains("+ADDED_LINE"),
"must not carry a `+` prefix next to added body:\n{view}"
);
assert!(
!view.contains("-DELETED_LINE"),
"must not carry a `-` prefix next to deleted body:\n{view}"
);
}
#[test]
fn wrap_mode_renders_newline_marker_and_wraps_long_line() {
let long_content: String = (0..120u8).map(|i| (b'a' + (i % 26)) as char).collect();
let mut app = populated_app(vec![make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, &long_content)])],
100,
)]);
app.wrap_lines = true;
let view = render_to_string(&app, 80, 14);
assert!(
view.contains("¶"),
"wrap mode should draw a ¶ newline marker:\n{view}"
);
assert!(
view.contains(&long_content[90..110]),
"expected wrapped continuation to be visible:\n{view}"
);
}
#[test]
fn render_diff_line_nowrap_cjk_pads_to_cell_width_not_char_count() {
use unicode_width::UnicodeWidthStr;
let line = diff_line(LineKind::Added, "あいうえお");
let rendered = super::render_diff_line(
&line,
false,
false,
20,
None,
None,
Color::Rgb(10, 50, 10),
Color::Rgb(60, 10, 10),
);
let body_cells: usize = rendered
.spans
.iter()
.skip(1)
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
assert_eq!(
body_cells, 20,
"nowrap CJK body must pad to body_width in cells, got {body_cells} cells",
);
}
#[test]
fn wrap_at_chars_respects_cjk_display_width() {
let chunks = super::wrap_at_chars("日本語テスト", 4);
assert_eq!(chunks, vec!["日本", "語テ", "スト"]);
}
#[test]
fn wrap_at_chars_handles_mixed_ascii_and_cjk() {
let chunks = super::wrap_at_chars("ab漢字cd", 4);
assert_eq!(chunks, vec!["ab漢", "字cd"]);
}
#[test]
fn wrap_mode_cjk_line_wraps_within_viewport() {
let forty_kanji: String = "あいうえおかきくけこ".repeat(4);
assert_eq!(forty_kanji.chars().count(), 40);
let mut app = populated_app(vec![make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, &forty_kanji)])],
100,
)]);
app.wrap_lines = true;
let view = render_to_string(&app, 45, 10);
assert!(view.contains("あ"), "first kanji must be visible:\n{view}");
let late_kanji: char = forty_kanji.chars().nth(35).unwrap();
assert!(
view.contains(late_kanji),
"late CJK char {late_kanji:?} must survive wrap:\n{view}"
);
}
#[test]
fn wrap_mode_omits_newline_marker_when_diff_line_has_no_terminal_newline() {
let long_content: String = (0..40u8).map(|i| (b'a' + (i % 26)) as char).collect();
let mut file = make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, &long_content)])],
100,
);
let DiffContent::Text(hunks) = &mut file.content else {
panic!("expected text diff");
};
hunks[0].lines[0].has_trailing_newline = false;
let mut app = populated_app(vec![file]);
app.wrap_lines = true;
let view = render_to_string(&app, 40, 10);
assert!(
!view.contains("¶"),
"wrap mode must not invent a newline marker for EOF-no-newline lines:\n{view}"
);
}
#[test]
fn nowrap_mode_has_no_newline_marker() {
let mut app = populated_app(vec![make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "short")])],
100,
)]);
app.wrap_lines = false;
let view = render_to_string(&app, 80, 10);
assert!(
!view.contains("¶"),
"nowrap mode should not draw newline markers:\n{view}"
);
}
#[test]
fn wrap_nowrap_indicator_appears_in_footer() {
let mut app = populated_app(vec![make_file(
"a.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
)]);
let nowrap_view = render_to_string(&app, 80, 8);
assert!(nowrap_view.contains("nowrap"));
app.wrap_lines = true;
let wrap_view = render_to_string(&app, 80, 8);
assert!(wrap_view.contains("wrap"));
assert!(!wrap_view.contains("nowrap"));
}
#[test]
fn render_scroll_marks_binary_file_with_notice() {
let app = populated_app(vec![binary_file("assets/icon.png")]);
let view = render_to_string(&app, 80, 8);
assert!(view.contains("assets/icon.png"));
assert!(view.contains("[binary file - diff suppressed]"));
assert!(view.contains("bin"));
}
#[test]
fn render_picker_overlays_a_box_with_query_and_filtered_list() {
let mut app = populated_app(vec![
make_file(
"src/auth.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
300,
),
make_file(
"src/handler.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "y")])],
200,
),
make_file(
"tests/auth_test.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "z")])],
100,
),
]);
app.picker = Some(PickerState {
query: "auth".into(),
cursor: 0,
});
let view = render_to_string(&app, 90, 14);
assert!(view.contains("> auth"), "missing query line:\n{view}");
assert!(view.contains("src/auth.rs"), "missing src/auth.rs:\n{view}");
assert!(
view.contains("tests/auth_test.rs"),
"missing tests/auth_test.rs:\n{view}"
);
assert!(view.contains("Files 2/3"), "missing files counter:\n{view}");
assert!(view.contains("[picker]"));
assert!(view.contains("type to filter"));
assert!(view.contains("Esc"));
}
#[test]
fn render_footer_shows_last_error_in_red_when_set() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
)]);
app.last_error = Some("git diff exploded".into());
let backend = TestBackend::new(140, 6);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let footer_y = buffer.area().height - 1;
let mut footer_text = String::new();
let mut had_red_x = false;
for x in 0..buffer.area().width {
let cell = &buffer[(x, footer_y)];
footer_text.push_str(cell.symbol());
if cell.symbol() == "×" && cell.style().fg == Some(Color::Red) {
had_red_x = true;
}
}
assert!(
footer_text.contains("git diff exploded"),
"footer:\n{footer_text}"
);
assert!(
had_red_x,
"expected red '×' marker before the error message"
);
}
#[test]
fn render_footer_shows_source_aware_watcher_failures() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
)]);
app.watcher_health.record_failure(
crate::watcher::WatchSource::GitRefs,
"watcher [git.refs]: refs watcher dead".into(),
);
app.watcher_health.record_failure(
crate::watcher::WatchSource::Worktree,
"watcher [worktree]: worktree watcher dead".into(),
);
let view = render_to_string(&app, 160, 6);
assert!(
view.contains("⚠ WATCHER"),
"missing watcher warning:\n{view}"
);
assert!(
view.contains("watcher [git.refs]: refs watcher dead"),
"missing git watcher message:\n{view}"
);
assert!(
view.contains("watcher [worktree]: worktree"),
"missing worktree watcher message:\n{view}"
);
}
#[test]
fn render_footer_shows_input_health_warning() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
)]);
app.input_health = Some("input: stream hiccup".into());
let view = render_to_string(&app, 140, 6);
assert!(view.contains("⚠ INPUT"), "missing input warning:\n{view}");
assert!(
view.contains("input: stream hiccup"),
"missing input health message:\n{view}"
);
}
#[test]
fn render_footer_switches_to_manual_when_follow_mode_off() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
)]);
app.follow_mode = false;
let view = render_to_string(&app, 80, 6);
assert!(view.contains("[manual]"), "expected [manual]:\n{view}");
}
fn hunk_with_context(old_start: usize, ctx: &str, lines: Vec<DiffLine>) -> Hunk {
let added = lines.iter().filter(|l| l.kind == LineKind::Added).count();
let deleted = lines.iter().filter(|l| l.kind == LineKind::Deleted).count();
Hunk {
old_start,
old_count: deleted,
new_start: old_start,
new_count: added,
lines,
context: Some(ctx.to_string()),
}
}
fn modified_file_status(name: &str, status: FileStatus, secs: u64) -> FileDiff {
FileDiff {
path: PathBuf::from(name),
status,
added: 1,
deleted: 0,
content: DiffContent::Text(vec![hunk(1, vec![diff_line(LineKind::Added, "x")])]),
mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(secs),
header_prefix: None,
}
}
#[test]
fn file_header_path_color_encodes_status() {
let mut app = populated_app(vec![
modified_file_status("a.rs", FileStatus::Modified, 100),
modified_file_status("b.rs", FileStatus::Added, 200),
modified_file_status("c.rs", FileStatus::Deleted, 300),
modified_file_status("d.rs", FileStatus::Untracked, 400),
]);
app.scroll_to(0);
let backend = TestBackend::new(80, 30);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let want = [
("a.rs", Color::Cyan),
("b.rs", Color::Green),
("c.rs", Color::Red),
("d.rs", Color::Yellow),
];
for (name, expected) in want {
let mut found = false;
'outer: for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
if buffer[(x, y)].symbol() == &name[..1] {
let chars: String = (0..name.len())
.map(|i| buffer[(x + i as u16, y)].symbol().to_string())
.collect();
if chars == name {
assert_eq!(
buffer[(x, y)].style().fg,
Some(expected),
"{name} should be in {expected:?}"
);
found = true;
break 'outer;
}
}
}
}
assert!(found, "{name} not found in buffer");
}
}
#[test]
fn file_header_shows_prefix_when_set() {
let mut file = make_file(
"src/auth.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "x")])],
100,
);
file.header_prefix = Some("14:03:22 Write".to_string());
let mut app = populated_app(vec![file]);
app.scroll = 0;
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 10))
.expect("test terminal");
terminal.draw(|frame| render(frame, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let first_line: String = (0..buffer.area().width)
.map(|x| buffer[(x, 0)].symbol().chars().next().unwrap_or(' '))
.collect();
assert!(
first_line.contains("14:03:22 Write"),
"header should contain prefix, got: {first_line:?}"
);
}
#[test]
fn hunk_header_uses_function_context_when_available() {
let app = populated_app(vec![make_file(
"src/auth.rs",
vec![hunk_with_context(
10,
"fn verify_token(claims: &Claims) -> Result<bool> {",
vec![diff_line(LineKind::Added, "let x = 1;")],
)],
100,
)]);
let view = render_to_string(&app, 100, 14);
assert!(
view.contains("@@ fn verify_token(claims: &Claims) -> Result<bool> {"),
"expected xfuncname header, got:\n{view}"
);
assert!(
!view.contains("@@ -10,0 +10,1 @@"),
"old hunk-range header leaked through:\n{view}"
);
}
#[test]
fn hunk_header_shows_line_range_and_counts() {
let app = populated_app(vec![make_file(
"a.rs",
vec![Hunk {
old_start: 10,
old_count: 2,
new_start: 10,
new_count: 5,
lines: vec![
diff_line(LineKind::Context, "ok"),
diff_line(LineKind::Added, "new1"),
diff_line(LineKind::Added, "new2"),
diff_line(LineKind::Added, "new3"),
diff_line(LineKind::Deleted, "old1"),
],
context: Some("fn example()".to_string()),
}],
100,
)]);
let view = render_to_string(&app, 100, 14);
assert!(
view.contains("L10"),
"expected line range L10, got:\n{view}"
);
assert!(
view.contains("+3/-1"),
"expected +3/-1 counts, got:\n{view}"
);
}
#[test]
fn hunk_header_shows_range_in_fallback_format() {
let app = populated_app(vec![make_file(
"a.rs",
vec![Hunk {
old_start: 5,
old_count: 1,
new_start: 5,
new_count: 3,
lines: vec![
diff_line(LineKind::Added, "x"),
diff_line(LineKind::Added, "y"),
diff_line(LineKind::Deleted, "z"),
],
context: None,
}],
100,
)]);
let view = render_to_string(&app, 100, 14);
assert!(view.contains("L5"), "expected line range L5, got:\n{view}");
assert!(
view.contains("+2/-1"),
"expected +2/-1 counts, got:\n{view}"
);
}
#[test]
fn selected_hunk_is_bright_and_unselected_hunk_is_dim() {
let mut app = populated_app(vec![make_file(
"a.rs",
vec![
hunk(1, vec![diff_line(LineKind::Added, "~~~~")]),
hunk(20, vec![diff_line(LineKind::Added, "!!!!")]),
],
100,
)]);
app.scroll_to(app.layout.hunk_starts[0]);
let backend = TestBackend::new(100, 14);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut focused_cells: Vec<(Option<Color>, Modifier)> = Vec::new();
let mut unfocused_cells: Vec<(Option<Color>, Modifier)> = Vec::new();
for y in 0..buffer.area().height {
for x in 5..buffer.area().width {
let cell = &buffer[(x, y)];
let style = cell.style();
if cell.symbol() == "~" {
focused_cells.push((style.bg, style.add_modifier));
}
if cell.symbol() == "!" {
unfocused_cells.push((style.bg, style.add_modifier));
}
}
}
assert!(
!focused_cells.is_empty(),
"focused hunk body '~' never rendered"
);
assert!(
!unfocused_cells.is_empty(),
"unfocused hunk body '!' never rendered"
);
assert!(
focused_cells
.iter()
.all(|(bg, m)| *bg == Some(BG_ADDED) && !m.contains(Modifier::DIM)),
"focused hunk must be BG_ADDED without DIM, got {focused_cells:?}"
);
assert!(
unfocused_cells
.iter()
.all(|(bg, m)| *bg == Some(BG_ADDED) && m.contains(Modifier::DIM)),
"unfocused hunk must be BG_ADDED with DIM, got {unfocused_cells:?}"
);
}
#[test]
fn selected_hunk_displays_yellow_left_bar() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(
1,
vec![
diff_line(LineKind::Added, "first"),
diff_line(LineKind::Added, "second"),
],
)],
100,
)]);
app.scroll_to(app.layout.hunk_starts[0]);
let backend = TestBackend::new(80, 14);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut had_yellow_bar = false;
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = &buffer[(x, y)];
if cell.symbol() == "▎" && cell.style().fg == Some(Color::Yellow) {
had_yellow_bar = true;
}
}
}
assert!(
had_yellow_bar,
"expected a yellow '▎' on the selected hunk row"
);
}
#[test]
fn cursor_row_displays_arrow_marker_distinct_from_hunk_bar() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(
1,
vec![
diff_line(LineKind::Added, "first"),
diff_line(LineKind::Added, "second"),
],
)],
100,
)]);
app.scroll_to(app.layout.hunk_starts[0] + 1);
let backend = TestBackend::new(80, 14);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut had_arrow = false;
let mut had_plain_bar = false;
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = &buffer[(x, y)];
if cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow) {
had_arrow = true;
}
if cell.symbol() == "▎" && cell.style().fg == Some(Color::Yellow) {
had_plain_bar = true;
}
}
}
assert!(had_arrow, "expected a yellow '▶' arrow at the cursor row");
assert!(
had_plain_bar,
"expected a yellow '▎' ribbon on the other selected row"
);
}
#[test]
fn hunk_header_cursor_displays_arrow_marker() {
let mut app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "first")])],
100,
)]);
app.scroll_to(app.layout.hunk_starts[0]);
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("▶"),
"cursor parked on a hunk header must still be visible:\n{view}"
);
}
#[test]
fn file_header_cursor_displays_arrow_marker() {
let app = populated_app(vec![make_file(
"src/foo.rs",
vec![hunk(1, vec![diff_line(LineKind::Added, "first")])],
100,
)]);
let view = render_to_string(&app, 80, 10);
assert!(
view.contains("▶"),
"cursor parked on a file header must still be visible:\n{view}"
);
}
#[test]
fn binary_notice_cursor_displays_arrow_marker() {
let mut app = populated_app(vec![binary_file("assets/icon.png")]);
app.scroll_to(1);
let view = render_to_string(&app, 80, 8);
assert!(
view.contains("▶"),
"cursor parked on a binary notice row must still be visible:\n{view}"
);
}
#[test]
fn centered_cursor_renders_arrow_near_viewport_middle() {
let lines: Vec<DiffLine> = (0..40)
.map(|i| diff_line(LineKind::Added, &format!("line {i}")))
.collect();
let mut app = populated_app(vec![make_file_with_context(
"src/foo.rs",
"fn long_function() {",
lines,
100,
)]);
let header = app.layout.hunk_starts[0];
app.scroll_to(header + 20);
app.anim = None;
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut cursor_y: Option<u16> = None;
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = &buffer[(x, y)];
if cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow) {
cursor_y = Some(y);
}
}
}
let y = cursor_y.expect("expected the cursor `▶` to be drawn");
assert!(
(4..=8).contains(&y),
"expected cursor near viewport middle, was at row {y}"
);
}
#[test]
fn top_cursor_renders_arrow_near_viewport_top() {
let lines: Vec<DiffLine> = (0..40)
.map(|i| diff_line(LineKind::Added, &format!("line {i}")))
.collect();
let mut app = populated_app(vec![make_file_with_context(
"src/foo.rs",
"fn long_function() {",
lines,
100,
)]);
let header = app.layout.hunk_starts[0];
app.scroll_to(header + 20);
app.anim = None;
app.cursor_placement = crate::app::CursorPlacement::Top;
let backend = TestBackend::new(80, 12);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut cursor_y: Option<u16> = None;
for y in 0..buffer.area().height {
for x in 0..buffer.area().width {
let cell = &buffer[(x, y)];
if cell.symbol() == "▶" && cell.style().fg == Some(Color::Yellow) {
cursor_y = Some(y);
}
}
}
let y = cursor_y.expect("expected the cursor `▶` to be drawn");
assert_eq!(y, 1, "expected cursor at viewport ceiling, was at row {y}");
}
#[test]
fn sticky_header_decision_agrees_with_final_body_height() {
let lines: Vec<DiffLine> = (0..20)
.map(|i| diff_line(LineKind::Added, &format!("line {i}")))
.collect();
let mut app = populated_app(vec![make_file_with_context(
"src/foo.rs",
"fn boundary() {",
lines,
100,
)]);
let header_row = app.layout.hunk_starts[0];
app.scroll_to(header_row + 5);
app.anim = None;
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut row0 = String::new();
for x in 0..buffer.area().width {
row0.push_str(buffer[(x, 0)].symbol());
}
assert!(
row0.contains("boundary") || row0.contains("@@"),
"row 0 must show the hunk header (sticky or inline), got:\n{row0}"
);
}
#[test]
fn sticky_hunk_header_appears_when_cursor_is_below_it() {
let lines: Vec<DiffLine> = (0..40)
.map(|i| diff_line(LineKind::Added, &format!("line {i}")))
.collect();
let mut app = populated_app(vec![make_file_with_context(
"src/foo.rs",
"fn long_function() {",
lines,
100,
)]);
let header_row = app.layout.hunk_starts[0];
app.scroll_to(header_row + 10);
app.anim = None;
let backend = TestBackend::new(80, 8);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| render(f, &app)).expect("draw");
let buffer = terminal.backend().buffer().clone();
let mut row0 = String::new();
for x in 0..buffer.area().width {
row0.push_str(buffer[(x, 0)].symbol());
}
assert!(
row0.contains("long_function"),
"row 0 should be the pinned hunk header, got:\n{row0}"
);
}
fn make_file_with_context(name: &str, ctx: &str, lines: Vec<DiffLine>, secs: u64) -> FileDiff {
let added: usize = lines.iter().filter(|l| l.kind == LineKind::Added).count();
let deleted: usize = lines.iter().filter(|l| l.kind == LineKind::Deleted).count();
FileDiff {
path: PathBuf::from(name),
status: FileStatus::Modified,
added,
deleted,
content: DiffContent::Text(vec![Hunk {
old_start: 1,
old_count: deleted,
new_start: 1,
new_count: added,
lines,
context: Some(ctx.to_string()),
}]),
mtime: SystemTime::UNIX_EPOCH + Duration::from_secs(secs),
header_prefix: None,
}
}
}