use crate::Res;
use crate::config::Config;
use crate::error::Error;
use crate::git::diff::Diff;
use crate::highlight;
use crate::item_data::ItemData;
use crate::item_data::Ref;
use crate::rebase_todo::{RebaseTodo, TodoAction, TodoEntry};
use crate::style::Style;
use git2::Oid;
use git2::Repository;
use regex::Regex;
use std::cell::RefCell;
use std::cmp::Ordering;
use std::collections::HashMap;
use std::hash::DefaultHasher;
use std::hash::Hash;
use std::hash::Hasher;
use std::iter;
use std::rc::Rc;
use std::time::SystemTime;
pub type ItemId = u64;
pub(crate) type RenderedRow = Vec<(String, Style)>;
#[derive(Default, Clone)]
pub(crate) struct RenderParams {
pub size: (u16, u16),
pub features: Rc<[String]>,
pub context: Option<Rc<str>>,
}
impl RenderParams {
pub(crate) fn width(&self) -> usize {
(self.size.0 as usize).saturating_sub(2)
}
}
#[derive(Default, Clone, Debug)]
pub(crate) struct Item {
pub(crate) id: ItemId,
pub(crate) default_collapsed: bool,
pub(crate) depth: usize,
pub(crate) unselectable: bool,
pub(crate) data: ItemData,
pub(crate) rendered: Option<Rc<RenderedRow>>,
}
pub(crate) fn create_diff_items(
config: &Config,
params: &RenderParams,
diff: &Rc<Diff>,
depth: usize,
default_collapsed: bool,
commit: Option<String>,
) -> Vec<Item> {
if config.general.diff_renderer.enabled
&& !diff.text.is_empty()
&& let Some(items) = create_rendered_diff_items(
config,
params,
diff,
depth,
default_collapsed,
commit.clone(),
)
{
return items;
}
create_builtin_diff_items(
diff,
depth,
default_collapsed,
commit,
config.general.visit_context_lines,
)
.collect()
}
fn create_rendered_diff_items(
config: &Config,
params: &RenderParams,
diff: &Rc<Diff>,
depth: usize,
default_collapsed: bool,
commit: Option<String>,
) -> Option<Vec<Item>> {
let output = crate::diff_renderer::run(
&config.general.diff_renderer.command,
Some(&diff.text),
params,
None,
)?;
let parsed = crate::diff_renderer::parse_ansi_lines(&output);
parsed.protocol_version?;
let has_hunks = diff.file_diffs.iter().any(|file| !file.hunks.is_empty());
let annotated_a_line = parsed
.lines
.iter()
.flat_map(|line| &line.records)
.any(|record| record.kind.is_content());
if has_hunks && !annotated_a_line {
return None;
}
Some(rendered_diff_items(
diff,
&parsed.lines,
depth,
default_collapsed,
commit,
config.general.visit_context_lines,
))
}
fn rendered_diff_items(
diff: &Rc<Diff>,
lines: &[crate::diff_renderer::ParsedLine],
depth: usize,
default_collapsed: bool,
commit: Option<String>,
visit_context_lines: bool,
) -> Vec<Item> {
use crate::diff_renderer::LineKind;
let mut headers_by_hunk: HashMap<(usize, usize), Vec<RenderedRow>> = HashMap::new();
let mut content_by_hunk: HashMap<(usize, usize), Vec<Item>> = HashMap::new();
let mut last_content: Option<(usize, usize, usize)> = None;
for line in lines {
let Some(first) = line.records.first() else {
last_content = None;
continue; };
match first.kind {
LineKind::FileHeader | LineKind::Commit => last_content = None,
LineKind::HunkHeader => {
last_content = None;
if line.text.trim().is_empty() {
continue;
}
if let Some(key) = crate::diff_renderer::resolve_hunk(diff, first) {
headers_by_hunk
.entry(key)
.or_default()
.push(rendered_spans(line));
}
}
LineKind::Context | LineKind::Added | LineKind::Deleted => {
let Some((file_i, hunk_i, line_i)) =
crate::diff_renderer::resolve_line(diff, first)
else {
continue;
};
let line_indices = line
.records
.iter()
.filter_map(|meta| crate::diff_renderer::resolve_line(diff, meta))
.filter(|(f, h, _)| (*f, *h) == (file_i, hunk_i))
.map(|(_, _, li)| li)
.collect();
let hunk_hash = hash([diff.file_diff_header(file_i), diff.hunk(file_i, hunk_i)]);
let rows = content_by_hunk.entry((file_i, hunk_i)).or_default();
let continues_previous = last_content
.replace((file_i, hunk_i, line_i))
.is_some_and(|previous| previous == (file_i, hunk_i, line_i));
if continues_previous {
rows.push(Item {
id: hunk_hash,
depth: depth + 3,
unselectable: true,
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
});
continue;
}
let line_range = highlight::line_range_iterator(diff.hunk_content(file_i, hunk_i))
.nth(line_i)
.map(|(range, _)| range)
.unwrap_or_default();
rows.push(Item {
id: hunk_hash,
depth: depth + 2,
unselectable: !visit_context_lines && matches!(first.kind, LineKind::Context),
data: ItemData::HunkLine {
diff: Rc::clone(diff),
file_i,
hunk_i,
line_i,
line_range,
line_indices,
},
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
});
}
}
}
let mut items = Vec::new();
for (file_i, file_diff) in diff.file_diffs.iter().enumerate() {
items.push(Item {
id: hash(diff.file_diff_header(file_i)),
default_collapsed,
depth,
data: ItemData::Delta {
diff: Rc::clone(diff),
file_i,
commit: commit.clone(),
},
..Default::default()
});
for hunk_i in 0..file_diff.hunks.len() {
let hunk_hash = hash([diff.file_diff_header(file_i), diff.hunk(file_i, hunk_i)]);
let hunk = ItemData::Hunk {
diff: Rc::clone(diff),
file_i,
hunk_i,
};
let mut header_rows = headers_by_hunk
.remove(&(file_i, hunk_i))
.unwrap_or_default()
.into_iter();
items.push(Item {
id: hunk_hash,
depth: depth + 1,
data: hunk,
rendered: header_rows.next().map(Rc::new),
..Default::default()
});
for extra in header_rows {
items.push(Item {
id: hunk_hash,
depth: depth + 2,
unselectable: true,
rendered: Some(Rc::new(extra)),
..Default::default()
});
}
if let Some(rows) = content_by_hunk.remove(&(file_i, hunk_i)) {
items.extend(rows);
}
}
}
items
}
pub(crate) fn plain_rows(text: &str) -> Vec<Item> {
rows_of(crate::diff_renderer::parse_ansi_lines(text).lines.iter())
}
fn rows_of<'a>(lines: impl Iterator<Item = &'a crate::diff_renderer::ParsedLine>) -> Vec<Item> {
lines
.map(|line| Item {
depth: 0,
rendered: Some(Rc::new(rendered_spans(line))),
..Default::default()
})
.collect()
}
fn rendered_spans(line: &crate::diff_renderer::ParsedLine) -> RenderedRow {
line.runs
.iter()
.map(|(range, style)| {
let text = line.text[range.clone()].replace('\t', " ");
let text = if let Some(link) = line
.hyperlinks
.iter()
.find(|link| link.range.contains(&range.start))
{
crate::ui::osc8_hyperlink(&text, &link.uri)
} else {
text
};
(text, *style)
})
.collect()
}
fn create_builtin_diff_items(
diff: &Rc<Diff>,
depth: usize,
default_collapsed: bool,
commit: Option<String>,
visit_context_lines: bool,
) -> impl Iterator<Item = Item> + '_ {
diff.file_diffs
.iter()
.enumerate()
.flat_map(move |(file_i, file_diff)| {
iter::once(Item {
id: hash(diff.file_diff_header(file_i)),
default_collapsed,
depth,
data: ItemData::Delta {
diff: Rc::clone(diff),
file_i,
commit: commit.clone(),
},
..Default::default()
})
.chain(file_diff.hunks.iter().cloned().enumerate().flat_map(
move |(hunk_i, _hunk)| {
create_hunk_items(
Rc::clone(diff),
file_i,
hunk_i,
depth + 1,
visit_context_lines,
)
},
))
})
}
fn create_hunk_items(
diff: Rc<Diff>,
file_i: usize,
hunk_i: usize,
depth: usize,
visit_context_lines: bool,
) -> impl Iterator<Item = Item> {
let hunk_hash = hash([diff.file_diff_header(file_i), diff.hunk(file_i, hunk_i)]);
iter::once(Item {
id: hunk_hash,
depth,
data: ItemData::Hunk {
diff: Rc::clone(&diff),
file_i,
hunk_i,
},
..Default::default()
})
.chain(format_diff_hunk_items(
diff,
file_i,
hunk_i,
depth + 1,
hunk_hash,
visit_context_lines,
))
}
fn format_diff_hunk_items(
diff: Rc<Diff>,
file_i: usize,
hunk_i: usize,
depth: usize,
hunk_hash: u64,
visit_context_lines: bool,
) -> Vec<Item> {
let hunk_content = diff.hunk_content(file_i, hunk_i);
highlight::line_range_iterator(hunk_content)
.enumerate()
.map(|(line_index, (line_range, line))| Item {
id: hunk_hash,
unselectable: !visit_context_lines && line.starts_with(' '),
depth,
data: ItemData::HunkLine {
diff: Rc::clone(&diff),
file_i,
hunk_i,
line_i: line_index,
line_range,
line_indices: vec![line_index],
},
..Default::default()
})
.collect()
}
pub(crate) fn stash_list(repo: &Repository, limit: usize) -> Res<Vec<Item>> {
Ok(repo
.reflog("refs/stash")
.map_err(Error::StashList)?
.iter()
.enumerate()
.map(|(i, stash)| -> Res<Item> {
let stash_id = stash.id_new();
let stash_ref = format!("stash@{{{}}}", i);
Ok(Item {
id: hash(stash_id),
depth: 1,
data: ItemData::Stash {
message: stash.message().unwrap_or("").to_string(),
stash_ref,
id: i,
},
..Default::default()
})
})
.map(|result| match result {
Ok(item) => item,
Err(err) => {
let err = err.to_string();
Item {
id: hash(&err),
data: ItemData::Error(err),
..Default::default()
}
}
})
.take(limit)
.collect::<Vec<_>>())
}
fn short_age(time: git2::Time) -> String {
const MINUTE: i64 = 60;
const HOUR: i64 = 60 * MINUTE;
const DAY: i64 = 24 * HOUR;
const WEEK: i64 = 7 * DAY;
const MONTH: i64 = 30 * DAY;
const YEAR: i64 = 365 * DAY;
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |since_epoch| since_epoch.as_secs() as i64);
let age = (now - time.seconds()).max(0);
if age < HOUR {
format!("{}m", age / MINUTE)
} else if age < DAY {
format!("{}h", age / HOUR)
} else if age < WEEK {
format!("{}d", age / DAY)
} else if age < MONTH {
format!("{}w", age / WEEK)
} else if age < YEAR {
format!("{}M", age / MONTH)
} else {
format!("{}y", age / YEAR)
}
}
pub(crate) fn log(
repo: &Repository,
limit: usize,
rev: Option<Oid>,
msg_regex: Option<Regex>,
) -> Res<Vec<Item>> {
let mut revwalk = repo.revwalk().map_err(Error::ReadLog)?;
if let Some(r) = rev {
revwalk.push(r).map_err(Error::ReadLog)?;
} else if revwalk.push_head().is_err() {
return Ok(vec![]);
}
let references = commit_refs(repo)?;
let items: Vec<Item> = revwalk
.map(|oid_result| -> Res<Option<Item>> {
let oid = oid_result.map_err(Error::ReadLog)?;
let commit = repo.find_commit(oid).map_err(Error::ReadLog)?;
let short_id = commit.as_object().short_id().map_err(Error::ReadOid)?;
let short_id = String::from_utf8_lossy(&short_id).to_string();
if let Some(re) = &msg_regex
&& !re.is_match(commit.message().unwrap_or(""))
{
return Ok(None);
}
let associated_references: Vec<_> = references
.iter()
.filter(|(commit, _)| commit.id() == oid)
.map(|(_, reference)| reference.clone())
.collect();
let data = ItemData::Commit {
oid: oid.to_string(),
short_id,
associated_references,
summary: commit.summary().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
age: short_age(commit.author().when()),
};
Ok(Some(Item {
id: hash(oid),
depth: 1,
data,
..Default::default()
}))
})
.filter_map(|result| match result {
Ok(item) => item,
Err(err) => {
let err = err.to_string();
Some(Item {
id: hash(&err),
data: ItemData::Error(err),
..Default::default()
})
}
})
.take(limit)
.collect();
if items.is_empty() {
Ok(vec![Item {
data: ItemData::Raw("No commits found".to_string()),
..Default::default()
}])
} else {
Ok(items)
}
}
fn commit_refs(repo: &Repository) -> Res<Vec<(git2::Commit<'_>, Ref)>> {
Ok(repo
.references()
.map_err(Error::ReadLog)?
.filter_map(Result::ok)
.filter_map(
|reference| match (reference.peel_to_commit(), reference.shorthand()) {
(Ok(target), Some(name)) => {
if name.ends_with("/HEAD") || name.starts_with("prefetch/remotes/") {
return None;
}
let name = name.to_owned();
let ref_kind = if reference.is_remote() {
Ref::Remote(name)
} else if reference.is_tag() {
Ref::Tag(name)
} else {
Ref::Head(name)
};
Some((target, ref_kind))
}
_ => None,
},
)
.collect())
}
pub(crate) fn rendered_log(
config: &Config,
repo: &Repository,
params: &RenderParams,
limit: usize,
rev: Option<Oid>,
msg_regex: Option<&Regex>,
) -> Option<Vec<Item>> {
let mut args = Vec::new();
if let Ok(limit) = i32::try_from(limit) {
args.push(format!("-n{limit}"));
}
if let Some(regex) = msg_regex {
args.push("--extended-regexp".into());
args.push(format!("--grep={regex}"));
}
args.push(rev.map_or_else(|| "HEAD".to_string(), |oid| oid.to_string()));
let blocks = rendered_commits(config, repo, params, &args)?;
Some(log_items(repo, &blocks))
}
pub(crate) fn rendered_log_items(repo: &Repository, rendered: &str) -> Option<Vec<Item>> {
Some(log_items(repo, &commits_in(rendered)?))
}
fn log_items(repo: &Repository, blocks: &[RenderedCommit]) -> Vec<Item> {
let references = commit_refs(repo).unwrap_or_default();
blocks
.iter()
.flat_map(|block| commit_block_items(repo, &references, block))
.collect()
}
pub(crate) struct RenderedCommit {
oid: Option<String>,
rows: Vec<Rc<RenderedRow>>,
}
fn rendered_commits(
config: &Config,
repo: &Repository,
params: &RenderParams,
args: &[String],
) -> Option<Vec<RenderedCommit>> {
let command: Vec<String> = config
.general
.log_renderer
.command
.iter()
.map(|arg| arg.replace("{commit}", crate::diff_renderer::COMMIT_RECORD_FORMAT))
.chain(args.iter().cloned())
.collect();
let dir = repo.workdir().unwrap_or_else(|| repo.path());
let output = crate::diff_renderer::run(&command, None, params, Some(dir))?;
commits_in(&output).or_else(|| {
log::warn!(
"log command emitted no commit records; is the '{{commit}}' token in its format?"
);
None
})
}
fn commits_in(rendered: &str) -> Option<Vec<RenderedCommit>> {
let parsed = crate::diff_renderer::parse_ansi_lines(rendered);
let blocks = crate::diff_renderer::commit_blocks(&parsed.lines);
if blocks.iter().all(|block| block.commit.is_none()) {
return None;
}
Some(
blocks
.iter()
.map(|block| RenderedCommit {
oid: block.commit.map(String::from),
rows: block
.rows
.iter()
.map(|row| Rc::new(rendered_spans(row)))
.collect(),
})
.collect(),
)
}
pub(crate) fn rendered_commit_rows(
config: &Config,
repo: &Repository,
params: &RenderParams,
revs: &[String],
) -> HashMap<String, Vec<Rc<RenderedRow>>> {
if !config.general.log_renderer.enabled || revs.is_empty() {
return HashMap::new();
}
rendered_commits(config, repo, params, revs)
.unwrap_or_default()
.into_iter()
.filter_map(|block| Some((block.oid?, block.rows)))
.collect()
}
fn commit_block_items(
repo: &Repository,
references: &[(git2::Commit, Ref)],
block: &RenderedCommit,
) -> Vec<Item> {
let commit = block
.oid
.as_ref()
.and_then(|oid| repo.revparse_single(oid).ok())
.and_then(|object| object.peel_to_commit().ok());
let Some(commit) = commit else {
return block.rows.iter().map(|row| row_item(0, 1, row)).collect();
};
block_items(hash(commit.id()), &block.rows, |row| Item {
data: commit_data(&commit, references),
unselectable: false,
..row_item(hash(commit.id()), 1, row)
})
}
fn block_items(
id: ItemId,
rows: &[Rc<RenderedRow>],
anchor: impl Fn(&Rc<RenderedRow>) -> Item,
) -> Vec<Item> {
let Some(anchor_i) = rows.iter().position(|row| !is_decoration(row)) else {
return rows.iter().map(|row| row_item(id, 1, row)).collect();
};
rows.iter()
.enumerate()
.map(|(i, row)| match i.cmp(&anchor_i) {
Ordering::Less => row_item(id, 1, row),
Ordering::Greater => row_item(id, 2, row),
Ordering::Equal => anchor(row),
})
.collect()
}
fn is_decoration(row: &RenderedRow) -> bool {
row.iter()
.flat_map(|(text, _)| text.chars())
.all(|c| c.is_whitespace() || BOX_DRAWING.contains(&c))
}
const BOX_DRAWING: std::ops::RangeInclusive<char> = '\u{2500}'..='\u{257f}';
fn row_item(id: ItemId, depth: usize, row: &Rc<RenderedRow>) -> Item {
Item {
id,
depth,
unselectable: true,
rendered: Some(Rc::clone(row)),
..Default::default()
}
}
pub(crate) fn rebase_todo_items(
config: &Config,
repo: &Repository,
todo: &Rc<RefCell<RebaseTodo>>,
rows_by_commit: &HashMap<String, Vec<Rc<RenderedRow>>>,
) -> Vec<Item> {
let mut items = Vec::new();
for (index, entry) in todo.borrow().entries.iter().enumerate() {
let data = ItemData::RebaseTodo {
todo: Rc::clone(todo),
index,
};
let (id, keyword, rows) = match entry {
TodoEntry::Commit { action, oid } => (
hash(oid),
Some(*action),
rows_by_commit
.get(oid)
.cloned()
.unwrap_or_else(|| vec![Rc::new(commit_row(config, repo, oid))]),
),
TodoEntry::Other(instruction) => (
hash(instruction),
None,
vec![Rc::new(vec![(instruction.clone(), Style::new())])],
),
};
items.extend(block_items(id, &rows, |row| Item {
data: data.clone(),
unselectable: false,
..row_item(id, 1, &Rc::new(action_prefixed(config, keyword, row)))
}));
}
items
}
fn action_prefixed(
config: &Config,
action: Option<TodoAction>,
row: &Rc<RenderedRow>,
) -> RenderedRow {
let Some(action) = action else {
return row.as_ref().clone();
};
let styles = &config.style.rebase_todo;
let style = Style::from(match action {
TodoAction::Pick => return blank_prefixed(row),
TodoAction::Reword => &styles.reword,
TodoAction::Edit => &styles.edit,
TodoAction::Squash => &styles.squash,
TodoAction::Fixup => &styles.fixup,
TodoAction::Drop => &styles.drop,
});
iter::once((format!("{:<7}", action.keyword()), style))
.chain(row.iter().cloned())
.collect()
}
fn blank_prefixed(row: &Rc<RenderedRow>) -> RenderedRow {
iter::once((" ".repeat(7), Style::new()))
.chain(row.iter().cloned())
.collect()
}
fn commit_row(config: &Config, repo: &Repository, oid: &str) -> RenderedRow {
let commit = Oid::from_str(oid)
.ok()
.and_then(|oid| repo.find_commit(oid).ok());
let Some(commit) = commit else {
return vec![(oid.to_string(), Style::new())];
};
let short_id = commit
.as_object()
.short_id()
.map(|id| String::from_utf8_lossy(&id).to_string())
.unwrap_or_default();
vec![
(format!("{short_id} "), Style::from(&config.style.hash)),
(commit.summary().unwrap_or("").to_string(), Style::new()),
]
}
fn commit_data(commit: &git2::Commit, references: &[(git2::Commit, Ref)]) -> ItemData {
let short_id = commit
.as_object()
.short_id()
.map(|id| String::from_utf8_lossy(&id).to_string())
.unwrap_or_default();
ItemData::Commit {
oid: commit.id().to_string(),
short_id,
associated_references: references
.iter()
.filter(|(target, _)| target.id() == commit.id())
.map(|(_, reference)| reference.clone())
.collect(),
summary: commit.summary().unwrap_or("").to_string(),
author: commit.author().name().unwrap_or("").to_string(),
age: short_age(commit.author().when()),
}
}
pub(crate) fn blank_line() -> Item {
Item {
depth: 0,
unselectable: true,
..Default::default()
}
}
pub(crate) fn hash<T: Hash>(x: T) -> ItemId {
let mut hasher = DefaultHasher::new();
x.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff_renderer::parse_ansi_lines;
use crate::git::diff::{Diff, DiffType};
const DIFF: &str = "diff --git a/f.rs b/f.rs\n\
index 1..2 100644\n\
--- a/f.rs\n\
+++ b/f.rs\n\
@@ -1,2 +1,2 @@\n\
\x20keep\n\
+added\n";
fn diff_from(text: &str) -> Rc<Diff> {
Rc::new(Diff {
text: text.to_string(),
diff_type: DiffType::WorkdirToIndex,
file_diffs: crate::gitu_diff::Parser::new(text).parse_diff().unwrap(),
commit: None,
})
}
fn hunk_line_rows(items: &[Item]) -> Vec<(usize, bool)> {
items
.iter()
.skip_while(|item| !matches!(item.data, ItemData::HunkLine { .. }))
.map(|item| (item.depth, item.unselectable))
.collect()
}
#[test]
fn rendered_spans_keep_hyperlinks() {
let line = &parse_ansi_lines(
"plain \x1b]8;;file:///tmp/a.rs:12\x1b\\linked\x1b]8;;\x1b\\ plain\n",
)
.lines[0];
assert_eq!(
rendered_spans(line)[1].0,
"\x1b]8;;file:///tmp/a.rs:12\x1b\\linked\x1b]8;;\x1b\\"
);
}
#[test]
fn wrapped_row_continuations_nest_under_their_line() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;a;2;;f.rs\x1b\\+added the first part of a long line\n\
\x1b]1717;1;a;2;;f.rs\x1b\\ and its wrapped remainder\n",
)
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
hunk_line_rows(&items),
vec![(2, false), (3, true)],
"the wrapped remainder is not a second selectable diff line"
);
}
#[test]
fn a_render_carrying_no_content_records_falls_back_to_the_built_in_one() {
let diff = diff_from(DIFF);
let mut config = crate::config::init_test_config().unwrap();
config.general.diff_renderer.enabled = true;
config.general.diff_renderer.command = ["sh", "-c", r"printf '\033]1717;1\033\\\n'"]
.map(String::from)
.to_vec();
let items = create_diff_items(&config, &Default::default(), &diff, 0, false, None);
assert_eq!(
items
.iter()
.filter(|item| matches!(item.data, ItemData::HunkLine { .. }))
.count(),
2,
"both content lines of the diff are still there"
);
}
#[test]
fn an_empty_diff_does_not_run_the_renderer() {
let dir = temp_dir::TempDir::new().unwrap();
let ran = dir.child("ran");
let mut config = crate::config::init_test_config().unwrap();
config.general.diff_renderer.enabled = true;
config.general.diff_renderer.command = vec![
"sh".into(),
"-c".into(),
r#"touch "$0""#.into(),
ran.to_string_lossy().into_owned(),
];
let items = create_diff_items(&config, &Default::default(), &diff_from(""), 0, false, None);
assert!(items.is_empty());
assert!(!ran.exists(), "the renderer ran");
}
#[test]
fn a_line_wrapping_to_several_rows_stays_one_line() {
let diff = diff_from(DIFF);
let row = "\x1b]1717;1;a;2;;f.rs\x1b\\part\n";
let lines = parse_ansi_lines(&format!("\x1b]1717;1\x1b\\\n{}", row.repeat(3))).lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(
hunk_line_rows(&items),
vec![(2, false), (3, true), (3, true)]
);
}
#[test]
fn consecutive_rows_for_different_lines_are_both_selectable() {
let diff = diff_from(DIFF);
let lines = parse_ansi_lines(
"\x1b]1717;1\x1b\\\n\
\x1b]1717;1;a;2;;f.rs\x1b\\+added\n\
\x1b]1717;1;c;1;;f.rs\x1b\\ keep\n",
)
.lines;
let items = rendered_diff_items(&diff, &lines, 0, false, None, false);
assert_eq!(hunk_line_rows(&items), vec![(2, false), (2, true)]);
}
}