use super::*;
use crate::diff_review::{ReviewFile, ReviewLine, ReviewLineKind, ReviewSnapshot};
use ratatui::{Terminal, backend::TestBackend};
fn state() -> MissionControlState {
let mut state = MissionControlState::default();
state.select_right_column_tab(RightColumnTab::Diff);
state.diff.apply_snapshot(Arc::new(PreparedSnapshot::new(
ReviewSnapshot {
root: "/repo".into(),
files: vec![ReviewFile {
path: "src/main.rs".into(),
change: Some(crate::diff_review::ReviewFileChange::Edited),
original: "fn old() {}\n".into(),
current: "fn new() {}\n".into(),
notice: None,
rows: vec![
ReviewLine {
old_line: Some(1),
new_line: None,
text: "fn old() {}".into(),
kind: ReviewLineKind::Removed,
},
ReviewLine {
old_line: None,
new_line: Some(1),
text: "fn new() {}".into(),
kind: ReviewLineKind::Added,
},
],
}],
comments: vec![],
},
None,
)));
state
}
#[test]
fn file_status_colors_preserve_selection_and_fit_narrow_columns() {
use crate::diff_review::ReviewFileChange;
use ratatui::style::Modifier;
let mut state = state();
let theme = state.theme;
let changes = [
(
Some(ReviewFileChange::Written),
"W",
theme.diff_inserted_color(),
),
(
Some(ReviewFileChange::Edited),
"E",
theme.diff_changed_color(),
),
(
Some(ReviewFileChange::Deleted),
"D",
theme.diff_removed_color(),
),
(None, "", theme.text_primary()),
];
let mut source = (**state.diff.snapshot.as_ref().unwrap()).clone();
source.files = changes
.iter()
.enumerate()
.map(|(index, (change, _, _))| {
let mut file = source.files[0].clone();
file.path = format!("src/{index}.rs");
file.change = *change;
file
})
.collect();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(source, None)));
for width in [30, 120] {
let area = Rect::new(0, 0, width, 12);
let files = render::content_area(render::columns(area)[0]);
let mut terminal = Terminal::new(TestBackend::new(width, area.height)).unwrap();
for selected in 1..=changes.len() {
state.diff.tree_row = selected;
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(buffer[(files.x, files.y)].symbol(), "s"); for (index, (_, marker, color)) in changes.iter().enumerate() {
let y = files.y + index as u16 + 1;
if !marker.is_empty() {
let cell = &buffer[(files.x, y)];
assert_eq!(cell.symbol(), *marker);
assert_eq!(cell.fg, *color);
assert_eq!(Some(cell.bg), theme.pane().bg);
assert!(cell.modifier.contains(Modifier::BOLD));
}
if width == 120 {
let x = files.x + if marker.is_empty() { 2 } else { 4 };
let cell = &buffer[(x, y)];
assert_eq!(cell.symbol(), index.to_string());
let expected = if selected == index + 1 {
theme.right_column_rail_active_tab()
} else if marker.is_empty() {
theme.pane()
} else {
theme.pane().fg(*color)
};
assert_eq!(Some(cell.fg), expected.fg);
assert_eq!(Some(cell.bg), expected.bg);
}
}
}
}
}
#[test]
fn diff_rail_dot_has_color_and_clickable_rows_without_a_file_count() {
let mut state = state();
let area = Rect::new(0, 0, 100, 40);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
for unseen in [true, false] {
state.diff.has_unseen_changes = unseen;
for selected in [
RightColumnTab::Activity,
RightColumnTab::Summary,
RightColumnTab::Diff,
] {
state.right_column_tab = selected;
let label = if unseen && selected != RightColumnTab::Diff {
"●Diff"
} else {
" Diff"
};
let layout = crate::tui::layout::layout_for(area, &state);
let tab = layout.right_column_tab_area(RightColumnTab::Diff).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
let buffer = terminal.backend().buffer();
assert_eq!(tab.height, 6);
assert!(tab.bottom() <= layout.prompt.y);
assert_eq!(buffer[(tab.x + 2, tab.y - 1)].symbol(), "─");
assert_eq!(layout.right_column_tab_at(tab.x + 2, tab.y - 1), None);
for (offset, character) in format!("{label} ").chars().enumerate() {
let row = tab.y + offset as u16;
assert_eq!(buffer[(tab.x + 2, row)].symbol(), character.to_string());
if character == '●' {
assert_eq!(buffer[(tab.x + 2, row)].fg, state.theme.text_accent());
}
for column in [tab.x + 1, tab.x + 3, tab.x + 4] {
assert_eq!(buffer[(column, row)].symbol(), " ");
}
assert_eq!(
buffer[(tab.x, row)].symbol(),
if selected == RightColumnTab::Diff {
"▌"
} else {
" "
}
);
for column in tab.x..tab.right() {
assert_eq!(
layout.right_column_tab_at(column, row),
Some(RightColumnTab::Diff)
);
}
}
for row in tab.bottom()..layout.prompt.y {
assert_eq!(buffer[(tab.x + 2, row)].symbol(), " ");
assert_eq!(layout.right_column_tab_at(tab.x + 2, row), None);
}
}
}
}
#[test]
fn diff_rail_dot_switches_to_compact_labels_without_clipping() {
let mut state = state();
state.diff.has_unseen_changes = true;
state.right_column_tab = RightColumnTab::Activity;
for height in [6, 30, 31, 32, 34] {
let area = Rect::new(0, 0, 100, height);
let layout = crate::tui::layout::layout_for(area, &state);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
let buffer = terminal.backend().buffer();
for (tab, initial) in [
(RightColumnTab::Activity, "A"),
(RightColumnTab::Summary, "S"),
(RightColumnTab::Diff, "D"),
] {
let tab_area = layout.right_column_tab_area(tab).unwrap();
assert!(tab_area.bottom() <= layout.prompt.y);
if height < 31 {
assert_eq!(tab_area.height, 1);
assert_eq!(buffer[(tab_area.x, tab_area.y)].symbol(), initial);
if tab == RightColumnTab::Diff {
assert_eq!(buffer[(tab_area.x + 4, tab_area.y)].symbol(), "●");
}
} else if tab == RightColumnTab::Diff {
assert_eq!(tab_area.height, 6);
assert_eq!(buffer[(tab_area.x + 2, tab_area.y)].symbol(), "●");
assert_eq!(buffer[(tab_area.x + 2, tab_area.y + 1)].symbol(), "D");
}
assert_eq!(
layout.right_column_tab_at(tab_area.x, tab_area.bottom() - 1),
Some(tab)
);
}
}
}
fn key(state: &mut MissionControlState, code: KeyCode) {
apply(
state,
DiffCommand::Key(KeyEvent::new(code, KeyModifiers::NONE)),
);
}
fn alt(state: &mut MissionControlState, character: char) {
apply(
state,
DiffCommand::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::ALT)),
);
}
#[test]
fn refresh_shortcut_preserves_prompt_draft_and_queued_save() {
let mut state = state();
state.set_prompt_text("Keep my prompt", 14);
let refresh = KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE);
assert!(owns_key(&state, refresh));
key(&mut state, KeyCode::F(5));
assert!(state.diff.full_refresh_requested);
state.diff.full_refresh_requested = false;
state.diff.open_line(true);
apply(&mut state, DiffCommand::Paste("Keep my draft".into()));
key(&mut state, KeyCode::F(5));
assert_eq!(
state.diff.dialog.as_ref().unwrap().editor.text(),
"Keep my draft"
);
alt(&mut state, 's');
key(&mut state, KeyCode::F(5));
assert!(state.diff.saving);
assert!(
matches!(&state.diff.pending, Some(CommentChange::Save { text, .. }) if text == "Keep my draft")
);
assert_eq!(state.prompt_plain_text(), "Keep my prompt");
}
#[test]
fn refresh_button_is_drawn_and_clickable_without_a_snapshot() {
let mut state = state();
state.diff.snapshot = None;
state.diff.error = Some("scan failed".into());
let area = Rect::new(0, 0, 120, 35);
let body = super::super::layout::layout_for(area, &state).transcript;
let button = render::refresh_button_area(render::columns(body)[0]);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, body, &state)).unwrap();
let label: String = (button.x..button.right())
.map(|x| terminal.backend().buffer()[(x, button.y)].symbol())
.collect();
assert!(label.contains("Refresh"), "{label}");
apply(
&mut state,
DiffCommand::Mouse(
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: button.x,
row: button.y,
modifiers: KeyModifiers::NONE,
},
area,
1,
),
);
assert!(state.diff.full_refresh_requested);
}
#[test]
fn diff_focus_cycle_preserves_prompt_and_routes_comments() {
let mut state = state();
state.set_prompt_text("Keep my prompt", 14);
assert!(!owns_key(
&state,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
));
for expected in [1, 2, 3, 0] {
key(&mut state, KeyCode::Tab);
assert_eq!(state.diff.focus, expected);
}
assert!(state.is_prompt_focused());
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
let dialog = state.diff.dialog.as_ref().unwrap();
assert_eq!(dialog.side, ReviewSide::Changed);
assert_eq!(dialog.line, 1);
apply(
&mut state,
DiffCommand::Paste("Review this\ncarefully".into()),
);
alt(&mut state, 's');
assert!(state.diff.saving);
assert!(
matches!(&state.diff.pending, Some(CommentChange::Save { text, line: 1, .. }) if text == "Review this\ncarefully")
);
assert!(
state.diff.dialog.is_some(),
"keep draft until worker acknowledges"
);
assert_eq!(state.prompt_plain_text(), "Keep my prompt");
}
#[test]
fn vanished_file_comments_can_be_opened_and_resolved() {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].original.clear();
snapshot.files[0].rows.clear();
snapshot.comments.push(ReviewComment {
id: "one".into(),
path: "src/main.rs".into(),
side: ReviewSide::Changed,
line: 3,
anchor: "gone".into(),
text: "retained".into(),
stale: true,
resolved: false,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
assert!(state.diff.dialog.as_ref().unwrap().stale);
alt(&mut state, 'r');
assert!(matches!(
state.diff.pending,
Some(CommentChange::Resolve { resolved: true, .. })
));
}
#[test]
fn diff_render_replaces_transcript_and_keeps_rail_on_short_screens() {
for (width, height) in [(120, 35), (80, 18), (32, 10)] {
let state = state();
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| crate::tui::render::draw(frame, &state))
.unwrap();
let layout = crate::tui::layout::layout_for(Rect::new(0, 0, width, height), &state);
let diff_tab = layout.right_column_tab_area(RightColumnTab::Diff).unwrap();
assert!(diff_tab.bottom() <= height);
assert_eq!(
layout.right_column_tab_at(diff_tab.x, diff_tab.y),
Some(RightColumnTab::Diff)
);
assert!(layout.activity_tree.is_none());
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("Diff"));
if width >= 80 {
assert!(text.contains("Original"));
assert!(text.contains("fn new"));
}
}
}
#[test]
fn mouse_source_selects_before_opening_and_escape_cancels() {
let mut state = state();
let area = Rect::new(0, 0, 120, 35);
let columns = render::columns(crate::tui::layout::layout_for(area, &state).transcript);
let click = |x, y| MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
};
apply(
&mut state,
DiffCommand::Mouse(click(columns[1].x + 1, columns[1].y + 1), area, 1),
);
assert!(state.diff.dialog.is_none());
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal
.draw(|frame| {
draw(
frame,
crate::tui::layout::layout_for(area, &state).transcript,
&state,
)
})
.unwrap();
let marker: String = (1..=3)
.map(|offset| {
terminal.backend().buffer()[(columns[1].x + offset, columns[1].y + 1)].symbol()
})
.collect();
assert_eq!(marker, "[+]");
apply(
&mut state,
DiffCommand::Mouse(click(columns[1].x + 1, columns[1].y + 1), area, 1),
);
assert_eq!(
state.diff.dialog.as_ref().unwrap().side,
ReviewSide::Original
);
key(&mut state, KeyCode::Esc);
assert!(state.diff.dialog.is_none());
}
#[test]
fn diff_folder_grouping_and_refresh_retain_comment_identity() {
let mut state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.comments.push(ReviewComment {
id: "selected".into(),
path: "src/main.rs".into(),
side: ReviewSide::Changed,
line: 1,
anchor: "fn new() {}".into(),
text: "review".into(),
stale: false,
resolved: false,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(source.clone(), None)));
assert_eq!(
state
.diff
.tree_entries()
.iter()
.map(|e| e.label.as_str())
.collect::<Vec<_>>(),
["src/", " main.rs", " [•] D:1 review"]
);
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::Down);
let mut other = source.files[0].clone();
other.path = "aaa/nested/other.rs".into();
source.files.insert(0, other);
let mut comment = source.comments[0].clone();
comment.id = "earlier".into();
source.comments.insert(0, comment);
let refreshed = PreparedSnapshot::new(source, state.diff.snapshot.as_deref());
state.diff.apply_snapshot(Arc::new(refreshed));
assert_eq!(state.diff.selected, 1);
assert_eq!(
state.diff.tree_entries()[state.diff.tree_row].identity,
"comment:selected"
);
assert!(
state
.diff
.tree_entries()
.iter()
.any(|entry| entry.label == " nested/")
);
assert!(
state
.diff
.tree_entries()
.iter()
.any(|entry| entry.label == " other.rs")
);
key(&mut state, KeyCode::Enter);
assert_eq!(
state.diff.dialog.as_ref().unwrap().id.as_deref(),
Some("selected")
);
}
#[test]
fn diff_projection_preserves_multiline_syntax_and_reuses_unchanged_sources() {
use crate::rendering::DisplayRole;
let state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.files[0].original = "/* start\ninside comment\n*/\n".into();
source.files[0].current = "/* start\nchanged comment\n*/\n".into();
let first = PreparedSnapshot::new(source.clone(), None);
assert!(
first.files_display[0].original[1]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
assert!(
first.files_display[0].current[1]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
let second = PreparedSnapshot::new(source.clone(), Some(&first));
assert!(Arc::ptr_eq(
&first.files_display[0],
&second.files_display[0]
));
source.files[0].current.push_str("fn changed() {}\n");
let third = PreparedSnapshot::new(source, Some(&second));
assert!(!Arc::ptr_eq(
&second.files_display[0],
&third.files_display[0]
));
}
#[test]
fn diff_original_removals_are_colored_and_worktree_is_visible() {
use crate::rendering::DisplayRole;
let state = state();
let mut terminal = Terminal::new(TestBackend::new(120, 15)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
let text: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(text.contains("Files · /repo"));
assert!(text.contains("src/"));
assert!(text.contains(" main.rs"));
let original = render::columns(Rect::new(0, 0, 120, 15))[1];
assert_eq!(
buffer[(original.x + 8, original.y + 1)].bg,
state
.theme
.display_role(DisplayRole::DiffRemoved)
.bg
.unwrap()
);
}
#[test]
fn clicking_source_borders_does_not_open_a_comment() {
let mut state = state();
let area = Rect::new(0, 0, 120, 35);
let columns = render::columns(super::super::layout::layout_for(area, &state).transcript);
for column in &columns[1..] {
for (x, y) in [
(column.x, column.y),
(column.x + 2, column.bottom() - 1),
(column.x + 2, column.bottom() - 2),
(column.right() - 1, column.y + 1),
(column.right() - 2, column.y + 1),
] {
mouse_input(
&mut state,
MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: x,
row: y,
modifiers: KeyModifiers::NONE,
},
area,
1,
);
assert!(state.diff.dialog.is_none());
}
}
}
fn scrolling_state() -> MissionControlState {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let file = &mut snapshot.files[0];
file.original = (1..=40).map(|line| format!("original {line}\n")).collect();
file.rows = (1..=40)
.map(|line| ReviewLine {
old_line: Some(line),
new_line: Some(line),
text: format!("original {line}"),
kind: ReviewLineKind::Context,
})
.collect();
file.rows[2].new_line = None;
file.rows[2].kind = ReviewLineKind::Removed;
file.rows.insert(
1,
ReviewLine {
old_line: None,
new_line: Some(2),
text: "inserted".into(),
kind: ReviewLineKind::Added,
},
);
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state
}
#[test]
fn wheel_keeps_focus_and_selection_and_click_targets_scrolled_line() {
let mut state = scrolling_state();
let area = Rect::new(0, 0, 120, 30);
let columns = render::columns(crate::tui::layout::layout_for(area, &state).transcript);
let mouse = |kind, pane: usize| MouseEvent {
kind,
column: columns[pane].x + 1,
row: columns[pane].y + 1,
modifiers: KeyModifiers::NONE,
};
let focus = state.focus_pane;
let tree_row = state.diff.tree_row;
for pane in [1, 2, 0] {
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::ScrollDown, pane), area, 1),
);
}
assert_eq!(state.focus_pane, focus);
assert_eq!(
(
state.diff.focus,
state.diff.original_row,
state.diff.diff_row,
state.diff.tree_row
),
(0, 0, 0, tree_row)
);
assert_eq!(state.diff.viewport_offsets(5)[1..], [1, 2]);
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2), area, 1),
);
assert!(state.diff.dialog.is_none());
apply(
&mut state,
DiffCommand::Mouse(mouse(MouseEventKind::Down(MouseButton::Left), 2), area, 1),
);
let dialog = state.diff.dialog.as_ref().unwrap();
assert_eq!((dialog.side, dialog.line), (ReviewSide::Changed, 2));
}
#[test]
fn scroll_mapping_handles_additions_deletions_and_keyboard_visibility() {
let mut state = scrolling_state();
let page = &mut state.diff;
page.scroll_viewport(3, true, 5, 1);
assert_eq!(page.viewport_offsets(5)[1..], [1, 1]);
page.scroll_viewport(3, true, 5, 1);
assert_eq!(page.viewport_offsets(5)[1..], [1, 2]);
page.scroll_viewport(2, true, 5, 1);
assert_eq!(page.viewport_offsets(5)[1..], [2, 3]);
for _ in 0..15 {
page.scroll_viewport(3, true, 5, 1);
}
page.focus = 3;
page.navigate(true);
assert_eq!(page.diff_row, 1);
assert_eq!(page.viewport_offsets(5)[2], 1);
for _ in 0..10 {
page.navigate(true);
}
assert_eq!(page.viewport_offsets(5)[2], 7);
let mut snapshot = (***page.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].rows.truncate(1);
snapshot.files[0].original = "one\n".into();
page.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
assert_eq!(page.viewport_offsets(5)[1..], [0, 0]);
assert_eq!((page.original_row, page.diff_row), (0, 0));
}
#[test]
fn diff_large_source_keeps_syntax_and_plain_budget_remainder() {
use crate::rendering::{DisplayRole, highlight::highlight_source_file};
let source = format!(
"fn start() {{}}\n/*\n{}*/\nfn end() {{}}\n",
"inside\n".repeat(450)
);
let state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].current = source;
let prepared = PreparedSnapshot::new(snapshot, None);
let lines = &prepared.files_display[0].current;
assert!(
lines[0]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert!(
lines[440]
.spans
.iter()
.all(|span| span.role == DisplayRole::Comment)
);
assert!(
lines[453]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert_ne!(
state.theme.display_role(DisplayRole::Keyword).fg,
state.theme.display_role(DisplayRole::FallbackCode).fg
);
let source = format!("fn start() {{}}\n{}\nfn remainder() {{}}", "x".repeat(4097));
let lines = highlight_source_file(&source, Some("rs"));
assert!(
lines[0]
.spans
.iter()
.any(|span| span.role == DisplayRole::Keyword)
);
assert_eq!(lines[2].plain_text(), "fn remainder() {}");
assert!(
lines[2]
.spans
.iter()
.all(|span| span.role == DisplayRole::FallbackCode)
);
}
#[test]
fn diff_plain_unknown_source_uses_themed_change_foregrounds() {
use crate::rendering::DisplayRole;
let mut state = state();
let mut source = (***state.diff.snapshot.as_ref().unwrap()).clone();
source.files[0].path = "unknown.no_syntax".into();
source.files[0].original = "OLD\n".into();
source.files[0].current = "NEW\n".into();
source.files[0].rows[0].text = "OLD".into();
source.files[0].rows[1].text = "NEW".into();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(source, None)));
for depth in [
crate::appearance::ColorDepth::TrueColor,
crate::appearance::ColorDepth::Ansi256,
] {
let mut appearance = crate::appearance::RuntimeAppearance::default();
appearance.policy.depth = depth;
state.theme =
crate::tui::theme::MissionControlTheme::from_runtime_appearance(&appearance, 1);
let mut terminal = Terminal::new(TestBackend::new(120, 15)).unwrap();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let buffer = terminal.backend().buffer();
for (text, role) in [
("OLD", DisplayRole::DiffRemoved),
("NEW", DisplayRole::DiffInserted),
] {
let cells = buffer
.content
.windows(3)
.find(|cells| cells.iter().map(|cell| cell.symbol()).collect::<String>() == text)
.unwrap();
assert!(
cells
.iter()
.all(|cell| Some(cell.fg) == state.theme.display_role(role).fg)
);
}
}
}
#[test]
fn diff_dialog_keeps_alt_tab_shortcuts_while_normal_diff_releases_them() {
use crate::tui::input::{KeyViewports, handle_key_with_all_viewports};
let mut state = state();
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Enter);
assert!(state.diff.dialog.is_some());
for number in ['1', '2', '3'] {
handle_key_with_all_viewports(
KeyEvent::new(KeyCode::Char(number), KeyModifiers::ALT),
&mut state,
KeyViewports::default(),
&[],
);
assert_eq!(state.right_column_tab, RightColumnTab::Diff);
assert!(state.diff.dialog.is_some());
}
key(&mut state, KeyCode::Esc);
for (number, expected) in [('3', RightColumnTab::Diff), ('1', RightColumnTab::Activity)] {
handle_key_with_all_viewports(
KeyEvent::new(KeyCode::Char(number), KeyModifiers::ALT),
&mut state,
KeyViewports::default(),
&[],
);
assert_eq!(state.right_column_tab, expected);
}
}
#[test]
fn comment_editor_deletion_keys_only_edit_and_alt_d_discards() {
let mut state = state();
state.diff.open_line(true);
let dialog = state.diff.dialog.as_mut().unwrap();
dialog.id = Some("saved".into());
apply(&mut state, DiffCommand::Paste("ab".into()));
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Backspace);
assert_eq!(state.diff.dialog.as_ref().unwrap().editor.text(), "a");
key(&mut state, KeyCode::Left);
key(&mut state, KeyCode::Delete);
assert_eq!(state.diff.dialog.as_ref().unwrap().editor.text(), "");
assert!(state.diff.pending.is_none());
apply(
&mut state,
DiffCommand::Key(KeyEvent::new_with_kind(
KeyCode::Backspace,
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Repeat,
)),
);
assert!(state.diff.pending.is_none());
key(&mut state, KeyCode::Delete);
key(&mut state, KeyCode::Backspace);
assert!(state.diff.pending.is_none());
assert!(state.diff.dialog.is_some());
apply(&mut state, DiffCommand::Paste("nonempty draft".into()));
alt(&mut state, 'd');
assert!(matches!(&state.diff.pending, Some(CommentChange::Delete(id)) if id == "saved"));
assert!(state.diff.dialog.is_some());
}
#[test]
fn comment_dialog_is_compact_centered_and_reopens_resolved_comments() {
let mut state = state();
state.diff.open_line(true);
let dialog = state.diff.dialog.as_mut().unwrap();
dialog.id = Some("saved".into());
dialog.resolved = true;
let area = Rect::new(0, 0, 120, 35);
let text = render::dialog_text_area(area);
assert_eq!(text, Rect::new(37, 15, 46, 5));
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let buffer = terminal.backend().buffer();
let output: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert!(output.contains("[Alt-S] Save"));
assert!(output.contains("[Alt-R] Reopen"));
assert!(output.contains("[Alt-D] Discard"));
assert!(!output.contains("Tab:"));
alt(&mut state, 'r');
assert!(matches!(
state.diff.pending,
Some(CommentChange::Resolve {
resolved: false,
..
})
));
}
#[test]
fn empty_save_keeps_dialog_and_refresh_requires_another_first_click() {
let mut state = state();
state.diff.open_line(true);
alt(&mut state, 's');
assert!(state.diff.dialog.is_some());
assert!(!state.diff.saving);
assert!(state.diff.error.is_some());
key(&mut state, KeyCode::Esc);
state.diff.clicked_source = Some((2, 0));
state
.diff
.apply_snapshot(state.diff.snapshot.clone().unwrap());
assert!(state.diff.clicked_source.is_none());
}
#[test]
fn compact_comment_keeps_wrapped_paragraph_cursor_visible() {
assert_compact_comment_cursor_visible(false);
}
#[test]
fn compact_comment_keeps_trailing_newline_cursor_visible() {
assert_compact_comment_cursor_visible(true);
}
fn assert_compact_comment_cursor_visible(trailing_newline: bool) {
for width in [38, 80, 120] {
let mut state = state();
key(&mut state, KeyCode::BackTab);
key(&mut state, KeyCode::Down);
key(&mut state, KeyCode::Enter);
for character in "Review 界 e\u{301} carefully. ".repeat(30).chars() {
key(&mut state, KeyCode::Char(character));
}
if trailing_newline {
key(&mut state, KeyCode::Enter);
}
let mut terminal = Terminal::new(TestBackend::new(width, 20)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let text_area = render::dialog_text_area(Rect::new(0, 0, width, 20));
assert_eq!(text_area.height, 2);
let cursor =
ratatui::backend::Backend::get_cursor_position(terminal.backend_mut()).unwrap();
assert!(text_area.contains(cursor), "width {width}: {cursor:?}");
let editor = &state.diff.dialog.as_ref().unwrap().editor;
assert_eq!(editor.rendered_cursor_position(), Some(cursor));
assert_eq!(editor.cursor_at_position(cursor), Some(editor.text().len()));
if trailing_newline {
assert_eq!(cursor.x, text_area.x);
}
key(&mut state, KeyCode::Char('Z'));
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let cursor =
ratatui::backend::Backend::get_cursor_position(terminal.backend_mut()).unwrap();
assert!(text_area.contains(cursor));
let buffer = terminal.backend().buffer();
assert!(
(text_area.y..text_area.bottom()).any(|y| {
(text_area.x..text_area.right()).any(|x| buffer[(x, y)].symbol() == "Z")
}),
"newly typed text must remain visible at width {width}"
);
}
}
#[test]
fn alt_d_discards_unsaved_draft_without_deleting_a_saved_comment() {
let mut state = state();
state.diff.open_line(true);
apply(&mut state, DiffCommand::Paste("draft".into()));
alt(&mut state, 'd');
assert!(state.diff.dialog.is_none());
assert!(state.diff.pending.is_none());
assert!(!state.diff.saving);
}
#[test]
fn comment_dialog_busy_shell_does_not_offer_or_allow_close() {
for pending_taken in [false, true] {
let mut state = state();
state.diff.open_line(true);
apply(&mut state, DiffCommand::Paste("draft".into()));
alt(&mut state, 's');
assert!(state.diff.saving);
if pending_taken {
state.diff.pending.take();
}
let mut terminal = Terminal::new(TestBackend::new(120, 35)).unwrap();
terminal.draw(|frame| draw_dialog(frame, &state)).unwrap();
let output: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(output.contains("Saving…"));
assert!(!output.contains("[Esc]"));
assert!(!output.contains("[Alt-S]"));
key(&mut state, KeyCode::Esc);
assert!(state.diff.dialog.is_some());
}
}
#[test]
fn compact_gutter_keeps_source_visible_and_colors_comment_status() {
use crate::rendering::DisplayRole;
for (stale, resolved, marker, role) in [
(false, false, "[•]", DisplayRole::Link),
(true, false, "[•]", DisplayRole::DiffChanged),
(false, true, "[✓]", DisplayRole::DiffInserted),
] {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.comments.push(ReviewComment {
id: "marker".into(),
path: "src/main.rs".into(),
side: ReviewSide::Original,
line: 1,
anchor: "anchor".into(),
text: "Review".into(),
stale,
resolved,
});
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state.diff.focus = 2;
let area = Rect::new(0, 0, 50, 8);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let buffer = terminal.backend().buffer();
let original = render::content_area(render::columns(area)[1]);
let text: String = (original.x..original.right())
.map(|x| buffer[(x, original.y)].symbol())
.collect();
assert!(text.starts_with(&format!("{marker}1 fn old()")), "{text}");
assert_eq!(
buffer[(original.x + 1, original.y)].fg,
state.theme.display_role(role).fg.unwrap()
);
}
}
fn wide_diff_state(padding: &str, rows: usize) -> MissionControlState {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let file = &mut snapshot.files[0];
file.path = format!("src/{}PATH_END.rs", "long_name_".repeat(12));
file.original = (1..=rows)
.map(|line| format!("let line_{line} = \"{padding}e\u{301}TAIL\";\n"))
.collect();
file.current = file.original.clone();
file.rows = file
.original
.lines()
.enumerate()
.map(|(index, text)| ReviewLine {
old_line: Some(index + 1),
new_line: Some(index + 1),
text: text.to_owned(),
kind: ReviewLineKind::Context,
})
.collect();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state
}
fn routed_diff_mouse(
state: &mut MissionControlState,
area: Rect,
pane: usize,
kind: MouseEventKind,
modifiers: KeyModifiers,
amount: u16,
) {
let column = render::columns(crate::tui::layout::layout_for(area, state).transcript)[pane - 1];
let mouse = MouseEvent {
kind,
modifiers,
column: column.x + 1,
row: column.y + 1,
};
let command =
crate::tui::input::mouse::classify_mouse_event(mouse, area, state, amount).unwrap();
let crate::tui::input::DisplayCommand::Diff(command) = command else {
panic!("diff owns this mouse event")
};
apply(state, command);
}
fn diff_buffer(state: &MissionControlState, area: Rect) -> ratatui::buffer::Buffer {
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
let body = crate::tui::layout::layout_for(area, state).transcript;
terminal.draw(|frame| draw(frame, body, state)).unwrap();
terminal.backend().buffer().clone()
}
fn buffer_row(buffer: &ratatui::buffer::Buffer, area: Rect, row: u16) -> String {
(area.x..area.right())
.map(|x| buffer[(x, area.y + row)].symbol())
.collect()
}
#[test]
fn horizontal_wheel_reveals_long_lines_and_paths_without_moving_selection() {
let mut state = wide_diff_state(&"界".repeat(60), 30);
let area = Rect::new(0, 0, 120, 30);
let columns = render::columns(crate::tui::layout::layout_for(area, &state).transcript);
let initial = diff_buffer(&state, area);
let focus = state.focus_pane;
let selection = (
state.diff.selected,
state.diff.tree_row,
state.diff.original_row,
state.diff.diff_row,
);
let vertical = state.diff.viewport_offsets(10);
for pane in 1..=3 {
let content = render::content_area(columns[pane - 1]);
let row = u16::from(pane == 1);
let suffix = if pane == 1 {
"PATH_END.rs"
} else {
"e\u{301}TAIL\";"
};
assert!(!buffer_row(&initial, content, row).contains(suffix));
routed_diff_mouse(
&mut state,
area,
pane,
MouseEventKind::ScrollDown,
KeyModifiers::SHIFT,
u16::MAX,
);
let buffer = diff_buffer(&state, area);
assert!(
buffer_row(&buffer, content, row)
.trim_end()
.ends_with(suffix)
);
assert_eq!(state.focus_pane, focus);
assert_eq!(
(
state.diff.selected,
state.diff.tree_row,
state.diff.original_row,
state.diff.diff_row
),
selection
);
assert_eq!(state.diff.viewport_offsets(10), vertical);
assert!(
state
.diff
.horizontal
.iter()
.enumerate()
.all(|(index, offset)| index == pane - 1 || *offset == 0)
);
let track = buffer_row(&buffer, content, content.height);
assert!(track.contains('━') && track.contains('─'), "{track}");
for x in content.x..content.right() {
let cell = &buffer[(x, content.bottom())];
assert_eq!(cell.bg, state.theme.surface_background());
assert_eq!(
cell.fg,
if cell.symbol() == "━" {
state.theme.border_active()
} else {
state.theme.text_dim()
}
);
}
routed_diff_mouse(
&mut state,
area,
pane,
MouseEventKind::ScrollLeft,
KeyModifiers::NONE,
u16::MAX,
);
assert_eq!(
buffer_row(&diff_buffer(&state, area), content, row),
buffer_row(&initial, content, row)
);
routed_diff_mouse(
&mut state,
area,
pane,
MouseEventKind::ScrollRight,
KeyModifiers::NONE,
3,
);
routed_diff_mouse(
&mut state,
area,
pane,
MouseEventKind::ScrollUp,
KeyModifiers::SHIFT,
3,
);
assert_eq!(
buffer_row(&diff_buffer(&state, area), content, row),
buffer_row(&initial, content, row)
);
}
routed_diff_mouse(
&mut state,
area,
3,
MouseEventKind::ScrollRight,
KeyModifiers::NONE,
20,
);
routed_diff_mouse(
&mut state,
area,
3,
MouseEventKind::ScrollDown,
KeyModifiers::NONE,
5,
);
assert_eq!(state.diff.viewport_offsets(10)[1..], [5, 5]);
assert_eq!(state.diff.horizontal[2], 20);
for _ in 0..2 {
routed_diff_mouse(
&mut state,
area,
3,
MouseEventKind::Down(MouseButton::Left),
KeyModifiers::NONE,
1,
);
}
let dialog = state.diff.dialog.as_ref().unwrap();
assert_eq!((dialog.side, dialog.line), (ReviewSide::Changed, 6));
}
#[test]
fn horizontal_scroll_handles_very_long_lines_resize_refresh_and_file_selection() {
let mut state = wide_diff_state(&"界".repeat(36_000), 1);
let area = Rect::new(0, 0, 120, 30);
let column = render::columns(crate::tui::layout::layout_for(area, &state).transcript)[1];
let content = render::content_area(column);
for _ in 0..2 {
routed_diff_mouse(
&mut state,
area,
2,
MouseEventKind::ScrollDown,
KeyModifiers::SHIFT,
u16::MAX,
);
}
assert!(
buffer_row(&diff_buffer(&state, area), content, 0)
.trim_end()
.ends_with("TAIL\";")
);
for width in [240, 60, 5, 1] {
diff_buffer(&state, Rect::new(0, 0, width, 30));
}
for height in 1..=5 {
diff_buffer(&state, Rect::new(0, 0, 120, height));
}
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0].original = "short\n".into();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
assert!(buffer_row(&diff_buffer(&state, area), content, 0).contains("short"));
routed_diff_mouse(
&mut state,
area,
3,
MouseEventKind::ScrollRight,
KeyModifiers::NONE,
100,
);
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let mut file = snapshot.files[0].clone();
file.path = "src/other.rs".into();
snapshot.files.push(file);
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
key(&mut state, KeyCode::Tab);
key(&mut state, KeyCode::Down);
assert_eq!(state.diff.selected, 1);
assert_eq!(state.diff.horizontal[1..], [0, 0]);
}
#[test]
fn diff_scrollbars_match_shared_widget_at_each_viewport_offset() {
let mut state = scrolling_state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
for index in 0..20 {
let mut file = snapshot.files[0].clone();
file.path = format!("src/extra{index}.rs");
snapshot.files.push(file);
}
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
let area = Rect::new(0, 0, 100, 10);
let visible = 7;
for position in [0, 1, 20, 100] {
state.diff.viewport = DiffViewport {
tree: position,
original: position,
diff: position + 1,
reveal: 0,
};
let offsets = state.diff.viewport_offsets(visible);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
for (index, column) in render::columns(area).iter().enumerate() {
let scrollbar_area = render::vertical_scrollbar_area(*column);
let mut expected = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
expected
.draw(|frame| {
crate::tui::render::render_scrollbar_with_theme(
frame,
scrollbar_area,
state.diff.row_count(index + 1).saturating_sub(visible) + 1,
offsets[index],
visible,
state.theme,
)
})
.unwrap();
let x = scrollbar_area.right() - 1;
let mut symbols = String::new();
for y in scrollbar_area.y..scrollbar_area.bottom() {
let actual = &terminal.backend().buffer()[(x, y)];
assert_eq!(actual, &expected.backend().buffer()[(x, y)]);
symbols.push_str(actual.symbol());
}
assert!(symbols.contains('┃'), "missing thumb: {symbols}");
assert!(symbols.contains('│'), "missing track: {symbols}");
}
}
}
#[test]
fn line_number_width_grows_only_with_source_digits() {
let mut state = scrolling_state();
let area = Rect::new(0, 0, 60, 6);
for (offset, expected) in [
(0, " 1 original 1"),
(9, "10 original 10"),
(36, "37 original 37"),
] {
state.diff.viewport.original = offset;
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let content = render::content_area(render::columns(area)[1]);
let text: String = (content.x..content.right())
.map(|x| terminal.backend().buffer()[(x, content.y)].symbol())
.collect();
assert!(text.starts_with(expected), "{text}");
}
}
#[test]
fn diff_gutters_keep_old_and_new_identities_when_scrolled() {
use crate::rendering::DisplayRole;
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
let file = &mut snapshot.files[0];
file.original = (1..=120)
.map(|line| format!("let value_{line} = {line};\n"))
.collect();
file.current = (1..=145)
.map(|line| format!("// inserted {line}\n"))
.chain(file.original.lines().enumerate().map(|(index, line)| {
if index == 106 {
"let replacement = 252;\n".to_owned()
} else {
format!("{line}\n")
}
}))
.collect();
file.rows = similar::TextDiff::from_lines(&file.original, &file.current)
.iter_all_changes()
.map(|change| ReviewLine {
old_line: change.old_index().map(|index| index + 1),
new_line: change.new_index().map(|index| index + 1),
text: change.value().trim_end_matches('\n').to_owned(),
kind: match change.tag() {
similar::ChangeTag::Equal => ReviewLineKind::Context,
similar::ChangeTag::Delete => ReviewLineKind::Removed,
similar::ChangeTag::Insert => ReviewLineKind::Added,
},
})
.collect();
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
state.diff.focus = 3;
state.diff.diff_row = 252;
let area = Rect::new(0, 0, 120, 7);
let content = render::content_area(render::columns(area)[2]);
let expected = [
" 106 251 let value_106 = 106;",
" 107 -let value_107 = 107;",
"[+] 252+let replacement = 252;",
" 108 253 let value_108 = 108;",
];
for offset in 250..=252 {
state.diff.viewport.diff = offset;
state.diff.viewport.reveal = 0;
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
let buffer = terminal.backend().buffer();
for (index, expected) in expected.iter().enumerate().skip(offset - 250) {
let y = content.y + (250 + index - offset) as u16;
let text: String = (content.x..content.right())
.map(|x| buffer[(x, y)].symbol())
.collect();
assert_eq!(text.trim_end(), *expected);
let display = &state.diff.snapshot.as_ref().unwrap().files_display[0];
let source = match index {
0 => &display.current[250],
1 => &display.original[106],
2 => &display.current[251],
_ => &display.current[252],
};
let role = source.spans[0].role;
assert!(!matches!(
role,
DisplayRole::Plain | DisplayRole::FallbackCode
));
assert_eq!(
buffer[(content.x + 11, y)].fg,
state.theme.display_role(role).fg.unwrap()
);
}
}
}
#[test]
fn diff_number_columns_fit_each_source_independently() {
for (old_count, new_count, removed, added) in [
(9, 120, "1 -fn old() {}", " 1+fn new() {}"),
(120, 9, " 1 -fn old() {}", " 1+fn new() {}"),
(1, 1, "1 -fn old() {}", " 1+fn new() {}"),
] {
let mut state = state();
let mut snapshot = (***state.diff.snapshot.as_ref().unwrap()).clone();
snapshot.files[0]
.original
.push_str(&"// old\n".repeat(old_count - 1));
snapshot.files[0]
.current
.push_str(&"// new\n".repeat(new_count - 1));
state
.diff
.apply_snapshot(Arc::new(PreparedSnapshot::new(snapshot, None)));
let area = Rect::new(0, 0, 100, 6);
let content = render::content_area(render::columns(area)[2]);
let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap();
terminal.draw(|frame| draw(frame, area, &state)).unwrap();
for (index, expected) in [removed, added].into_iter().enumerate() {
let text: String = (content.x..content.right())
.map(|x| terminal.backend().buffer()[(x, content.y + index as u16)].symbol())
.collect();
assert_eq!(text.trim_end(), expected);
}
}
}