use super::{Items, Screen, streamed_items};
use crate::style::{Modifier, Style};
use crate::{
Res,
calling_process::GitCommand,
config::Config,
error::Error,
git::diff::{Diff, DiffType},
gitu_diff,
items::{self, Item, RenderParams},
stream::{Input, Snapshot, Stream},
};
use git2::Repository;
use std::{cell::RefCell, rc::Rc, sync::Arc};
pub struct Paged {
pub text: String,
pub git_argv: Option<Vec<String>>,
pub truncated: bool,
}
pub(crate) fn create(
config: Arc<Config>,
repo: Rc<Repository>,
params: RenderParams,
paged: Paged,
) -> Res<Screen> {
let source = Source::of(paged, &config.general.hide);
let git_command = source.git_command();
let mut screen = Screen::streamed(
Arc::clone(&config),
params,
Box::new(move |params: RenderParams| {
if params.streaming {
source.first_items(&config, &repo, ¶ms)
} else {
source.items(&config, &repo, ¶ms).map(Items::from)
}
}),
)?;
screen.git_command = git_command;
Ok(screen)
}
enum Source {
Live {
git: Rc<RefCell<GitCommand>>,
found: String,
},
Patch { text: String, truncated: bool },
Unstructured { text: String, truncated: bool },
}
impl Source {
fn of(paged: Paged, hide: &[String]) -> Self {
if let Some(git) = paged.git_argv.and_then(GitCommand::of) {
let mut patterns = git.file_patterns();
patterns.extend(hide.iter().map(|glob| format!("!{glob}")));
return Source::Live {
found: git.line(None),
git: Rc::new(RefCell::new(git.asking_for(&patterns))),
};
}
let file_diffs = gitu_diff::Parser::new(&crate::diff_renderer::strip_ansi(&paged.text))
.parse_diff()
.unwrap_or_default();
if file_diffs.is_empty() {
return Source::Unstructured {
text: paged.text,
truncated: paged.truncated,
};
}
Source::Patch {
text: paged.text,
truncated: paged.truncated,
}
}
fn git_command(&self) -> Option<Rc<RefCell<GitCommand>>> {
match self {
Source::Live { git, .. } => Some(Rc::clone(git)),
Source::Patch { .. } | Source::Unstructured { .. } => None,
}
}
fn items(&self, config: &Config, repo: &Repository, params: &RenderParams) -> Res<Vec<Item>> {
let (stream, shape) = self.start(config, repo, params)?;
stream.wait_for(usize::MAX);
built(config, repo, params, &shape, stream.snapshot())
}
fn first_items(
&self,
config: &Arc<Config>,
repo: &Rc<Repository>,
params: &RenderParams,
) -> Res<Items> {
let (stream, shape) = self.start(config, repo, params)?;
let (config, repo, built_params) = (Arc::clone(config), Rc::clone(repo), params.clone());
streamed_items(stream, params, move |snapshot| {
built(&config, &repo, &built_params, &shape, snapshot)
})
}
fn start(
&self,
config: &Config,
repo: &Repository,
params: &RenderParams,
) -> Res<(Stream, Shape)> {
let (input, shape) = match self {
Source::Live { git, found } => {
let git = git.borrow();
let mut command = git.command(params.context.as_deref());
command.current_dir(repo.workdir().ok_or(Error::NoRepoWorkdir)?);
let shape = Shape::Diff {
diff_type: git.diff_type(),
named_commit: true,
asked: asked_rows(&git.line(params.context.as_deref()), found),
truncated: false,
};
(Input::Command(command), shape)
}
Source::Patch { text, truncated } => {
let shape = Shape::Diff {
diff_type: DiffType::TreeToTree,
named_commit: false,
asked: Vec::new(),
truncated: *truncated,
};
(Input::Text(text.clone()), shape)
}
Source::Unstructured { text, truncated } => (
Input::Text(text.clone()),
Shape::Rows {
truncated: *truncated,
},
),
};
let renderer = (!config.general.diff_renderer.command.is_empty())
.then(|| {
crate::diff_renderer::renderer_command(
&config.general.diff_renderer.command,
params,
None,
)
})
.flatten();
let stream = Stream::start(input, renderer, config.general.max_input_bytes)
.map_err(Error::GitDiff)?;
Ok((stream, shape))
}
}
enum Shape {
Diff {
diff_type: DiffType,
named_commit: bool,
asked: Vec<Item>,
truncated: bool,
},
Rows { truncated: bool },
}
fn built(
config: &Config,
repo: &Repository,
params: &RenderParams,
shape: &Shape,
snapshot: Snapshot,
) -> Res<Vec<Item>> {
if let Some(refused) = snapshot.refused {
return Err(Error::GitRefusedTheCommand(refused));
}
let limit = config.general.max_input_bytes;
let drawn = snapshot.rendered.as_deref().unwrap_or(&snapshot.text);
let (mut items, truncated) = match shape {
Shape::Rows { truncated } => (rows(repo, drawn), *truncated),
Shape::Diff {
diff_type,
named_commit,
asked,
truncated,
} => {
let mut items = asked.clone();
let text = crate::diff_renderer::strip_ansi(&snapshot.text);
let file_diffs = gitu_diff::Parser::new(&text)
.parse_diff()
.unwrap_or_default();
if file_diffs.is_empty() {
items.extend(rows(repo, drawn));
} else {
let diff = Rc::new(Diff {
file_diffs,
diff_type: diff_type.clone(),
commit: named_commit.then(|| commit_named_by(&text)).flatten(),
text,
});
items.extend(preamble_items(config, params, &snapshot.text, &diff.text));
items.extend(items::drawn_diff_items(
config,
&diff,
snapshot.rendered.as_deref(),
0,
false,
None,
));
}
(items, *truncated)
}
};
items.extend(truncated_rows(truncated || snapshot.truncated, limit));
Ok(items)
}
fn asked_rows(line: &str, found: &str) -> Vec<Item> {
if line == found {
return Vec::new();
}
vec![dim_row(line.to_owned())]
}
fn truncated_rows(truncated: bool, limit: u64) -> Vec<Item> {
if !truncated {
return Vec::new();
}
vec![dim_row(format!(
"input truncated at {limit} bytes (general.max_input_bytes)"
))]
}
fn dim_row(text: String) -> Item {
Item {
unselectable: true,
rendered: Some(Rc::new(vec![(
text,
Style {
add_modifier: Modifier::DIM,
..Style::new()
},
)])),
..Default::default()
}
}
fn rows(repo: &Repository, drawn: &str) -> Vec<Item> {
items::rendered_log_items(repo, drawn).unwrap_or_else(|| items::plain_rows(drawn))
}
fn commit_named_by(text: &str) -> Option<String> {
let id = text
.lines()
.next()?
.strip_prefix("commit ")?
.split_whitespace()
.next()?;
id.chars()
.all(|c| c.is_ascii_hexdigit())
.then(|| id.to_owned())
}
fn preamble_items(
config: &Config,
params: &RenderParams,
coloured: &str,
plain: &str,
) -> Vec<Item> {
let end = match plain.find("\ndiff --git ") {
Some(i) if !plain.starts_with("diff --git ") => i + 1,
_ => return Vec::new(),
};
let lines = plain[..end].lines().count();
let preamble: String = coloured.split_inclusive('\n').take(lines).collect();
items::plain_rows(&render(config, params, &preamble))
}
fn render(config: &Config, params: &RenderParams, text: &str) -> String {
if config.general.diff_renderer.command.is_empty() {
return text.to_owned();
}
crate::diff_renderer::run(
&config.general.diff_renderer.command,
Some(text),
params,
None,
)
.unwrap_or_else(|| text.to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config;
use crate::item_data::ItemData;
use crate::repo_setup_clone;
use crate::tests::helpers::{RepoTestContext, run};
use stdext::function_name;
const PATCH: &str = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1,3 +1,3 @@\n\
\x20keep\n\
-gone\n\
+added\n";
fn paged(text: &str, git_argv: Option<Vec<String>>) -> Paged {
Paged {
text: text.to_owned(),
git_argv,
truncated: false,
}
}
fn items_of(repo: &Repository, patch: &str) -> Vec<ItemData> {
let config = config::init_test_config().unwrap();
Source::of(paged(patch, None), &[])
.items(&config, repo, &Default::default())
.unwrap()
.into_iter()
.map(|item| item.data)
.collect()
}
fn screen_of(ctx: &RepoTestContext, patch: &str) -> Screen {
create(
Arc::new(config::init_test_config().unwrap()),
Rc::new(Repository::open(&ctx.dir).unwrap()),
RenderParams {
size: (80, 20),
features: Rc::from([]),
context: None,
streaming: false,
},
paged(patch, None),
)
.unwrap()
}
#[test]
fn a_piped_patch_becomes_a_file_a_hunk_and_its_lines() {
let ctx = repo_setup_clone!();
let items = items_of(&ctx.local_repo, PATCH);
assert!(
matches!(items.first(), Some(ItemData::Delta { .. })),
"got {items:?}"
);
assert!(items.iter().any(|d| matches!(d, ItemData::Hunk { .. })));
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::HunkLine { .. }))
.count(),
3
);
}
#[test]
fn a_coloured_patch_parses_the_same_as_a_plain_one() {
let ctx = repo_setup_clone!();
let coloured = "\x1b[1mdiff --git a/f.rs b/f.rs\x1b[m\n\
\x1b[1mindex 1..2 100644\x1b[m\n\
\x1b[1m--- a/f.rs\x1b[m\n\
\x1b[1m+++ b/f.rs\x1b[m\n\
\x1b[36m@@ -1,3 +1,3 @@\x1b[m\n\
\x20keep\n\
\x1b[31m-gone\x1b[m\n\
\x1b[32m+added\x1b[m\n";
assert_eq!(
items_of(&ctx.local_repo, coloured).len(),
items_of(&ctx.local_repo, PATCH).len()
);
}
#[test]
fn a_commit_patch_keeps_its_message_above_the_diff() {
let ctx = repo_setup_clone!();
let patch = format!(
"commit 0123456789abcdef\n\
Author: Author Name <author@email.com>\n\
\n\
\x20 add initial-file\n\
\n\
{PATCH}"
);
let screen = screen_of(&ctx, &patch);
let rows = screen.row_texts();
assert!(
rows.starts_with(&[
"commit 0123456789abcdef".to_string(),
"Author: Author Name <author@email.com>".to_string(),
String::new(),
" add initial-file".to_string(),
String::new(),
]),
"got {rows:?}"
);
assert!(
items_of(&ctx.local_repo, &patch)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"the diff below it is still a diff"
);
}
#[test]
fn a_patch_of_several_files_shows_each_of_them_once() {
let ctx = repo_setup_clone!();
let patch = format!("{PATCH}{}", PATCH.replace("f.rs", "g.rs"));
let items = items_of(&ctx.local_repo, &patch);
assert!(
matches!(items.first(), Some(ItemData::Delta { .. })),
"got {items:?}"
);
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::Delta { .. }))
.count(),
2
);
assert_eq!(
items
.iter()
.filter(|d| matches!(d, ItemData::HunkLine { .. }))
.count(),
6
);
}
#[test]
fn the_cursor_moves_between_unstructured_rows() {
let ctx = repo_setup_clone!();
let mut screen = screen_of(&ctx, "one\ntwo\nthree\n");
assert_eq!(screen.row_texts().len(), 3, "{:?}", screen.row_texts());
screen.select_next(crate::screen::NavMode::IncludeSubLines);
assert_eq!(selected_row(&screen), "two");
}
fn selected_row(screen: &Screen) -> String {
screen
.get_selected_item()
.rendered
.as_ref()
.map(|row| row.iter().map(|(text, _)| text.as_str()).collect())
.unwrap_or_default()
}
#[test]
fn output_that_is_not_a_patch_yields_no_files() {
let ctx = repo_setup_clone!();
assert!(
!items_of(&ctx.local_repo, "this is not a diff\nnor is this\n")
.iter()
.any(|data| matches!(
data,
ItemData::Delta { .. } | ItemData::Hunk { .. } | ItemData::HunkLine { .. }
))
);
assert!(items_of(&ctx.local_repo, "").is_empty());
}
#[test]
fn output_that_is_not_a_patch_is_still_readable() {
let ctx = repo_setup_clone!();
let grep = "m.rs:2: let alpha = 1;\nm.rs:3: let gamma = 42;\n";
let screen = screen_of(&ctx, grep);
assert_eq!(
screen.row_texts(),
["m.rs:2: let alpha = 1;", "m.rs:3: let gamma = 42;"]
);
}
#[test]
fn the_context_of_a_diff_against_a_rev_can_be_widened() {
let ctx = repo_setup_clone!();
let lines = (1..=21).map(|n| format!("line {n}\n")).collect::<String>();
write(&ctx, &lines);
run(&ctx.dir, &["git", "add", "f.txt"]);
run(&ctx.dir, &["git", "commit", "-m", "add f.txt"]);
run(&ctx.dir, &["git", "checkout", "-b", "topic"]);
write(&ctx, &lines.replace("line 11\n", "LINE 11\n"));
run(&ctx.dir, &["git", "commit", "-am", "change f.txt"]);
let patch = run(&ctx.dir, &["git", "diff", "main"]);
let argv = ["git", "diff", "main"].map(String::from).to_vec();
assert!(
hunk_lines(&ctx, &patch, &argv, Some("-U5")) > hunk_lines(&ctx, &patch, &argv, None)
);
}
#[test]
fn an_edit_that_asks_for_a_diff_gets_one() {
let ctx = repo_setup_clone!();
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
let source = Source::of(
paged(
&run(&ctx.dir, &["git", "log"]),
Some(["git", "log"].map(String::from).to_vec()),
),
&[],
);
let items = |source: &Source| {
source
.items(&config, &repo, &Default::default())
.unwrap()
.into_iter()
.map(|item| item.data)
.collect::<Vec<_>>()
};
assert!(
!items(&source)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"a log has no diff in it"
);
let git = source.git_command().expect("the command was found");
let edited = git.borrow().edited("git log -p").unwrap();
*git.borrow_mut() = edited;
assert!(
items(&source)
.iter()
.any(|data| matches!(data, ItemData::HunkLine { .. })),
"a log with patches does"
);
}
#[test]
fn the_files_config_hides_are_gone_from_the_first_ask() {
let ctx = repo_setup_clone!();
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
for file in ["a.rs", "a.pb.rs"] {
std::fs::write(ctx.dir.join(file), "generated\n").unwrap();
}
run(&ctx.dir, &["git", "add", "."]);
let source = Source::of(
paged(
&run(&ctx.dir, &["git", "diff", "--cached"]),
Some(["git", "diff", "--cached"].map(String::from).to_vec()),
),
&["*.pb.rs".to_owned()],
);
let files = source
.items(&config, &repo, &Default::default())
.unwrap()
.iter()
.filter_map(|item| match &item.data {
ItemData::Delta { diff, file_i, .. } => Some(
diff.file_diffs[*file_i]
.header
.new_file
.fmt(&diff.text)
.into_owned(),
),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(files, ["a.rs"]);
}
fn write(ctx: &RepoTestContext, content: &str) {
std::fs::write(ctx.dir.join("f.txt"), content).unwrap();
}
fn hunk_lines(
ctx: &RepoTestContext,
patch: &str,
argv: &[String],
context: Option<&str>,
) -> usize {
let config = config::init_test_config().unwrap();
let repo = Repository::open(&ctx.dir).unwrap();
Source::of(paged(patch, Some(argv.to_vec())), &[])
.items(
&config,
&repo,
&RenderParams {
context: context.map(Rc::from),
..Default::default()
},
)
.unwrap()
.iter()
.filter(|item| matches!(item.data, ItemData::HunkLine { .. }))
.count()
}
}