use super::*;
use crate::{app::App, ops::Op};
use std::time::{Duration, Instant};
#[test]
fn a_working_tree_patch_admits_stage_and_not_unstage() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "-c", "color.diff=always", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(offers(&app, Op::Stage));
assert!(!offers(&app, Op::Unstage));
}
#[test]
fn an_index_patch_admits_unstage_and_not_stage() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
run(&ctx.dir, &["git", "add", "firstfile"]);
let mut app = ctx.init_app_as_pager_of(&["git", "diff", "--cached"]);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(offers(&app, Op::Unstage));
assert!(!offers(&app, Op::Stage));
}
#[test]
fn a_commit_patch_admits_neither() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
let mut app = ctx.init_app_as_pager_of(&["git", "show", "HEAD"]);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(!offers(&app, Op::Stage));
assert!(!offers(&app, Op::Unstage));
}
#[test]
fn staging_a_hunk_of_a_piped_working_tree_patch_stages_it() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>s"));
assert!(run(&ctx.dir, &["git", "diff", "--cached"]).contains("+changed"));
}
#[test]
fn a_staged_hunk_leaves_the_view_it_was_staged_from() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>s"));
assert!(
!ctx.redact_buffer().contains("changed"),
"the staged line is still on screen:\n{}",
ctx.redact_buffer()
);
}
fn twenty_lines(tenth: &str) -> String {
(1..=20)
.map(|i| {
if i == 10 {
format!("{tenth}\n")
} else {
format!("line {i}\n")
}
})
.collect()
}
#[test]
fn widening_the_context_asks_git_for_more_of_the_file() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
assert!(
!ctx.redact_buffer().contains("line 2 "),
"three lines of context already reach line 2"
);
ctx.update(&mut app, keys("U8<enter>"));
assert!(
ctx.redact_buffer().contains("line 2 "),
"the diff was not re-asked for:\n{}",
ctx.redact_buffer()
);
}
#[test]
fn widening_the_context_of_a_piped_commit() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
commit(&ctx.dir, "firstfile", &twenty_lines("changed"));
let mut app = ctx.init_app_as_pager_of(&["git", "show", "HEAD"]);
assert!(!ctx.redact_buffer().contains("line 2 "));
ctx.update(&mut app, keys("U8<enter>"));
assert!(
ctx.redact_buffer().contains("line 2 "),
"the commit was not re-asked for:\n{}",
ctx.redact_buffer()
);
}
#[test]
fn asking_for_three_lines_overrides_configured_context() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
run(&ctx.dir, &["git", "config", "diff.context", "8"]);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
assert!(ctx.redact_buffer().contains("line 2 "));
ctx.update(&mut app, keys("U3<enter>"));
assert!(
!ctx.redact_buffer().contains("line 2 "),
"{}",
ctx.redact_buffer()
);
}
#[test]
fn an_empty_context_answer_asks_for_no_context_flag() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("U8<enter>U"));
assert_eq!(ctx.prompt_line(), "8|");
ctx.update(&mut app, keys("U<ctrl+u><enter>"));
assert_eq!(app.state.context, None);
}
#[test]
fn the_context_prompt_says_nothing() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("U"));
let buffer = ctx.redact_buffer();
assert!(!buffer.contains('\u{203a}'), "{buffer}");
assert!(!buffer.contains("Context"), "{buffer}");
assert!(!buffer.contains("default"), "{buffer}");
}
#[test]
fn the_whole_function_is_asked_for_by_letter() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("UW<enter>"));
assert_eq!(app.state.context.as_deref(), Some("-W"));
}
#[test]
fn the_command_is_offered_for_editing_as_it_is_being_asked() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "-c", "color.diff=always", "diff"]);
ctx.update(&mut app, keys("U8<enter>:"));
let buffer = ctx.redact_buffer();
assert!(
buffer.contains("git -c color.diff=always diff -U8"),
"{buffer}"
);
}
#[test]
fn editing_the_command_asks_the_new_question() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
run(&ctx.dir, &["git", "add", "firstfile"]);
fs::write(ctx.dir.join("firstfile"), "changed\nagain\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(offers(&app, Op::Stage));
assert!(ctx.redact_buffer().contains("+again"));
ctx.update(&mut app, keys(": --cached<enter><ctrl+j>"));
assert!(offers(&app, Op::Unstage));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("+changed"), "{buffer}");
assert!(!buffer.contains("+again"), "{buffer}");
}
#[test]
fn an_edit_takes_over_the_context_asked_for() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("U8<enter>:<enter>"));
assert_eq!(app.state.context, None);
assert!(
ctx.redact_buffer().contains("line 2 "),
"the widened context was lost:\n{}",
ctx.redact_buffer()
);
}
#[test]
fn an_edit_of_a_stat_keeps_it_a_stat_with_the_context_asked_for() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", &twenty_lines("line 10"));
fs::write(ctx.dir.join("firstfile"), twenty_lines("changed")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff", "--stat"]);
ctx.update(&mut app, keys("U8<enter>:<enter>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("firstfile | 2 +-"), "{buffer}");
assert!(!buffer.contains("modified firstfile"), "{buffer}");
ctx.update(&mut app, keys("<tab>"));
assert!(
ctx.redact_buffer().contains("line 2 "),
"the widened context was lost:\n{}",
ctx.redact_buffer()
);
}
#[test]
fn a_command_git_refuses_says_so_and_changes_nothing() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(": --nonsense<enter>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("invalid option: --nonsense"), "{buffer}");
assert!(buffer.contains("+changed"), "{buffer}");
}
#[test]
fn a_command_can_be_edited_with_the_readline_keys() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
fs::write(ctx.dir.join("firstfile"), "changed\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff", "--cached"]);
ctx.update(&mut app, keys(":<ctrl+w>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("git diff "), "{buffer}");
assert!(!buffer.contains("--cached"), "{buffer}");
}
#[test]
fn the_cursor_is_drawn_at_the_insertion_point() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
fs::write(ctx.dir.join("firstfile"), "changed\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(":"));
assert_eq!(ctx.prompt_line(), "git diff|");
ctx.update(&mut app, keys(":<ctrl+a>"));
assert_eq!(ctx.prompt_line(), "|git diff");
ctx.update(&mut app, keys(":<ctrl+a><alt+f>"));
assert_eq!(ctx.prompt_line(), "git| diff");
ctx.update(&mut app, keys(":<ctrl+a><alt+f>X"));
assert_eq!(ctx.prompt_line(), "gitX| diff");
}
#[test]
fn a_patch_with_no_command_cannot_be_edited() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
let patch = run(&ctx.dir, &["git", "show", "HEAD"]);
let mut app = ctx.init_app_with_patch(patch);
ctx.update(&mut app, keys(":"));
assert!(
ctx.redact_buffer()
.contains("not something gitu asked git for"),
"{}",
ctx.redact_buffer()
);
}
fn two_changed_files(ctx: &TestContext) {
commit(&ctx.dir, "a.rs", "alpha\n");
commit(&ctx.dir, "a_test.rs", "beta\n");
fs::write(ctx.dir.join("a.rs"), "alpha changed\n").unwrap();
fs::write(ctx.dir.join("a_test.rs"), "beta changed\n").unwrap();
}
fn taller_than_the_screen(ctx: &TestContext) {
let lines = |suffix: &str| {
(1..=30)
.map(|i| format!("{i}{suffix}\n"))
.collect::<String>()
};
commit(&ctx.dir, "tall.rs", &lines(""));
fs::write(ctx.dir.join("tall.rs"), lines(" changed")).unwrap();
}
#[test]
fn hiding_the_file_under_the_cursor_drops_it_from_the_view() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("kdj"));
let buffer = ctx.redact_buffer();
assert!(!buffer.contains("alpha"), "{buffer}");
assert!(buffer.contains("beta"), "{buffer}");
assert!(offers(&app, Op::Stage));
}
#[test]
fn hiding_a_file_needs_the_cursor_on_its_header() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("d"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("alpha"), "{buffer}");
assert!(buffer.contains("beta"), "{buffer}");
}
#[test]
fn the_files_to_show_can_be_said_by_pattern() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("&!*_test.rs<enter>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("alpha"), "{buffer}");
assert!(!buffer.contains("beta"), "{buffer}");
}
#[test]
fn the_files_asked_for_come_back_for_editing() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("kd&"));
assert!(
ctx.redact_buffer().contains("Files: › !a.rs"),
"{}",
ctx.redact_buffer()
);
ctx.update(&mut app, keys("<enter>:"));
assert!(
ctx.redact_buffer()
.contains("git diff -- ':(top,exclude)a.rs'"),
"{}",
ctx.redact_buffer()
);
}
#[test]
fn hiding_a_file_puts_the_command_in_the_history() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("kd"));
drop(app);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(":<up>"));
assert_eq!(ctx.prompt_line(), "git diff -- ':(top,exclude)a.rs'|");
}
#[test]
fn command_and_file_histories_persist_separately() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(
&mut app,
keys(
"&!a_test.rs<enter>&<ctrl+u>!a.rs<enter>\
: HEAD<enter>:<ctrl+u>git diff --cached<enter>",
),
);
drop(app);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(":<up>"));
assert_eq!(ctx.prompt_line(), "git diff --cached|");
ctx.update(&mut app, keys("&<up>"));
assert_eq!(ctx.prompt_line(), "? Files: › !a.rs|");
ctx.update(&mut app, keys(":<up><up><down>"));
assert_eq!(ctx.prompt_line(), "git diff --cached|");
}
#[test]
fn command_history_is_scoped_to_the_worktree() {
let mut ctx = setup_clone!();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(": HEAD<enter>"));
drop(app);
run(
&ctx.dir,
&["git", "worktree", "add", "-b", "other", "../other-worktree"],
);
let other = ctx.dir.parent().unwrap().join("other-worktree");
let mut app = ctx.init_app_as_pager_of_at(other, &["git", "diff"]);
ctx.update(&mut app, keys(":<up>"));
assert_eq!(ctx.prompt_line(), "git diff|");
}
#[test]
fn an_edited_command_is_not_shown_in_the_view() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("kd"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("beta"), "{buffer}");
assert!(!buffer.contains("git diff"), "{buffer}");
}
#[test]
fn opening_a_folded_file_shows_its_diff() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<backtab><tab>"));
assert!(
ctx.redact_buffer().contains("changed"),
"the file opened onto something still folded:\n{}",
ctx.redact_buffer()
);
}
#[test]
fn a_patch_with_no_command_stays_as_it_arrived() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
let patch = run(&ctx.dir, &["git", "show", "HEAD"]);
let mut app = ctx.init_app_with_patch(patch);
ctx.update(&mut app, keys("g"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("add firstfile"), "{buffer}");
assert!(buffer.contains("+testing"), "{buffer}");
}
#[test]
fn a_piped_log_with_commit_records_is_navigable() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
let oid = run(&ctx.dir, &["git", "rev-parse", "HEAD"])
.trim()
.to_string();
let app = ctx.init_app_with_patch(format!(
"\x1b]1717;1\x1b\\\x1b]1717;1;C;;;{oid}\x1b\\commit {oid}\n\
Author: Author Name <author@email.com>\n\
\n\
\x20 add firstfile\n"
));
let item = app.state.screens.last().unwrap().get_selected_item();
assert!(
matches!(&item.data, crate::item_data::ItemData::Commit { oid: o, .. } if *o == oid),
"got {:?}",
item.data
);
assert!(Op::Show.implementation().get_action(&item.data).is_some());
assert!(
Op::CopyHash
.implementation()
.get_action(&item.data)
.is_some()
);
}
#[test]
fn a_log_naming_its_commits_by_abbreviation_is_navigable() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
let short = run(&ctx.dir, &["git", "rev-parse", "--short", "HEAD"])
.trim()
.to_string();
let oid = run(&ctx.dir, &["git", "rev-parse", "HEAD"])
.trim()
.to_string();
let app = ctx.init_app_with_patch(format!(
"\x1b]1717;1;C;;;{short}\x1b\\â–¸ {short} Author Name\n\
\n\
\x20 add firstfile\n"
));
let item = app.state.screens.last().unwrap().get_selected_item();
assert!(
matches!(&item.data, crate::item_data::ItemData::Commit { oid: o, .. } if *o == oid),
"got {:?}",
item.data
);
}
#[test]
fn a_key_press_on_an_empty_view_does_nothing() {
let mut ctx = setup_clone!();
let mut app = ctx.init_app_with_patch(String::new());
ctx.update(&mut app, keys("j"));
}
#[test]
fn a_key_press_after_staging_everything_does_nothing() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
fs::write(ctx.dir.join("firstfile"), "changed\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("jsj"));
}
#[test]
fn output_with_no_structure_is_still_rendered() {
let mut ctx = setup_clone!();
ctx.config().general.diff_renderer.command =
["sed", "s/^/rendered /"].map(String::from).to_vec();
ctx.init_app_with_patch("plain output\n".to_string());
let buffer = ctx.redact_buffer();
assert!(buffer.contains("rendered plain output"), "{buffer}");
}
#[test]
fn rendered_text_can_be_searched() {
let mut ctx = setup_clone!();
let mut app = ctx.init_app_with_patch("start\nmatch one\nmiddle\nmatch two\n".to_owned());
ctx.update(&mut app, keys("/match<enter>"));
assert_eq!(selected_rendered_row(&app), "match one");
ctx.update(&mut app, keys("n"));
assert_eq!(selected_rendered_row(&app), "match two");
ctx.update(&mut app, keys("n"));
assert_eq!(selected_rendered_row(&app), "match one");
ctx.update(&mut app, keys("N"));
assert_eq!(selected_rendered_row(&app), "match two");
}
#[test]
fn search_is_a_regex_over_rendered_text() {
let mut ctx = setup_clone!();
let mut app = ctx
.init_app_with_patch("\x1b[31mfirst needle\x1b[0m\nsecond needle\nnot this\n".to_owned());
ctx.update(&mut app, keys(r"/^second .*le$<enter>"));
assert_eq!(selected_rendered_row(&app), "second needle");
}
#[test]
fn search_reveals_a_match_inside_a_fold() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "before\n");
fs::write(ctx.dir.join("firstfile"), "changed\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<backtab>/changed<enter>"));
assert!(
matches!(
app.screen().get_selected_item().data,
crate::item_data::ItemData::HunkLine { .. }
),
"search did not land on the changed line"
);
assert!(ctx.redact_buffer().contains("+changed"));
}
fn selected_rendered_row(app: &App) -> String {
app.screen()
.get_selected_item()
.rendered
.as_ref()
.map(|row| {
row.iter()
.map(|(text, _)| crate::ui::display_text(text))
.collect()
})
.unwrap_or_default()
}
#[test]
fn a_raw_log_becomes_a_log_view_when_the_renderer_marks_commits() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
let oid = run(&ctx.dir, &["git", "rev-parse", "HEAD"])
.trim()
.to_string();
ctx.config().general.diff_renderer.command = [
"awk",
r#"/^commit /{printf "\033]1717;1;C;;;%s\033\\", $2} {print}"#,
]
.map(String::from)
.to_vec();
let app = ctx.init_app_with_patch(run(&ctx.dir, &["git", "log"]));
let item = app.state.screens.last().unwrap().get_selected_item();
assert!(
matches!(&item.data, crate::item_data::ItemData::Commit { oid: o, .. } if *o == oid),
"got {:?}",
item.data
);
}
#[test]
fn paging_past_a_commit_taller_than_the_screen_reaches_the_end() {
let mut ctx = setup_clone!();
let body = (1..=30).fold(String::new(), |mut acc, i| {
use std::fmt::Write as _;
writeln!(acc, "body line {i}").unwrap();
acc
});
fs::write(ctx.dir.join("tallfile"), "testing\n").unwrap();
run(&ctx.dir, &["git", "add", "tallfile"]);
run(
&ctx.dir,
&["git", "commit", "-m", &format!("a tall commit\n\n{body}")],
);
ctx.config().general.diff_renderer.command = [
"awk",
r#"/^commit /{printf "\033]1717;1;C;;;%s\033\\", $2} {print}"#,
]
.map(String::from)
.to_vec();
let mut app = ctx.init_app_with_patch(run(&ctx.dir, &["git", "log"]));
ctx.update(&mut app, keys(&"<space>".repeat(10)));
let buffer = ctx.redact_buffer();
assert!(
buffer.contains("add initial-file"),
"paging stalled before the end:\n{buffer}"
);
}
#[test]
fn context_lines_are_skipped_when_configured() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "one\ntwo\nthree\n");
fs::write(ctx.dir.join("firstfile"), "one\nCHANGED\nthree\n").unwrap();
ctx.config().general.visit_context_lines = false;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("j"));
assert_eq!(selected_hunk_line(&app), Some(1));
}
#[test]
fn context_lines_are_visited_by_default() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "one\ntwo\nthree\n");
fs::write(ctx.dir.join("firstfile"), "one\nCHANGED\nthree\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
for line_i in 0..4 {
ctx.update(&mut app, keys("j"));
assert_eq!(selected_hunk_line(&app), Some(line_i));
}
}
#[test]
fn rendered_context_lines_are_visited_when_configured() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "one\ntwo\nthree\n");
fs::write(ctx.dir.join("firstfile"), "one\nCHANGED\nthree\n").unwrap();
ctx.config().general.visit_context_lines = true;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
for line_i in 0..4 {
ctx.update(&mut app, keys("j"));
assert_eq!(selected_hunk_line(&app), Some(line_i));
}
}
pub(super) fn annotating_renderer() -> Vec<String> {
[
"awk",
r#"
BEGIN{bs=sprintf("%c",92);osc="\033]1717;1";st="\033" bs;handshake=osc st}
{raw=$0;gsub(/\033\[[0-9;]*m/,"");printf "%s",handshake;handshake=""}
!/^[-+ ]/&&substr($0,1,1)!=bs{hunk=0}
header&&/^(@@|diff |(commit|â–¸) [0-9a-f]+)/{file_header()}
/^(commit|â–¸) [0-9a-f]+/{printf "%s;C;;;%s%s%s\n",osc,$2,st,raw;next}
/^diff --git /{file=$0;sub(/^diff --git a\/.* b\//,"",file);label=file;suffix="";header=1;next}
/^diff --cc /{file=substr($0,11);label=file;suffix="";header=1;next}
/^new file mode /{label="added: " file}
/^deleted file mode /{label="removed: " file}
/^rename from /{from=substr($0,13)}
/^rename to /{label="renamed: " from " ⟶ " file}
/^copy from /{from=substr($0,11)}
/^copy to /{label="copied: " from " ⟶ " file}
/^old mode /{oldmode=$3}
/^new mode /{suffix=oldmode=="100644"&&$3=="100755"?" (mode +x)":oldmode=="100755"&&$3=="100644"?" (mode -x)":" (mode " oldmode " ⟶ " $3 ")"}
/^Binary files /{suffix=" (binary file)"}
header{next}
/^@@ /{o=$2;n=$3;sub(/^-/,"",o);sub(/^\+/,"",n);split(o,a,",");split(n,b,",");old=a[1];new=b[1];hunk=1;printf "%s;h;%d;;%s%s%s\n",osc,new,file,st,raw;next}
hunk&&/^\+/{printf "%s;a;%d;;%s%s%s\n",osc,new++,file,st,$0;next}
hunk&&/^-/{printf "%s;d;%d;%d;%s%s%s\n",osc,new,old++,file,st,$0;next}
hunk&&/^ /{printf "%s;c;%d;;%s%s%s\n",osc,new++,file,st,$0;old++;next}
{print raw}
END{if(header)file_header();printf "%s",handshake}
function file_header(){printf "\n%s;f;;;%s%s%s%s\n",osc,file,st,label,suffix;header=0}
"#,
]
.map(String::from)
.to_vec()
}
fn selected_hunk_line(app: &App) -> Option<usize> {
match app.screen().get_selected_item().data {
crate::item_data::ItemData::HunkLine { line_i, .. } => Some(line_i),
_ => None,
}
}
fn offers(app: &App, op: Op) -> bool {
let item = app.state.screens.last().unwrap().get_selected_item();
assert!(
matches!(item.data, crate::item_data::ItemData::Hunk { .. }),
"expected a hunk to be selected, got {:?}",
item.data
);
op.implementation().get_action(&item.data).is_some()
}
#[test]
fn a_shorter_rendering_still_fills_the_screen() {
let mut ctx = setup_clone!();
ctx.config().general.diff_renderer.features = vec!["side-by-side".into()];
ctx.config().general.diff_renderer.command = fusing_renderer();
let patch = (1..=100).map(|i| format!("line {i}\n")).collect::<String>();
let mut app = ctx.init_app_with_patch(patch);
ctx.update(&mut app, keys("G"));
assert!(
blank_rows_at_the_bottom(&ctx.redact_buffer()) <= 2,
"before the re-render"
);
ctx.update(&mut app, keys("<-><enter>"));
let buffer = ctx.redact_buffer();
assert!(blank_rows_at_the_bottom(&buffer) <= 2, "{buffer}");
}
#[test]
fn a_rerender_keeps_the_cursor_on_its_screen_row() {
let mut ctx = setup_clone!();
let lines = |prefix: &str| {
(1..=60)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
commit(&ctx.dir, "a.rs", &lines("old"));
fs::write(ctx.dir.join("a.rs"), lines("new")).unwrap();
let renderer = &mut ctx.config().general.diff_renderer;
renderer.features = vec!["line-numbers".into()];
renderer.feature_keys.insert("line-numbers".into(), 'L');
renderer.command = pausing_renderer();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
finish_drawing(&mut ctx, &mut app);
ctx.update(
&mut app,
keys(&format!(
"{}{}",
"<ctrl+j>".repeat(90),
"<ctrl+k>".repeat(15)
)),
);
let before = (selected_hunk_line(&app), cursor_screen_row(&ctx));
ctx.update(&mut app, keys("L"));
assert_eq!(
(selected_hunk_line(&app), cursor_screen_row(&ctx)),
before,
"{}",
ctx.redact_buffer()
);
finish_drawing(&mut ctx, &mut app);
ctx.update(&mut app, vec![]);
assert_eq!(
(selected_hunk_line(&app), cursor_screen_row(&ctx)),
before,
"{}",
ctx.redact_buffer()
);
}
fn cursor_screen_row(ctx: &TestContext) -> Option<usize> {
ctx.redact_buffer()
.lines()
.position(|row| row.starts_with('\u{258c}'))
}
fn fusing_renderer() -> Vec<String> {
[
"sh",
"-c",
r#"if [ -n "$DELTA_FEATURES" ]; then awk 'NR%2==1'; else cat; fi"#,
]
.map(String::from)
.to_vec()
}
fn one_hunk_patch(file: &str) -> String {
format!(
"diff --git a/{file} b/{file}\n\
index 1111111..2222222 100644\n\
--- a/{file}\n\
+++ b/{file}\n\
@@ -1 +1 @@\n\
-{file} was\n\
+{file} is\n"
)
}
#[test]
fn input_past_the_limit_is_cut_back_to_whole_file_diffs() {
let mut ctx = setup_clone!();
let first = one_hunk_patch("a.rs");
let patch = format!("{first}{}", one_hunk_patch("b.rs"));
ctx.config().general.max_input_bytes = (first.len() + 60) as u64;
let mut app = ctx.init_app_with_patch(patch);
ctx.update(&mut app, keys("j"));
let buffer = ctx.redact_buffer();
assert!(!buffer.contains("b.rs"), "{buffer}");
assert!(
buffer.find("a.rs is") < buffer.find("input truncated at"),
"{buffer}"
);
}
#[test]
fn a_hunk_the_limit_cut_short_is_dropped() {
let mut ctx = setup_clone!();
let patch = "diff --git a/f.rs b/f.rs\n\
index 1111111..2222222 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1 +1 @@\n\
-first was\n\
+first is\n\
@@ -9 +9 @@\n\
-second was\n\
+second is\n";
ctx.config().general.max_input_bytes = (patch.find("+second").unwrap() + 4) as u64;
let mut app = ctx.init_app_with_patch(patch.to_owned());
ctx.update(&mut app, keys("j"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("first is"), "{buffer}");
assert!(!buffer.contains("second"), "{buffer}");
}
#[test]
fn a_coloured_patch_is_cut_back_at_the_same_place() {
let mut ctx = setup_clone!();
let first = one_hunk_patch("a.rs");
let coloured = |patch: String| {
patch
.lines()
.map(|line| format!("\x1b[1m{line}\x1b[m\n"))
.collect::<String>()
};
let patch = coloured(format!("{first}{}", one_hunk_patch("b.rs")));
ctx.config().general.max_input_bytes = (coloured(first).len() + 60) as u64;
let _app = ctx.init_app_with_patch(patch);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("a.rs is"), "{buffer}");
assert!(!buffer.contains("b.rs"), "{buffer}");
}
#[test]
fn a_command_that_says_more_than_the_limit_is_cut_back_too() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let coloured = run(&ctx.dir, &["git", "-c", "color.ui=always", "diff"]);
let limit = coloured.find("a/a_test.rs").unwrap() + 20;
ctx.config().general.max_input_bytes = limit as u64;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>"));
let buffer = ctx.redact_buffer();
assert!(
buffer.contains(&format!("input truncated at {limit} bytes")),
"{buffer}"
);
assert!(buffer.contains("alpha"), "{buffer}");
assert!(!buffer.contains("beta"), "{buffer}");
assert!(offers(&app, Op::Stage), "what is left is still a patch");
}
fn blank_rows_at_the_bottom(buffer: &str) -> usize {
buffer
.lines()
.filter(|row| row.ends_with('|'))
.rev()
.take_while(|row| row.trim_end_matches(['|', ' ']).is_empty())
.count()
}
#[test]
fn a_patch_that_fits_on_one_screen_is_printed() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.term.clear().unwrap();
ctx.term.queue_move_cursor(0, 0).unwrap();
assert!(app.print_if_one_screen(&mut ctx.term).unwrap());
insta::assert_snapshot!(ctx.redact_buffer());
}
#[test]
fn a_patch_longer_than_one_screen_is_not_printed() {
let mut ctx = setup_clone!();
let lines = |prefix: &str| {
(1..=20)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
commit(&ctx.dir, "firstfile", &lines("old"));
fs::write(ctx.dir.join("firstfile"), lines("new")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.term.clear().unwrap();
ctx.term.queue_move_cursor(0, 0).unwrap();
assert!(!app.print_if_one_screen(&mut ctx.term).unwrap());
assert!(ctx.physical_screen().trim().is_empty());
}
#[test]
fn the_help_menu_heads_its_columns_by_what_is_in_them() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = hunk_header_renderer();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("?"));
assert_eq!(menu_headings(&ctx), ["Commands", "Submenus", "Hunk"]);
ctx.update(&mut app, keys("<esc>j?"));
assert_eq!(menu_headings(&ctx), ["Commands", "Submenus", "Line"]);
}
fn menu_headings(ctx: &TestContext) -> Vec<String> {
let buffer = ctx.redact_buffer();
let mut rows = buffer.lines().skip_while(|row| !row.starts_with('─'));
let headings = rows.nth(1).unwrap_or_default();
headings
.trim_end_matches('|')
.split_whitespace()
.map(String::from)
.collect()
}
fn hunk_header_renderer() -> Vec<String> {
annotating_renderer()
.into_iter()
.map(|arg| {
arg.replace(
r#"%s;h;%d;;%s%s%s\n",osc,new,file,st,raw;next}"#,
r#"%s;h;%d;;%srendered hunk %d\n",osc,new,file,st,new;next}"#,
)
})
.collect()
}
#[test]
fn the_renderers_file_header_folds_and_stages_its_file() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("~ firstfile ~"), "{buffer}");
assert!(buffer.contains("==========="), "{buffer}");
assert!(!buffer.contains("modified firstfile"), "{buffer}");
ctx.update(&mut app, keys("k<tab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("~ firstfile ~"), "{buffer}");
assert!(!buffer.contains("==========="), "{buffer}");
assert!(!buffer.contains("changed"), "{buffer}");
ctx.update(&mut app, keys("s"));
assert!(run(&ctx.dir, &["git", "diff", "--cached"]).contains("+changed"));
}
#[test]
fn a_patch_asked_for_with_its_stat_has_one_stat_row_per_file() {
for file_header in [
crate::config::FileHeader::Delta,
crate::config::FileHeader::Stat,
] {
for argv in [
["git", "diff", "-p", "--stat"],
["git", "diff", "--patch-with-stat", "-U3"],
] {
let mut ctx = setup_clone!();
ctx.config().general.file_header = file_header;
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
ctx.init_app_as_pager_of(&argv);
let buffer = ctx.redact_buffer();
assert_eq!(buffer.matches("firstfile | 2 +-").count(), 1, "{buffer}");
assert!(!buffer.contains("~ firstfile ~"), "{buffer}");
assert!(buffer.contains("changed"), "{buffer}");
}
}
}
#[test]
fn a_stat_file_unfolds_under_its_stat_row_not_the_renderers_header() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
let mut app = ctx.init_app_as_pager_of(&["git", "diff", "--stat"]);
ctx.update(&mut app, keys("<tab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("firstfile | 2 +-"), "{buffer}");
assert!(buffer.contains("changed"), "{buffer}");
assert!(!buffer.contains("~ firstfile ~"), "{buffer}");
assert!(!buffer.contains("==========="), "{buffer}");
}
fn file_header_renderer() -> Vec<String> {
annotating_renderer()
.into_iter()
.map(|arg| {
arg.replace(
r#"function file_header(){printf "\n%s;f;;;%s%s%s%s\n",osc,file,st,label,suffix;header=0}"#,
r#"function file_header(){printf "%s;f;;;%s%s~ %s ~\n%s;f;;;%s%s===========\n",osc,file,st,file,osc,file,st;header=0}"#,
)
})
.collect()
}
struct Watched<'a> {
text: &'a [u8],
read: &'a std::cell::Cell<bool>,
}
impl std::io::Read for Watched<'_> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.read.set(true);
self.text.read(buf)
}
}
#[test]
fn what_git_piped_is_not_read_when_its_command_was_found() {
let read = std::cell::Cell::new(false);
let input = Watched {
text: b"diff --git a/f b/f\n",
read: &read,
};
crate::paged(input, Some(vec!["git".into(), "diff".into()]), None, 1_000).unwrap();
assert!(!read.get());
}
#[test]
fn what_git_piped_is_read_when_its_command_was_not_found() {
let read = std::cell::Cell::new(false);
let input = Watched {
text: b"diff --git a/f b/f\n",
read: &read,
};
let paged = crate::paged(input, None, None, 1_000).unwrap();
assert_eq!(paged.text, "diff --git a/f b/f\n");
}
#[test]
fn the_first_screen_is_drawn_before_the_renderer_finishes() {
let mut ctx = setup_clone!();
let lines = |prefix: &str, n: usize| {
(1..=n)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
commit(&ctx.dir, "a.rs", &lines("old a", 60));
commit(&ctx.dir, "b.rs", "old b\n");
fs::write(ctx.dir.join("a.rs"), lines("new a", 60)).unwrap();
fs::write(ctx.dir.join("b.rs"), "new b\n").unwrap();
ctx.config().general.diff_renderer.command = pausing_renderer();
let started = Instant::now();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
assert!(
started.elapsed() < Duration::from_secs(2),
"{:?}",
started.elapsed()
);
assert!(draws_lines_of(&app, "a.rs"));
assert!(
!draws_lines_of(&app, "b.rs"),
"drawn before the renderer reached it"
);
while !draws_lines_of(&app, "b.rs") {
assert!(
started.elapsed() < Duration::from_secs(10),
"the rest never arrived"
);
std::thread::sleep(Duration::from_millis(50));
app.update(&mut ctx.term, None).unwrap();
}
}
#[test]
fn a_renderer_slow_to_start_does_not_hold_up_the_view() {
let mut ctx = setup_clone!();
let lines = |prefix: &str, n: usize| {
(1..=n)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
commit(&ctx.dir, "a.rs", &lines("old a", 60));
commit(&ctx.dir, "b.rs", "old b\n");
fs::write(ctx.dir.join("a.rs"), lines("new a", 60)).unwrap();
fs::write(ctx.dir.join("b.rs"), "new b\n").unwrap();
ctx.config().general.diff_renderer.command = slow_to_start(pausing_renderer());
let started = Instant::now();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
assert!(
started.elapsed() < Duration::from_millis(1500),
"{:?}",
started.elapsed()
);
assert!(!draws_lines_of(&app, "a.rs"));
while !draws_lines_of(&app, "a.rs") {
assert!(
started.elapsed() < Duration::from_secs(10),
"the first screen never arrived"
);
std::thread::sleep(Duration::from_millis(50));
app.update(&mut ctx.term, None).unwrap();
}
assert!(
!draws_lines_of(&app, "b.rs"),
"waited for the rest before showing the first screen"
);
}
#[test]
fn a_patch_that_fits_is_printed_however_slow_the_renderer() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = slow_to_start(annotating_renderer());
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
ctx.term.clear().unwrap();
ctx.term.queue_move_cursor(0, 0).unwrap();
assert!(app.print_if_one_screen(&mut ctx.term).unwrap());
assert!(ctx.physical_screen().contains("+changed"));
}
fn slow_to_start(renderer: Vec<String>) -> Vec<String> {
renderer
.into_iter()
.map(|arg| arg.replacen("BEGIN{", "BEGIN{system(\"sleep 2\")}\nBEGIN{", 1))
.collect()
}
pub(super) fn pausing_renderer() -> Vec<String> {
annotating_renderer()
.into_iter()
.map(|arg| arg.replace("BEGIN{", "NR==80{fflush(); system(\"sleep 3\")}\nBEGIN{"))
.collect()
}
pub(super) fn draws_lines_of(app: &App, file: &str) -> bool {
app.screen().items().iter().any(|item| {
matches!(&item.data, crate::item_data::ItemData::HunkLine { diff, file_i, .. }
if diff.file_diffs[*file_i].header.new_file.fmt(&diff.text) == file)
})
}
fn colour_of(ctx: &TestContext, text: &str) -> Option<crate::style::Color> {
let crate::term::TermBackend::Test { buffer, .. } = &ctx.term else {
unreachable!();
};
let screen = ctx.physical_screen();
let row = screen.lines().position(|row| row.contains(text))?;
let column = screen.lines().nth(row)?.find(text)?;
let column = screen.lines().nth(row)?[..column].chars().count();
Some(buffer.cells[row * buffer.width as usize + column].fg)
}
#[test]
fn the_colours_a_commits_format_asks_for_are_kept() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
let mut app = ctx.init_app_as_pager_of(&["git", "show", "--format=%C(blue)%an%C(reset)"]);
assert_eq!(
colour_of(&ctx, "Author Name"),
Some(crate::style::Color::Indexed(4)),
"{}",
ctx.redact_buffer()
);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(matches!(
app.screen().get_selected_item().data,
crate::item_data::ItemData::HunkLine { .. } | crate::item_data::ItemData::Hunk { .. }
));
}
#[test]
fn a_piped_patch_keeps_the_colours_of_its_commit() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\n");
let patch = run(
&ctx.dir,
&[
"git",
"show",
"--color=always",
"--format=%C(blue)%an%C(reset)",
],
);
ctx.init_app_with_patch(patch);
assert_eq!(
colour_of(&ctx, "Author Name"),
Some(crate::style::Color::Indexed(4)),
"{}",
ctx.redact_buffer()
);
}
#[test]
fn a_rerender_keeps_the_cursor_on_a_line_drawn_late() {
let mut ctx = setup_clone!();
let lines = |prefix: &str, n: usize| {
(1..=n)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
commit(&ctx.dir, "a.rs", &lines("old a", 60));
commit(&ctx.dir, "b.rs", "old b\n");
fs::write(ctx.dir.join("a.rs"), lines("new a", 60)).unwrap();
fs::write(ctx.dir.join("b.rs"), "new b\n").unwrap();
ctx.config().general.diff_renderer.command = pausing_renderer();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
let started = Instant::now();
while app.screen().is_drawing() {
assert!(
started.elapsed() < Duration::from_secs(20),
"never finished"
);
std::thread::sleep(Duration::from_millis(50));
app.update(&mut ctx.term, None).unwrap();
}
ctx.update(&mut app, keys("/new b<enter>"));
assert_eq!(selected_line_of(&app), Some(("b.rs".into(), 1)));
ctx.update(&mut app, keys("<-><enter>"));
assert_eq!(selected_line_of(&app), Some(("b.rs".into(), 1)));
}
fn selected_line_of(app: &App) -> Option<(String, usize)> {
match &app.screen().get_selected_item().data {
crate::item_data::ItemData::HunkLine {
diff,
file_i,
line_i,
..
} => Some((
diff.file_diffs[*file_i]
.header
.new_file
.fmt(&diff.text)
.to_string(),
*line_i,
)),
_ => None,
}
}
#[test]
fn hiding_a_file_leaves_the_cursor_on_the_next_file() {
for (args, to_second_file) in [
(&["git", "diff"][..], "k<alt+j>"),
(&["git", "diff", "--stat"], "j"),
] {
let mut ctx = setup_clone!();
three_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(args);
ctx.update(&mut app, keys(to_second_file));
assert_eq!(selected_file(&app).as_deref(), Some("b.rs"), "{args:?}");
ctx.update(&mut app, keys("d"));
assert_eq!(
selected_file(&app).as_deref(),
Some("c.rs"),
"{args:?}\n{}",
ctx.redact_buffer()
);
}
}
fn three_changed_files(ctx: &TestContext) {
for file in ["a.rs", "b.rs", "c.rs"] {
commit(&ctx.dir, file, "before\n");
fs::write(ctx.dir.join(file), "after\n").unwrap();
}
}
fn selected_file(app: &App) -> Option<String> {
match &app.screen().get_selected_item().data {
crate::item_data::ItemData::Delta { diff, file_i, .. } => Some(
diff.file_diffs[*file_i]
.header
.new_file
.fmt(&diff.text)
.into_owned(),
),
_ => None,
}
}
#[test]
fn hiding_a_file_drawn_late_leaves_the_cursor_on_the_next_file() {
let mut ctx = setup_clone!();
let lines = |prefix: &str| {
(1..=60)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
for file in [
"a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs",
] {
commit(&ctx.dir, file, &lines("old"));
fs::write(ctx.dir.join(file), lines("new")).unwrap();
}
ctx.config().general.diff_renderer.command = pausing_renderer();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff", "--stat"]);
finish_drawing(&mut ctx, &mut app);
ctx.update(&mut app, keys("jjjj"));
assert_eq!(selected_file(&app).as_deref(), Some("e.rs"));
ctx.update(&mut app, keys("d"));
finish_drawing(&mut ctx, &mut app);
assert_eq!(selected_file(&app).as_deref(), Some("f.rs"));
}
#[test]
fn hiding_the_last_file_leaves_the_cursor_on_the_file_above() {
let mut ctx = setup_clone!();
let lines = |prefix: &str| {
(1..=60)
.map(|i| format!("{prefix} {i}\n"))
.collect::<String>()
};
for file in [
"a.rs", "b.rs", "c.rs", "d.rs", "e.rs", "f.rs", "g.rs", "h.rs",
] {
commit(&ctx.dir, file, &lines("old"));
fs::write(ctx.dir.join(file), lines("new")).unwrap();
}
ctx.config().general.diff_renderer.command = pausing_renderer();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff", "--stat"]);
finish_drawing(&mut ctx, &mut app);
ctx.update(&mut app, keys("jjjjjjj"));
assert_eq!(selected_file(&app).as_deref(), Some("h.rs"));
ctx.update(&mut app, keys("d"));
finish_drawing(&mut ctx, &mut app);
assert_eq!(selected_file(&app).as_deref(), Some("g.rs"));
}
fn finish_drawing(ctx: &mut TestContext, app: &mut App) {
let started = Instant::now();
while app.screen().is_drawing() {
assert!(
started.elapsed() < Duration::from_secs(20),
"never finished"
);
std::thread::sleep(Duration::from_millis(50));
app.update(&mut ctx.term, None).unwrap();
}
}
fn a_replaced_line_and_three_added(ctx: &TestContext) {
commit(&ctx.dir, "a.rs", "keep\nold\n");
commit(&ctx.dir, "longer_b.rs", "keep\n");
fs::write(ctx.dir.join("a.rs"), "keep\nnew\n").unwrap();
fs::write(ctx.dir.join("longer_b.rs"), "keep\n1\n2\n3\n").unwrap();
}
fn folded_to_stat_rows(ctx: &mut TestContext) -> String {
ctx.config().general.file_header = crate::config::FileHeader::Stat;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<backtab>"));
ctx.redact_buffer()
}
#[test]
fn a_folded_file_shows_its_stat_row() {
let mut ctx = setup_clone!();
a_replaced_line_and_three_added(&ctx);
let buffer = folded_to_stat_rows(&mut ctx);
assert!(buffer.contains("a.rs | 2 +-"), "{buffer}");
assert!(buffer.contains("longer_b.rs | 3 +++"), "{buffer}");
}
#[test]
fn a_folded_file_shows_its_stat_row_whatever_drew_it() {
let mut ctx = setup_clone!();
a_replaced_line_and_three_added(&ctx);
let buffer = folded_to_stat_rows(&mut ctx);
assert!(buffer.contains("a.rs | 2 +-"), "{buffer}");
assert!(buffer.contains("longer_b.rs | 3 +++"), "{buffer}");
}
#[test]
fn an_unfolded_file_is_headed_by_its_stat_row() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
ctx.config().general.file_header = crate::config::FileHeader::Stat;
ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("firstfile | 2 +-"), "{buffer}");
assert!(buffer.contains("changed"), "{buffer}");
assert!(!buffer.contains("~ firstfile ~"), "{buffer}");
assert!(!buffer.contains("==========="), "{buffer}");
}
#[test]
fn stat_rows_are_laid_out_as_git_lays_them_out() {
let mut ctx = setup_clone!();
let long = "a/very/long/directory/path/that/keeps/going/and/going/file_with_a_long_name.rs";
fs::create_dir_all(ctx.dir.join(long).parent().unwrap()).unwrap();
commit(&ctx.dir, "big.rs", "");
commit(&ctx.dir, "small.rs", "");
commit(&ctx.dir, long, "");
let lines = |n: usize| (1..=n).map(|i| format!("{i}\n")).collect::<String>();
fs::write(ctx.dir.join("big.rs"), lines(100)).unwrap();
fs::write(ctx.dir.join("small.rs"), lines(1)).unwrap();
fs::write(ctx.dir.join(long), lines(7)).unwrap();
let buffer = folded_to_stat_rows(&mut ctx);
let git = run(&ctx.dir, &["sh", "-c", "COLUMNS=78 git diff --stat"]);
for line in git.lines() {
assert!(buffer.contains(line), "{line:?} not in\n{buffer}");
}
}
#[test]
fn a_deleted_files_stat_row_shows_its_path() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "a.rs", "alpha\n");
fs::remove_file(ctx.dir.join("a.rs")).unwrap();
let buffer = folded_to_stat_rows(&mut ctx);
assert!(buffer.contains("a.rs | 1 -"), "{buffer}");
assert!(!buffer.contains("/dev/null"), "{buffer}");
}
#[test]
fn a_folded_file_shows_the_renderers_header_by_default() {
let mut ctx = setup_clone!();
a_replaced_line_and_three_added(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<backtab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("a.rs"), "{buffer}");
assert!(!buffer.contains(" | "), "{buffer}");
}
#[test]
fn files_start_folded_when_asked() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
taller_than_the_screen(&ctx);
ctx.config().general.start_folded = true;
ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("a.rs"), "{buffer}");
assert!(buffer.contains("a_test.rs"), "{buffer}");
assert!(!buffer.contains("alpha changed"), "{buffer}");
}
#[test]
fn files_starting_folded_fill_the_first_screen() {
let mut ctx = setup_clone!();
let lines = |suffix: &str| {
(1..=20)
.map(|i| format!("{i}{suffix}\n"))
.collect::<String>()
};
for i in 1..=45 {
commit(&ctx.dir, &format!("f{i:02}.rs"), &lines(""));
fs::write(ctx.dir.join(format!("f{i:02}.rs")), lines(" changed")).unwrap();
}
ctx.config().general.start_folded = true;
ctx.config().general.diff_renderer.command = annotating_renderer()
.into_iter()
.map(|arg| {
arg.replace(
"BEGIN{",
r#"/diff --git /{files++; if (files == 3) {fflush(); system("sleep 0.05")} if (files == 45) {fflush(); system("sleep 2")}}
BEGIN{"#,
)
})
.collect();
ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
let files = buffer.lines().filter(|line| line.contains(".rs")).count();
assert!(files >= 15, "{buffer}");
}
#[test]
fn a_diffstat_is_drawn_before_the_renderer_reaches_its_files() {
let mut ctx = setup_clone!();
changed_files_and_a_renderer_stopping_after_two(&mut ctx, 12);
let started = std::time::Instant::now();
ctx.init_app_streaming_as_pager_of(&["git", "diff", "--stat"]);
assert!(
started.elapsed() < std::time::Duration::from_secs(2),
"{:?}",
started.elapsed()
);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("f12.rs"), "{buffer}");
}
pub(super) fn changed_files_and_a_renderer_stopping_after_two(ctx: &mut TestContext, count: usize) {
for i in 1..=count {
commit(&ctx.dir, &format!("f{i:02}.rs"), "old\n");
fs::write(ctx.dir.join(format!("f{i:02}.rs")), "new\n").unwrap();
}
ctx.config().general.diff_renderer.command = annotating_renderer()
.into_iter()
.map(|arg| {
arg.replace(
"BEGIN{",
r#"/diff --git /{files++; if (files == 3) {fflush(); system("sleep 3")}}
BEGIN{"#,
)
})
.collect();
}
#[test]
fn a_commit_opened_from_the_log_starts_folded_when_asked() {
let mut ctx = setup_clone!();
let tall = (1..=30).map(|i| format!("{i}\n")).collect::<String>();
fs::write(ctx.dir.join("secondfile"), tall).unwrap();
run(&ctx.dir, &["git", "add", "secondfile"]);
commit(&ctx.dir, "firstfile", "This should be folded\n");
ctx.config().general.start_folded = true;
let mut app = ctx.init_app();
ctx.update(&mut app, keys("ll<enter>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("firstfile"), "{buffer}");
assert!(!buffer.contains("This should be folded"), "{buffer}");
}
#[test]
fn files_start_folded_to_their_stat_rows() {
let mut ctx = setup_clone!();
a_replaced_line_and_three_added(&ctx);
taller_than_the_screen(&ctx);
ctx.config().general.start_folded = true;
ctx.config().general.file_header = crate::config::FileHeader::Stat;
ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("a.rs | 2 +-"), "{buffer}");
assert!(buffer.contains("longer_b.rs | 3 +++"), "{buffer}");
}
#[test]
fn unfolding_everything_heads_the_files_with_the_renderers_headers() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
ctx.config().general.file_header = crate::config::FileHeader::Stat;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<backtab><backtab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("~ firstfile ~"), "{buffer}");
assert!(buffer.contains("changed"), "{buffer}");
assert!(!buffer.contains("firstfile | 2 +-"), "{buffer}");
ctx.update(&mut app, keys("<backtab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("firstfile | 2 +-"), "{buffer}");
assert!(!buffer.contains("~ firstfile ~"), "{buffer}");
}
#[test]
fn unfolding_everything_from_a_diffstat_heads_the_files_with_the_renderers_headers() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "testing\ntesttest\n");
fs::write(ctx.dir.join("firstfile"), "changed\ntesttest\n").unwrap();
ctx.config().general.diff_renderer.command = file_header_renderer();
let mut app = ctx.init_app_as_pager_of(&["git", "diff", "--stat"]);
ctx.update(&mut app, keys("<backtab>"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("~ firstfile ~"), "{buffer}");
assert!(buffer.contains("changed"), "{buffer}");
assert!(!buffer.contains("firstfile | 2 +-"), "{buffer}");
}
#[test]
fn an_unfolded_file_stays_unfolded_through_a_slow_rerender() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
taller_than_the_screen(&ctx);
ctx.config().general.start_folded = true;
let renderer = &mut ctx.config().general.diff_renderer;
renderer.features = vec!["line-numbers".into()];
renderer.command = annotating_renderer()
.into_iter()
.map(|arg| arg.replace("BEGIN{", "BEGIN{system(\"sleep 1\");"))
.collect();
let mut app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
finish_drawing(&mut ctx, &mut app);
ctx.update(&mut app, keys("gg<tab>"));
assert!(ctx.redact_buffer().contains("alpha changed"));
ctx.update(&mut app, keys("<-><enter>"));
finish_drawing(&mut ctx, &mut app);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("alpha changed"), "{buffer}");
assert!(!buffer.contains("beta changed"), "{buffer}");
}
#[test]
fn a_file_new_to_a_rebuild_starts_folded() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "a.rs", "alpha\n");
commit(&ctx.dir, "b.rs", "beta\n");
fs::write(ctx.dir.join("a.rs"), "alpha changed\n").unwrap();
taller_than_the_screen(&ctx);
ctx.config().general.start_folded = true;
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
fs::write(ctx.dir.join("b.rs"), "beta changed\n").unwrap();
ctx.update(&mut app, keys("gr"));
let buffer = ctx.redact_buffer();
assert!(buffer.contains("b.rs"), "{buffer}");
assert!(!buffer.contains("beta changed"), "{buffer}");
}
#[test]
fn a_diffstat_is_the_diff_folded_to_stat_rows() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
taller_than_the_screen(&ctx);
ctx.config().general.diff_renderer.command = file_header_renderer();
let stat = {
ctx.init_app_as_pager_of(&["git", "diff", "--stat"]);
ctx.redact_buffer()
};
ctx.config().general.start_folded = true;
ctx.config().general.file_header = crate::config::FileHeader::Stat;
ctx.init_app_as_pager_of(&["git", "diff"]);
assert_eq!(stat, ctx.redact_buffer());
}
#[test]
fn a_patch_part_drawn_is_shown_as_the_renderer_drew_it() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
ctx.config().general.diff_renderer.command = annotating_renderer()
.into_iter()
.map(|arg| arg.replace("BEGIN{", "NR==6{fflush(); system(\"sleep 2\")}\nBEGIN{"))
.collect();
let app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
let undrawn: Vec<_> = app
.screen()
.items()
.iter()
.filter(|item| matches!(item.data, crate::item_data::ItemData::HunkLine { .. }))
.filter(|item| item.rendered.is_none())
.collect();
assert!(undrawn.is_empty(), "{undrawn:?}");
}
#[test]
fn a_part_drawn_patch_has_only_the_files_drawn_so_far() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
ctx.config().general.diff_renderer.command = annotating_renderer()
.into_iter()
.map(|arg| {
arg.replace(
"BEGIN{",
"/diff --git/ && ++files == 2 {fflush(); system(\"sleep 2\")}\nBEGIN{",
)
})
.collect();
let app = ctx.init_app_streaming_as_pager_of(&["git", "diff"]);
let files: Vec<usize> = app
.screen()
.items()
.iter()
.filter_map(|item| match item.data {
crate::item_data::ItemData::Delta { file_i, .. } => Some(file_i),
_ => None,
})
.collect();
assert_eq!(files, vec![0]);
}
#[test]
fn a_patch_of_one_file_starts_unfolded() {
let mut ctx = setup_clone!();
commit(&ctx.dir, "a.rs", "alpha\n");
fs::write(ctx.dir.join("a.rs"), "alpha changed\n").unwrap();
ctx.config().general.start_folded = true;
ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("alpha changed"), "{buffer}");
}
#[test]
fn a_patch_that_fits_on_one_screen_starts_unfolded() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
ctx.config().general.start_folded = true;
ctx.init_app_as_pager_of(&["git", "diff"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("alpha changed"), "{buffer}");
assert!(buffer.contains("beta changed"), "{buffer}");
}
#[test]
fn a_view_opening_folded_starts_on_its_first_file() {
let mut ctx = setup_clone!();
let tall = (1..=30).map(|i| format!("{i}\n")).collect::<String>();
fs::write(ctx.dir.join("secondfile"), tall).unwrap();
run(&ctx.dir, &["git", "add", "secondfile"]);
commit(&ctx.dir, "firstfile", "first\n");
ctx.config().general.start_folded = true;
let mut app = ctx.init_app();
ctx.update(&mut app, keys("ll<enter>"));
assert!(
matches!(
app.screen().get_selected_item().data,
crate::item_data::ItemData::Delta { file_i: 0, .. }
),
"{:?}\n{}",
app.screen().get_selected_item().data,
ctx.redact_buffer()
);
}
#[test]
fn a_command_run_in_a_subdirectory_is_run_there_again() {
let mut ctx = setup_clone!();
fs::create_dir(ctx.dir.join("sub")).unwrap();
commit(&ctx.dir, "sub/lib.rs", "sub\n");
commit(&ctx.dir, "lib.rs", "top\n");
fs::write(ctx.dir.join("sub/lib.rs"), "sub changed\n").unwrap();
fs::write(ctx.dir.join("lib.rs"), "top changed\n").unwrap();
ctx.init_app_as_pager_of_in(ctx.dir.join("sub"), &["git", "diff", "--", "lib.rs"]);
let buffer = ctx.redact_buffer();
assert!(buffer.contains("sub changed"), "{buffer}");
assert!(!buffer.contains("top changed"), "{buffer}");
}
#[test]
fn j_k_and_the_arrow_keys_move_a_line_at_a_time() {
for (down, up) in [("j", "k"), ("<down>", "<up>")] {
let mut ctx = setup_clone!();
commit(&ctx.dir, "firstfile", "a\nb\nc\n");
fs::write(ctx.dir.join("firstfile"), "A\nB\nc\n").unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys(down));
let first = selected_hunk_line(&app);
assert!(first.is_some(), "{}", ctx.redact_buffer());
ctx.update(&mut app, keys(down));
assert_eq!(selected_hunk_line(&app), first.map(|line_i| line_i + 1));
ctx.update(&mut app, keys(up));
assert_eq!(selected_hunk_line(&app), first);
}
}
#[test]
fn ctrl_j_moves_to_the_next_hunk() {
let mut ctx = setup_clone!();
let lines = |second: &str, last: &str| {
let mut lines: Vec<String> = (1..=30).map(|i| format!("line {i}")).collect();
lines[1] = second.to_owned();
lines[28] = last.to_owned();
lines.join("\n") + "\n"
};
commit(&ctx.dir, "firstfile", &lines("line 2", "line 29"));
fs::write(ctx.dir.join("firstfile"), lines("changed 2", "changed 29")).unwrap();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
ctx.update(&mut app, keys("<ctrl+j>"));
assert!(
matches!(
app.screen().get_selected_item().data,
crate::item_data::ItemData::Hunk { hunk_i: 1, .. }
),
"{:?}\n{}",
app.screen().get_selected_item().data,
ctx.redact_buffer()
);
}