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("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("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("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("js"));
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("js"));
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 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("j"));
assert!(offers(&app, Op::Stage));
assert!(ctx.redact_buffer().contains("+again"));
ctx.update(&mut app, keys(": --cached<enter>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 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();
}
#[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 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);
let other = ctx.dir.parent().unwrap().join("other-worktree");
let other_arg = other.to_str().unwrap();
run(
&ctx.dir,
&["git", "worktree", "add", "-b", "other", other_arg],
);
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 a_view_that_is_not_what_git_asked_for_says_what_it_is() {
let mut ctx = setup_clone!();
two_changed_files(&ctx);
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
assert!(
!ctx.redact_buffer().contains("git diff"),
"nothing to say about git's own question:\n{}",
ctx.redact_buffer()
);
ctx.update(&mut app, keys("kd"));
assert!(
ctx.redact_buffer()
.contains("git diff -- ':(top,exclude)a.rs'"),
"{}",
ctx.redact_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("<ctrl+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("<ctrl+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;
ctx.config().general.diff_renderer.command = osc_1717_passthrough();
let mut app = ctx.init_app_as_pager_of(&["git", "diff"]);
for line_i in 0..4 {
ctx.update(&mut app, keys("<ctrl+j>"));
assert_eq!(selected_hunk_line(&app), Some(line_i));
}
}
pub(super) fn osc_1717_passthrough() -> Vec<String> {
[
"awk",
r#"
BEGIN{printf "\033]1717;1\033\\\n"}
{gsub(/\033\[[0-9;]*m/,"")}
/^\+\+\+ /{file=substr($0,7);print;next}
/^--- /{print;next}
/^@@ /{o=$2;n=$3;sub(/^-/,"",o);sub(/^\+/,"",n);split(o,a,",");split(n,b,",");old=a[1];new=b[1];print;next}
/^\+/{printf "\033]1717;1;a;%d;;%s\033\\%s\n",new++,file,$0;next}
/^-/{printf "\033]1717;1;d;%d;%d;%s\033\\%s\n",new,old++,file,$0;next}
/^ /{printf "\033]1717;1;c;%d;;%s\033\\%s\n",new++,file,$0;old++;next}
{print}
"#,
]
.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}");
}
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("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 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 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><ctrl+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> {
osc_1717_passthrough()
.into_iter()
.map(|arg| {
arg.replace(
r#"old=a[1];new=b[1];print;next}"#,
r#"old=a[1];new=b[1];printf "\033]1717;1;h;%d;;%s\033\\rendered hunk %d\n",new,file,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"));
}
fn file_header_renderer() -> Vec<String> {
osc_1717_passthrough()
.into_iter()
.map(|arg| {
arg.replace(
r#"/^\+\+\+ /{file=substr($0,7);print;next}"#,
r#"/^\+\+\+ /{file=substr($0,7);printf "\033]1717;1;f;;;%s\033\\~ %s ~\n\033]1717;1;f;;;%s\033\\===========\n",file,file,file;next}"#,
)
})
.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()]), 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, 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();
}
}
pub(super) fn pausing_renderer() -> Vec<String> {
osc_1717_passthrough()
.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()
);
}