use std::collections::HashMap;
use std::sync::OnceLock;
use diffler_core::highlight::{Highlighter, StyledRange, SyntaxTheme};
use diffler_core::model::{DiffLine, DiffModel, FileDiff};
use diffler_core::session::{Comment, CommentStatus, Session};
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use crate::app::{
App, CommentLine, DiffRow, DiffSource, DiffView, FileHighlights, Pane, SplitRow,
build_split_rows, comment_display,
};
use crate::keymap::Action;
use crate::theme::Theme;
use crate::tree::TreeNode;
use crate::ui::Hint;
use crate::ui::diff_render::{
SplitSide, file_gutter_width, hunk_header, line_syntax, render_diff_line, render_split_pair,
};
use crate::ui::{diffstat_spans, hint_line, proportion_bar, status_bar, status_color};
const HINTS: &[Hint] = &[
Hint::Leaf(&[Action::ToggleFocus], "focus"),
Hint::Leaf(&[Action::Comment], "comment"),
Hint::Leaf(&[Action::VisualSelect], "select"),
Hint::Leaf(&[Action::Reply], "reply"),
Hint::Leaf(&[Action::Resolve], "resolve"),
Hint::Leaf(&[Action::MarkViewed], "viewed"),
Hint::Leaf(&[Action::ToggleSideBySide], "split"),
Hint::Leaf(&[Action::NextFile, Action::PrevFile], "file"),
Hint::Leaf(&[Action::CopyFileFeedback, Action::CopyAllFeedback], "copy"),
Hint::Leaf(&[Action::Back], "back"),
];
fn sidebar_width(total: u16) -> u16 {
(total / 4).clamp(28, 44).min(total)
}
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
let area = frame.area();
frame.render_widget(Block::new().style(app.theme.base()), area);
let [hint, body, bar] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(Paragraph::new(hint_line(app, HINTS)), hint);
app.enrich_diff_selected_file();
let theme = &app.theme;
let review = &app.review;
if let Some(diff) = app.diff.as_mut() {
diff.ensure_rows(review);
let review_model = (diff.commit_model.is_none()).then(|| review.model());
let session = review.session_for(&diff.source);
draw_body(frame, body, theme, session, review_model, diff);
}
frame.render_widget(
Paragraph::new(status_bar(app, bar.width)).style(Style::new().bg(app.theme.panel)),
bar,
);
}
static HIGHLIGHTER: OnceLock<Highlighter> = OnceLock::new();
pub(crate) fn init_highlighter(syntax: SyntaxTheme) {
let _ = HIGHLIGHTER.set(Highlighter::new(syntax));
}
fn highlighter() -> &'static Highlighter {
HIGHLIGHTER.get_or_init(Highlighter::default)
}
fn draw_body(
frame: &mut Frame<'_>,
area: Rect,
theme: &Theme,
session: &Session,
review_model: Option<&DiffModel>,
diff: &mut DiffView,
) {
let width = sidebar_width(area.width);
let [list_area, pane_area] =
Layout::horizontal([Constraint::Length(width), Constraint::Min(0)]).areas(area);
draw_sidebar(frame, list_area, theme, session, review_model, diff);
draw_pane(frame, pane_area, theme, session, review_model, diff);
}
fn draw_sidebar(
frame: &mut Frame<'_>,
area: Rect,
theme: &Theme,
session: &Session,
review_model: Option<&DiffModel>,
diff: &mut DiffView,
) {
let focused = diff.focus == Pane::List;
let block = pane_block(theme, "Files", focused);
let inner = block.inner(area);
diff.sidebar = inner;
frame.render_widget(block, area);
let Some(model) = diff.commit_model.as_ref().or(review_model) else {
return;
};
let lines: Vec<Line<'static>> = diff
.tree_rows(model)
.iter()
.enumerate()
.map(|(row_index, row)| {
let on_cursor = row_index == diff.tree_cursor;
match &row.node {
TreeNode::Dir { name, path } => sidebar_dir_line(
theme,
name,
diff.folded_dirs.contains(path),
row.depth,
inner.width,
on_cursor,
),
TreeNode::File { index, name } => {
let Some(file) = model.files.get(*index) else {
return Line::default();
};
let viewed = session.is_viewed(&file.path, &file.content_hash());
let open = open_comment_count(session, &file.path);
sidebar_file_line(
theme,
file,
name,
viewed,
open,
row.depth,
inner.width,
on_cursor,
)
}
}
})
.collect();
let height = inner.height.max(1) as usize;
let scroll = diff.tree_cursor.saturating_sub(height - 1) as u16;
diff.sidebar_scroll = scroll as usize;
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
#[allow(clippy::too_many_lines)]
fn draw_pane(
frame: &mut Frame<'_>,
area: Rect,
theme: &Theme,
session: &Session,
review_model: Option<&DiffModel>,
diff: &mut DiffView,
) {
let focused = diff.focus == Pane::Diff;
let title = pane_title(&diff.source);
let block = pane_block(theme, &title, focused);
let inner = block.inner(area);
frame.render_widget(block, area);
let Some(model) = diff.commit_model.as_ref().or(review_model) else {
frame.render_widget(
Paragraph::new(Line::styled(
" nothing to review",
Style::new().fg(theme.dim).bg(theme.bg),
)),
inner,
);
return;
};
let Some(file) = model.files.get(diff.selected) else {
frame.render_widget(
Paragraph::new(Line::styled(
" nothing to review",
Style::new().fg(theme.dim).bg(theme.bg),
)),
inner,
);
return;
};
let [header_area, rows_area] =
Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(inner);
let viewed = session.is_viewed(&file.path, &file.content_hash());
let open = open_comment_count(session, &file.path);
let total = session
.comments
.iter()
.filter(|c| c.anchor.file == file.path)
.count();
frame.render_widget(
Paragraph::new(pane_header_line(
theme,
file,
viewed,
(open, total),
header_area.width,
)),
header_area,
);
diff.viewport = rows_area.height;
diff.pane = rows_area;
ensure_file_highlights(&mut diff.highlights, file);
if diff.side_by_side {
let split = build_split_rows(model, session, diff.selected);
let (sel, side) = diff.split_cursor(&split);
let height = rows_area.height.max(1) as usize;
if sel < diff.split_scroll {
diff.split_scroll = sel;
}
if sel >= diff.split_scroll + height {
diff.split_scroll = sel + 1 - height;
}
let scroll = diff.split_scroll;
let highlights = diff.highlights.get(&file.path);
let gutter = file_gutter_width(file);
let lines: Vec<Line<'static>> = split
.iter()
.enumerate()
.skip(scroll)
.take(height)
.map(|(index, row)| {
split_row_line(
theme,
session,
file,
highlights,
gutter,
rows_area.width,
row,
index == sel,
side,
)
})
.collect();
frame.render_widget(Paragraph::new(lines), rows_area);
return;
}
let height = rows_area.height.max(1) as usize;
if diff.cursor < diff.scroll {
diff.scroll = diff.cursor;
}
if diff.cursor >= diff.scroll + height {
diff.scroll = diff.cursor + 1 - height;
}
let scroll = diff.scroll;
let cursor = diff.cursor;
let selection = diff.selection();
let selected = |index: usize| {
index == cursor || selection.is_some_and(|(start, end)| index >= start && index <= end)
};
let lines: Vec<Line<'static>> = diff
.rows()
.iter()
.enumerate()
.skip(scroll)
.take(height)
.map(|(index, row)| {
row_line(
theme,
session,
model,
diff,
row,
rows_area.width,
selected(index),
)
})
.collect();
frame.render_widget(Paragraph::new(lines), rows_area);
}
#[allow(clippy::too_many_arguments)]
fn split_row_line(
theme: &Theme,
session: &Session,
file: &FileDiff,
highlights: Option<&FileHighlights>,
gutter: usize,
width: u16,
row: &SplitRow,
on_cursor: bool,
cursor_side: Option<SplitSide>,
) -> Line<'static> {
match *row {
SplitRow::Hunk { hunk } => match file.hunks.get(hunk) {
Some(hunk) => hunk_header(theme, hunk, width, on_cursor),
None => Line::default(),
},
SplitRow::Pair { hunk, left, right } => {
let Some(hunk) = file.hunks.get(hunk) else {
return Line::default();
};
let cell = |index: Option<usize>, side: SplitSide| {
index.and_then(|i| hunk.lines.get(i)).map(|line| {
(
line,
highlights.and_then(|hl| split_side_syntax(hl, line, side)),
)
})
};
let sel_left = on_cursor && !matches!(cursor_side, Some(SplitSide::Right));
let sel_right = on_cursor && !matches!(cursor_side, Some(SplitSide::Left));
render_split_pair(
theme,
cell(left, SplitSide::Left),
cell(right, SplitSide::Right),
gutter,
width,
sel_left,
sel_right,
)
}
SplitRow::Comment {
comment,
line,
outdated,
} => match session.comments.get(comment) {
Some(comment) => comment_row_line(theme, comment, line, outdated, width, on_cursor),
None => Line::default(),
},
}
}
fn split_side_syntax<'a>(
highlights: &'a FileHighlights,
line: &DiffLine,
side: SplitSide,
) -> Option<&'a [StyledRange]> {
let (column, number) = match side {
SplitSide::Left => (&highlights.old, line.old_no),
SplitSide::Right => (&highlights.new, line.new_no),
};
let index = usize::try_from(number?).ok()?.checked_sub(1)?;
column.get(index).map(Vec::as_slice)
}
fn pane_title(source: &DiffSource) -> String {
match source {
DiffSource::WorkingTree | DiffSource::Commit { .. } => "Diff".to_owned(),
DiffSource::Range { oldest, newest } => {
let short = |oid: &str| oid.get(..7).unwrap_or(oid).to_owned();
format!("Diff {}..{}", short(oldest), short(newest))
}
}
}
fn pane_block(theme: &Theme, title: &str, focused: bool) -> Block<'static> {
let border = if focused { theme.accent } else { theme.border };
Block::new()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::new().fg(border).bg(theme.bg))
.title(Span::styled(
format!(" {title} "),
Style::new()
.fg(if focused { theme.accent } else { theme.dim })
.bg(theme.bg)
.add_modifier(Modifier::BOLD),
))
}
fn open_comment_count(session: &Session, path: &str) -> usize {
session
.comments
.iter()
.filter(|c| c.anchor.file == path && c.status != CommentStatus::Resolved)
.count()
}
fn tree_lead(theme: &Theme, depth: usize, bg: Color, on_cursor: bool) -> Span<'static> {
let marker = if on_cursor { "▌" } else { " " };
Span::styled(
format!("{marker}{}", " ".repeat(depth * 2)),
Style::new().fg(theme.accent).bg(bg),
)
}
fn sidebar_dir_line(
theme: &Theme,
name: &str,
folded: bool,
depth: usize,
width: u16,
on_cursor: bool,
) -> Line<'static> {
let bg = if on_cursor {
theme.cursor_line
} else {
theme.bg
};
let arrow = if folded { "▸ " } else { "▾ " };
let spans = vec![
tree_lead(theme, depth, bg, on_cursor),
Span::styled(arrow.to_owned(), Style::new().fg(theme.dim).bg(bg)),
Span::styled(
name.to_owned(),
Style::new()
.fg(if on_cursor { theme.accent } else { theme.fg })
.bg(bg),
),
];
pad_line(spans, bg, width)
}
#[allow(clippy::too_many_arguments)]
fn sidebar_file_line(
theme: &Theme,
file: &FileDiff,
name: &str,
viewed: bool,
open: usize,
depth: usize,
width: u16,
on_cursor: bool,
) -> Line<'static> {
let bg = if on_cursor {
theme.cursor_line
} else {
theme.bg
};
let dim = Style::new().fg(theme.dim).bg(bg);
let glyph = file.status.glyph();
let mut spans = vec![
tree_lead(theme, depth, bg, on_cursor),
Span::styled(
format!("{glyph} "),
Style::new().fg(status_color(theme, file.status)).bg(bg),
),
];
let suffix_width = usize::from(viewed) * 2
+ if open > 0 {
2 + open.to_string().len()
} else {
0
};
let used = spans.iter().map(Span::width).sum::<usize>() + suffix_width;
let room = (width as usize).saturating_sub(used + 1);
let name_style = Style::new()
.fg(if on_cursor { theme.accent } else { theme.fg })
.bg(bg);
spans.push(Span::styled(clip_path(name, room), name_style));
if viewed {
spans.push(Span::styled(" ✓".to_owned(), dim));
}
if open > 0 {
spans.push(Span::styled(format!(" ·{open}"), dim));
}
let (added, deleted) = file.diffstat();
let stat = diffstat_spans(theme, added, deleted, bg);
let stat_width: usize = stat.iter().map(Span::width).sum();
let used: usize = spans.iter().map(Span::width).sum();
if stat_width > 0 && used + stat_width < width as usize {
let gap = (width as usize).saturating_sub(used + stat_width);
if gap > 0 {
spans.push(Span::styled(" ".repeat(gap), Style::new().bg(bg)));
}
spans.extend(stat);
}
pad_line(spans, bg, width)
}
fn clip_name(name: &str, room: usize) -> String {
if room == 0 {
return String::new();
}
if name.chars().count() <= room {
return name.to_owned();
}
let head: String = name.chars().take(room.saturating_sub(1)).collect();
format!("{head}…")
}
fn clip_path(name: &str, room: usize) -> String {
if !name.contains('/') {
return clip_name(name, room);
}
if room == 0 {
return String::new();
}
let count = name.chars().count();
if count <= room {
return name.to_owned();
}
let tail: String = name.chars().skip(count - room.saturating_sub(1)).collect();
format!("…{tail}")
}
fn row_line(
theme: &Theme,
session: &Session,
model: &DiffModel,
diff: &DiffView,
row: &DiffRow,
width: u16,
selected: bool,
) -> Line<'static> {
let highlights = &diff.highlights;
match row {
DiffRow::Hunk { file, hunk } => {
match model.files.get(*file).and_then(|f| f.hunks.get(*hunk)) {
Some(hunk) => hunk_header(theme, hunk, width, selected),
None => Line::default(),
}
}
DiffRow::Line { file, hunk, line } => {
let Some(file) = model.files.get(*file) else {
return Line::default();
};
let Some(line) = file.hunks.get(*hunk).and_then(|h| h.lines.get(*line)) else {
return Line::default();
};
let syntax = highlights
.get(&file.path)
.and_then(|cached| line_syntax(&cached.old, &cached.new, line));
render_diff_line(
theme,
line,
syntax,
file_gutter_width(file),
width,
selected,
)
}
DiffRow::Comment {
comment,
line,
outdated,
} => match session.comments.get(*comment) {
Some(comment) => comment_row_line(theme, comment, *line, *outdated, width, selected),
None => Line::default(),
},
}
}
pub(crate) fn ensure_file_highlights(cache: &mut HashMap<String, FileHighlights>, file: &FileDiff) {
let hash = file.sides_hash();
if cache
.get(&file.path)
.is_some_and(|cached| cached.hash == hash)
{
return;
}
let highlight = |text: &Option<String>| {
text.as_deref()
.map(|content| highlighter().highlight(&file.path, content))
.unwrap_or_default()
};
let entry = FileHighlights {
hash,
old: highlight(&file.old_text),
new: highlight(&file.new_text),
};
cache.insert(file.path.clone(), entry);
}
fn pane_header_line(
theme: &Theme,
file: &FileDiff,
viewed: bool,
comments: (usize, usize),
width: u16,
) -> Line<'static> {
let bg = theme.panel;
let dim = Style::new().fg(theme.dim).bg(bg);
let mode = file.status.label();
let mut spans = vec![Span::styled(format!(" {mode:<10}"), dim)];
spans.push(Span::styled(
file.path.clone(),
Style::new()
.fg(theme.accent)
.bg(bg)
.add_modifier(Modifier::BOLD),
));
if file.binary {
spans.push(Span::styled(" (binary)".to_owned(), dim));
}
if viewed {
spans.push(Span::styled(" ✓ viewed".to_owned(), dim));
}
let (open, total) = comments;
if open > 0 {
let noun = if open == 1 { "comment" } else { "comments" };
spans.push(Span::styled(format!(" · {open} {noun}"), dim));
} else if total > 0 {
spans.push(Span::styled(" · resolved".to_owned(), dim));
}
let (added, deleted) = file.diffstat();
let mut tail = diffstat_spans(theme, added, deleted, bg);
let bar = proportion_bar(theme, added, deleted, bg);
if !bar.is_empty() {
tail.push(Span::styled(" ".to_owned(), Style::new().bg(bg)));
tail.extend(bar);
}
let tail_width: usize = tail.iter().map(Span::width).sum();
let used: usize = spans.iter().map(Span::width).sum();
let gap = (width as usize).saturating_sub(used + tail_width);
if gap > 0 {
spans.push(Span::styled(" ".repeat(gap), Style::new().bg(bg)));
}
spans.extend(tail);
pad_line(spans, bg, width)
}
fn comment_row_line(
theme: &Theme,
comment: &Comment,
line: usize,
outdated: bool,
width: u16,
selected: bool,
) -> Line<'static> {
let bg = if selected {
theme.cursor_line
} else {
theme.panel
};
let (status_label, accent) = match comment.status {
CommentStatus::Open => ("open", theme.warn_fg),
CommentStatus::Replied => ("replied", theme.accent),
CommentStatus::Resolved => ("resolved", theme.dim),
};
let bar = Span::styled(" ▌ ".to_owned(), Style::new().fg(accent).bg(bg));
let dim = Style::new().fg(theme.dim).bg(bg);
let fg = Style::new().fg(theme.fg).bg(bg);
let lines = comment_display(comment);
let Some(part) = lines.get(line) else {
return Line::default();
};
let spans = match part {
CommentLine::Header => {
let mut spans = vec![
bar,
Span::styled(
comment.author.clone(),
Style::new()
.fg(theme.purple)
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(" · ".to_owned(), dim),
Span::styled(
status_label.to_owned(),
Style::new().fg(accent).bg(bg).add_modifier(Modifier::BOLD),
),
];
if outdated {
spans.push(Span::styled(
" · outdated".to_owned(),
Style::new().fg(theme.warn_fg).bg(bg),
));
}
spans
}
CommentLine::Body(text) => vec![bar, Span::styled(text.clone(), fg)],
CommentLine::Reply {
author,
text,
first,
} => {
let mut spans = vec![bar];
if *first {
spans.push(Span::styled(
format!("↳ {author}: "),
Style::new().fg(theme.purple).bg(bg),
));
} else {
spans.push(Span::styled(" ".to_owned(), fg));
}
spans.push(Span::styled(text.clone(), fg));
spans
}
CommentLine::Footer => vec![Span::styled(
" ▌".to_owned(),
Style::new().fg(accent).bg(bg),
)],
};
pad_line(spans, bg, width)
}
fn pad_line(mut spans: Vec<Span<'static>>, bg: Color, width: u16) -> Line<'static> {
let used: usize = spans.iter().map(Span::width).sum();
let pad = (width as usize).saturating_sub(used);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), Style::new().bg(bg)));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use super::{clip_name, clip_path};
use crate::app::{App, DiffRow, Pane};
use crate::config::LoadedConfig;
use crate::test_support::{
Fixture, key, mouse_click, mouse_drag, mouse_right_click, mouse_scroll, standard_fixture,
};
#[test]
fn basename_shorter_than_room_is_kept_whole() {
assert_eq!(clip_name("lib.rs", 32), "lib.rs");
}
#[test]
fn basename_exactly_filling_room_is_not_clipped() {
assert_eq!(clip_name("lib.rs", 6), "lib.rs");
}
#[test]
fn basename_longer_than_room_is_end_clipped_with_ellipsis() {
let clipped = clip_name("a_very_long_filename_indeed.rs", 12);
assert_eq!(clipped.chars().count(), 12);
assert!(clipped.ends_with('…'), "clipped at the end: {clipped:?}");
assert!(
clipped.starts_with("a_very"),
"kept from the start: {clipped:?}"
);
}
#[test]
fn zero_room_yields_an_empty_name() {
assert_eq!(clip_name("lib.rs", 0), "");
}
#[test]
fn clip_path_keeps_a_bare_basename_end_clipped() {
assert_eq!(clip_path("lib.rs", 32), "lib.rs");
let clipped = clip_path("a_very_long_filename_indeed.rs", 12);
assert_eq!(clipped.chars().count(), 12);
assert!(clipped.ends_with('…'), "end-clipped: {clipped:?}");
}
#[test]
fn clip_path_front_elides_a_path_to_keep_the_basename() {
assert_eq!(clip_path("src/lib.rs", 32), "src/lib.rs");
let clipped = clip_path("deep/nested/dir/module.rs", 12);
assert_eq!(clipped.chars().count(), 12);
assert!(clipped.starts_with('…'), "front-elided: {clipped:?}");
assert!(clipped.ends_with("module.rs"), "basename kept: {clipped:?}");
}
fn render(app: &mut App) -> Terminal<TestBackend> {
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| crate::ui::draw(frame, app))
.expect("draw");
terminal
}
fn diff_app() -> (crate::test_support::Fixture, App) {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.author = "reviewer".to_owned();
app.open_working_tree_diff(None);
(fixture, app)
}
fn open_lib_diff(app: &mut App) {
let index = app
.diff
.as_ref()
.unwrap()
.model(&app.review)
.files
.iter()
.position(|f| f.path == "src/lib.rs")
.expect("src/lib.rs present");
let diff = app.diff.as_mut().unwrap();
diff.selected = index;
diff.focus = Pane::Diff;
diff.invalidate();
diff.ensure_rows(&app.review);
}
fn cursor_to_added_line(app: &mut App) {
let diff = app.diff.as_ref().unwrap();
let model = diff.model(&app.review);
let position = diff
.rows()
.iter()
.position(|row| {
let DiffRow::Line { file, hunk, line } = row else {
return false;
};
model.files.get(*file).is_some_and(|f| {
f.path == "src/lib.rs"
&& f.hunks.get(*hunk).is_some_and(|h| {
h.lines
.get(*line)
.is_some_and(|l| l.new_no.is_some() && l.text.contains("42"))
})
})
})
.expect("added line");
app.diff.as_mut().unwrap().cursor = position;
}
#[test]
fn diff_pane_renders_with_syntax_emphasis_and_gutter() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
let terminal = render(&mut app);
let styles = format!("{:?}", terminal.backend().buffer());
let add_emph = format!("{:?}", app.theme.add_emph_bg);
let del_emph = format!("{:?}", app.theme.del_emph_bg);
assert!(styles.contains(&add_emph), "added emphasis bg rendered");
assert!(styles.contains(&del_emph), "deleted emphasis bg rendered");
let highlights = &app.diff.as_ref().unwrap().highlights;
let lib = highlights
.get("src/lib.rs")
.expect("src/lib.rs highlighted");
assert!(
lib.new.iter().any(|line| !line.is_empty()),
"rust syntax produced styled ranges"
);
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn side_by_side_pane_renders_old_and_new_columns() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
app.diff.as_mut().unwrap().side_by_side = true;
insta::assert_snapshot!(render(&mut app).backend());
}
fn sidebar_file_pos(app: &App) -> (u16, u16, usize) {
let diff = app.diff.as_ref().unwrap();
let rows = diff.tree_rows(diff.model(&app.review));
let target = rows
.iter()
.position(|r| matches!(r.node, crate::tree::TreeNode::File { .. }))
.expect("a file row in the sidebar");
let x = diff.sidebar.x + 1;
let y = diff.sidebar.y + target as u16 - diff.sidebar_scroll as u16;
(x, y, target)
}
#[test]
fn single_click_in_the_sidebar_selects_without_focusing() {
let (_fixture, mut app) = diff_app();
render(&mut app);
let (x, y, target) = sidebar_file_pos(&app);
app.handle(mouse_click(x, y));
let diff = app.diff.as_ref().unwrap();
assert_eq!(diff.tree_cursor, target);
assert_eq!(diff.focus, Pane::List, "single click keeps sidebar focus");
}
#[test]
fn double_click_in_the_sidebar_opens_the_file() {
let (_fixture, mut app) = diff_app();
render(&mut app);
let (x, y, target) = sidebar_file_pos(&app);
app.handle(mouse_click(x, y));
app.handle(mouse_click(x, y));
let diff = app.diff.as_ref().unwrap();
assert_eq!(diff.tree_cursor, target);
assert_eq!(diff.focus, Pane::Diff, "double-click opens into the pane");
}
#[test]
fn mouse_wheel_over_the_pane_scrolls_it() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
render(&mut app);
let before = app.diff.as_ref().unwrap().cursor;
let pane = app.diff.as_ref().unwrap().pane;
app.handle(mouse_scroll(true, pane.x + 1, pane.y + 1));
assert!(
app.diff.as_ref().unwrap().cursor > before,
"wheel advanced the pane cursor"
);
}
fn first_two_pane_lines(app: &App) -> (u16, u16, u16, usize, usize) {
let diff = app.diff.as_ref().unwrap();
let lines: Vec<usize> = diff
.rows()
.iter()
.enumerate()
.filter(|(_, r)| matches!(r, DiffRow::Line { .. }))
.map(|(i, _)| i)
.collect();
let x = diff.pane.x + 1;
let y0 = diff.pane.y + lines[0] as u16 - diff.scroll as u16;
let y1 = diff.pane.y + lines[1] as u16 - diff.scroll as u16;
(x, y0, y1, lines[0], lines[1])
}
#[test]
fn dragging_in_the_pane_selects_a_line_range() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
render(&mut app);
let (x, y0, y1, line0, line1) = first_two_pane_lines(&app);
app.handle(mouse_click(x, y0));
app.handle(mouse_drag(x, y1));
let diff = app.diff.as_ref().unwrap();
assert!(diff.visual_anchor.is_some(), "drag started a selection");
assert_eq!(diff.selection(), Some((line0, line1)));
}
#[test]
fn double_click_in_the_pane_starts_a_comment() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
render(&mut app);
let (x, y0, ..) = first_two_pane_lines(&app);
app.handle(mouse_click(x, y0));
app.handle(mouse_click(x, y0));
assert!(app.modal.is_some(), "double-click opened the comment input");
}
#[test]
fn right_click_cancels_a_pane_selection() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
render(&mut app);
let (x, y0, y1, ..) = first_two_pane_lines(&app);
app.handle(mouse_click(x, y0));
app.handle(mouse_drag(x, y1));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
app.handle(mouse_right_click(x, y0));
assert_eq!(
app.diff.as_ref().unwrap().visual_anchor,
None,
"right-click dropped the selection"
);
}
#[test]
fn sidebar_focus_renders_the_file_list_and_first_file_diff() {
let (_fixture, mut app) = diff_app();
assert_eq!(app.diff.as_ref().unwrap().focus, Pane::List);
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn sidebar_renders_as_a_flat_list_when_configured() {
let fixture = standard_fixture();
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = crate::config::FileLayout::List;
let mut app = App::new(fixture.review(), loaded);
app.author = "reviewer".to_owned();
app.open_working_tree_diff(None);
assert_eq!(app.diff.as_ref().unwrap().focus, Pane::List);
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn sidebar_scrolls_to_keep_the_cursor_visible() {
let fixture = crate::test_support::Fixture::new();
fixture.write(".keep", "x\n");
fixture.commit_all("base");
for i in 0..40 {
fixture.write(&format!("f{i:02}.txt"), "x\n");
}
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.open_working_tree_diff(None);
let count = {
let diff = app.diff.as_ref().unwrap();
diff.tree_rows(diff.model(&app.review)).len()
};
app.diff.as_mut().unwrap().tree_cursor = count - 1;
let backend = TestBackend::new(120, 14);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal
.draw(|frame| crate::ui::draw(frame, &mut app))
.expect("draw");
let content = terminal.backend().to_string();
assert!(content.contains("f39.txt"), "cursor row visible: {content}");
assert!(
!content.contains("f01.txt"),
"top rows scrolled off: {content}"
);
}
#[test]
fn commit_diff_opened_from_the_log_renders() {
let (_fixture, mut app) = diff_app();
app.handle(key('q'));
app.handle(key('l'));
app.handle(key('l'));
app.handle(key('\n'));
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn range_diff_from_the_log_renders_with_a_range_header() {
let fixture = standard_fixture();
fixture.write("notes.txt", "alpha\nbeta\n");
fixture.commit_all("add beta note");
fixture.write(
"src/util.rs",
"pub fn twice(x: u32) -> u32 {\n x * 2\n}\n",
);
fixture.commit_all("add util module");
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('l'));
app.handle(key('l'));
app.handle(key('V'));
app.handle(key('j'));
app.handle(key('\n'));
let content = render(&mut app).backend().to_string();
assert!(
content.contains(".."),
"range header shows a span: {content}"
);
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn comment_blocks_render_open_and_replied_threads() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
cursor_to_added_line(&mut app);
app.handle(key('c'));
for c in "why 42?".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
let answered = app
.review
.session
.add_comment(
"reviewer",
diffler_core::session::Anchor {
file: "src/lib.rs".to_owned(),
line: Some(1),
line_end: None,
on_old_side: false,
hunk: None,
line_text: Some("pub fn answer() -> u32 {".to_owned()),
},
"rename this?",
)
.id
.clone();
app.review
.session
.reply(&answered, "agent", "kept for api compat");
app.diff.as_mut().unwrap().invalidate();
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn visual_selection_highlights_the_selected_rows() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
cursor_to_added_line(&mut app);
app.handle(key('V'));
app.handle(key('j'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
let terminal = render(&mut app);
let bg = format!("{:?}", app.theme.cursor_line);
let selected = format!("{:?}", terminal.backend().buffer())
.matches(&bg)
.count();
app.diff.as_mut().unwrap().visual_anchor = None;
let unselected = format!("{:?}", render(&mut app).backend().buffer())
.matches(&bg)
.count();
assert!(
selected > unselected,
"selection must paint extra rows: {selected} vs {unselected}"
);
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn viewed_file_shows_a_check_and_comment_count_in_the_sidebar() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
cursor_to_added_line(&mut app);
app.handle(key('c'));
for c in "look".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
let hash = app
.review
.model()
.files
.iter()
.find(|f| f.path == "src/lib.rs")
.map(diffler_core::model::FileDiff::content_hash)
.unwrap();
app.review.session.mark_viewed("src/lib.rs", &hash);
app.diff.as_mut().unwrap().invalidate();
let terminal = render(&mut app);
let content = terminal.backend().to_string();
assert!(
content.contains("✓"),
"viewed check in the sidebar: {content}"
);
assert!(
content.contains("·1"),
"comment count in the sidebar: {content}"
);
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn file_header_shows_resolved_when_all_comments_are_resolved() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
cursor_to_added_line(&mut app);
app.handle(key('c'));
for c in "why 42?".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
let id = app.review.session.comments[0].id.clone();
app.review.session.resolve(&id);
app.diff.as_mut().unwrap().invalidate();
let terminal = render(&mut app);
let content = terminal.backend().to_string();
assert!(
content.contains("· resolved"),
"all-resolved file shows the marker: {content}"
);
assert!(
!content.contains("1 comment"),
"resolved comments do not count: {content}"
);
insta::assert_snapshot!(terminal.backend());
}
#[test]
fn status_bar_shows_viewed_progress() {
let (_fixture, mut app) = diff_app();
let content = render(&mut app).backend().to_string();
assert!(content.contains("viewed 0/3 files"), "{content}");
app.handle(key('v'));
let content = render(&mut app).backend().to_string();
assert!(content.contains("viewed 1/3 files"), "{content}");
}
#[test]
fn viewed_walk_advances_the_selection() {
let (_fixture, mut app) = diff_app();
app.handle(key('v'));
app.handle(key('v'));
insta::assert_snapshot!(render(&mut app).backend());
}
#[test]
fn diff_pane_renders_a_sliding_window_and_jumps_to_extremes() {
let fixture = Fixture::new();
let lines: Vec<String> = (1..=2000).map(|i| format!("line {i}")).collect();
fixture.write("big.txt", &(lines.join("\n") + "\n"));
fixture.commit_all("initial commit");
let edited: Vec<String> = (1..=2000)
.map(|i| {
if i % 10 == 0 {
format!("edited {i}")
} else {
format!("line {i}")
}
})
.collect();
fixture.write("big.txt", &(edited.join("\n") + "\n"));
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.open_working_tree_file("big.txt");
let total_rows = app.diff.as_ref().unwrap().rows().len();
assert!(
total_rows > 200,
"the model must dwarf the viewport: {total_rows}"
);
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).expect("terminal");
let mut render = |app: &mut App| {
terminal
.draw(|frame| crate::ui::draw(frame, app))
.expect("draw");
terminal.backend().to_string()
};
let top = render(&mut app);
assert!(top.contains("line 1"), "{top}");
let diff = app.diff.as_ref().unwrap();
assert_eq!(diff.scroll, 0);
assert!(diff.highlights.contains_key("big.txt"));
app.handle(key('G'));
assert_eq!(app.diff.as_ref().unwrap().cursor, total_rows - 1);
let bottom = render(&mut app);
let diff = app.diff.as_ref().unwrap();
let viewport = usize::from(diff.viewport);
assert!(viewport < total_rows, "sanity: window smaller than model");
assert_eq!(
diff.scroll,
total_rows - viewport,
"scroll pins the cursor to the last body row"
);
assert!(bottom.contains("2000"), "tail row visible: {bottom}");
app.handle(key('g'));
app.handle(key('g'));
assert_eq!(app.diff.as_ref().unwrap().cursor, 0);
let top_again = render(&mut app);
assert!(top_again.contains("line 1"), "{top_again}");
assert_eq!(app.diff.as_ref().unwrap().scroll, 0);
}
#[test]
fn comment_input_modal_renders_over_the_diff() {
let (_fixture, mut app) = diff_app();
open_lib_diff(&mut app);
cursor_to_added_line(&mut app);
app.handle(key('c'));
for c in "why".chars() {
app.handle(key(c));
}
insta::assert_snapshot!(render(&mut app).backend());
}
}