use std::collections::HashSet;
use diffler_core::model::{DiffLine, DiffModel, FileDiff, Hunk, LineKind};
use diffler_core::review::Review;
use super::rows::{line_row_text, thread_folds};
use super::{DiffRow, DiffView, RowCopy, SplitRow};
#[derive(Debug, Clone)]
pub(crate) struct FoldGroup {
pub keys: Vec<String>,
pub hunk: usize,
pub lines: Vec<(usize, usize)>,
pub label: String,
}
pub(crate) fn hunk_key(hunk: &Hunk) -> String {
hunk.id.0.clone()
}
pub(crate) fn fold_label(hunk: &Hunk, comments: usize) -> String {
let added = hunk
.lines
.iter()
.filter(|l| l.kind == LineKind::Added)
.count();
let deleted = hunk
.lines
.iter()
.filter(|l| l.kind == LineKind::Deleted)
.count();
let ranges = format!(
"@@ -{},{} +{},{} @@",
hunk.old_start, hunk.old_lines, hunk.new_start, hunk.new_lines
);
let heading = if hunk.context.is_empty() {
ranges
} else {
format!("{ranges} {}", hunk.context)
};
let noted = match comments {
0 => String::new(),
1 => " · 1 comment".to_owned(),
n => format!(" · {n} comments"),
};
format!(
"{heading} ⋯ {} lines +{added} -{deleted}{noted}",
hunk.lines.len()
)
}
pub(crate) fn resolve_hidden<'a>(
file: &'a FileDiff,
lines: &[(usize, usize)],
) -> Vec<((usize, usize), &'a DiffLine)> {
lines
.iter()
.filter_map(|&(hunk, line)| Some(((hunk, line), file.hunks.get(hunk)?.lines.get(line)?)))
.collect()
}
fn hidden_copy(file: &FileDiff, lines: &[(usize, usize)]) -> String {
resolve_hidden(file, lines)
.iter()
.map(|(_, diff_line)| line_row_text(diff_line))
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn apply(
rows: &[DiffRow],
copy: &[RowCopy],
model: &DiffModel,
folded: &HashSet<String>,
) -> (Vec<DiffRow>, Vec<RowCopy>, Vec<FoldGroup>) {
let mut out_rows = Vec::with_capacity(rows.len());
let mut out_copy = Vec::with_capacity(copy.len());
let mut groups = Vec::new();
let mut i = 0;
while i < rows.len() {
let (Some(row), Some(text)) = (rows.get(i), copy.get(i)) else {
break;
};
let folded_hunk = match *row {
DiffRow::Hunk { file, hunk } => model
.files
.get(file)
.and_then(|f| Some((f, f.hunks.get(hunk)?)))
.filter(|(_, h)| folded.contains(&hunk_key(h)))
.map(|(f, h)| (file, hunk, f, h)),
_ => None,
};
let Some((file_index, hunk_index, file, hunk)) = folded_hunk else {
out_rows.push(*row);
out_copy.push(text.clone());
i += 1;
continue;
};
let end = (i + 1..rows.len())
.find(|&j| matches!(rows.get(j), Some(DiffRow::Hunk { .. })))
.unwrap_or(rows.len());
let body = rows.get(i + 1..end).unwrap_or_default();
let lines: Vec<(usize, usize)> = body
.iter()
.filter_map(|row| match *row {
DiffRow::Line { hunk, line, .. } => Some((hunk, line)),
_ => None,
})
.collect();
let comments = body
.iter()
.filter(|row| matches!(row, DiffRow::Comment { line: 0, .. }))
.count();
out_rows.push(DiffRow::Fold {
file: file_index,
group: groups.len(),
});
out_copy.push(RowCopy::Text(hidden_copy(file, &lines)));
groups.push(FoldGroup {
keys: vec![hunk_key(hunk)],
hunk: hunk_index,
lines,
label: fold_label(hunk, comments),
});
for j in i + 1..end {
if let (Some(row @ DiffRow::Composer { .. }), Some(text)) = (rows.get(j), copy.get(j)) {
out_rows.push(*row);
out_copy.push(text.clone());
}
}
i = end;
}
(out_rows, out_copy, groups)
}
pub(crate) fn apply_split(
split: Vec<SplitRow>,
file: Option<&FileDiff>,
folded: &HashSet<String>,
) -> Vec<SplitRow> {
let is_folded = |hunk: usize| {
file.and_then(|f| f.hunks.get(hunk))
.is_some_and(|h| folded.contains(&hunk_key(h)))
};
let mut out = Vec::with_capacity(split.len());
let mut skipping = false;
for row in split {
match row {
SplitRow::Hunk { hunk } if is_folded(hunk) => {
skipping = true;
out.push(SplitRow::Fold { hunk });
}
SplitRow::Hunk { .. } => {
skipping = false;
out.push(row);
}
SplitRow::Composer { .. } => out.push(row),
_ if skipping => {}
_ => out.push(row),
}
}
out
}
impl DiffView {
pub(super) fn open_fold_group(&mut self, group: usize) -> bool {
let Some(keys) = self.fold_groups.get(group).map(|g| g.keys.clone()) else {
return false;
};
if let Some(folded) = self.folded.get_mut(&self.rows_path) {
for key in &keys {
folded.remove(key);
}
}
self.mark_rows_dirty();
true
}
fn hunk_of_row(&self, row: usize) -> Option<usize> {
self.rows
.get(..=row)?
.iter()
.rev()
.find_map(|row| match *row {
DiffRow::Hunk { hunk, .. } | DiffRow::Line { hunk, .. } => Some(hunk),
_ => None,
})
}
pub(crate) fn toggle_thread_at_cursor(&mut self, review: &Review) -> bool {
let Some(DiffRow::Comment { comment, .. }) = self.rows.get(self.cursor).copied() else {
return false;
};
let session = review.session_for(&self.source);
let Some(card) = session
.comments
.get(comment)
.filter(|card| thread_folds(card))
else {
return false;
};
let id = card.id.clone();
if !self.open_threads.remove(&id) {
self.open_threads.insert(id);
}
self.mark_rows_dirty();
true
}
pub(crate) fn fold_lines<'a>(
&self,
file: &'a FileDiff,
group: usize,
) -> Vec<((usize, usize), &'a DiffLine)> {
let lines = self
.fold_groups
.get(group)
.map_or(&[][..], |g| g.lines.as_slice());
resolve_hidden(file, lines)
}
pub(crate) fn fold_text(&self, file: &FileDiff, group: usize) -> String {
self.fold_lines(file, group)
.iter()
.map(|(_, diff_line)| diff_line.text.as_str())
.collect::<Vec<_>>()
.join("\n")
}
pub(crate) fn toggle_fold_at_cursor(&mut self, review: &Review) -> bool {
if let Some(DiffRow::Fold { group, .. }) = self.rows.get(self.cursor).copied() {
return self.open_fold_group(group);
}
let Some(hunk) = self.hunk_of_row(self.cursor) else {
return false;
};
let Some(key) = self
.model_for_rows(review)
.files
.get(self.selected)
.and_then(|file| file.hunks.get(hunk))
.map(hunk_key)
else {
return false;
};
if let Some(header) = self
.rows
.iter()
.position(|row| matches!(*row, DiffRow::Hunk { hunk: h, .. } if h == hunk))
{
self.cursor = header;
}
self.folded
.entry(self.rows_path.clone())
.or_default()
.insert(key);
self.mark_rows_dirty();
true
}
pub(crate) fn open_all_folds(&mut self) -> bool {
let opened = self
.folded
.remove(&self.rows_path)
.is_some_and(|folded| !folded.is_empty());
if opened {
self.mark_rows_dirty();
}
opened
}
pub(crate) fn fold_all(&mut self, review: &Review) -> bool {
let keys: HashSet<String> = self
.model_for_rows(review)
.files
.get(self.selected)
.map(|file| file.hunks.iter().map(hunk_key).collect())
.unwrap_or_default();
if keys.is_empty() {
return false;
}
self.folded.insert(self.rows_path.clone(), keys);
self.mark_rows_dirty();
true
}
}
#[cfg(test)]
mod tests {
use crossterm::event::KeyCode;
use crate::app::App;
use crate::app::diff::{DiffRow, SplitRow};
use crate::config::LoadedConfig;
use crate::test_support::{Fixture, code_key, key};
fn fixture(changed: &[usize]) -> Fixture {
let content = |edited: &[usize]| -> String {
(1..=30)
.map(|i| {
if edited.contains(&i) {
format!("LINE {i}\n")
} else {
format!("line {i}\n")
}
})
.collect()
};
let fixture = Fixture::new();
fixture.write("a.txt", &content(&[]));
fixture.commit_all("base");
fixture.write("a.txt", &content(changed));
fixture
}
fn open(fixture: &Fixture) -> App {
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.author = "reviewer".to_owned();
app.open_working_tree_file("a.txt");
let diff = app.diff.as_mut().expect("diff");
diff.focus = crate::app::diff::Pane::Diff;
diff.ensure_rows(&app.review);
app
}
fn rows(app: &App) -> Vec<DiffRow> {
app.diff.as_ref().expect("diff").rows().to_vec()
}
fn cursor_row(app: &App) -> Option<DiffRow> {
let diff = app.diff.as_ref().expect("diff");
diff.rows().get(diff.cursor).copied()
}
fn headers(app: &App) -> Vec<&'static str> {
rows(app)
.iter()
.filter_map(|row| match row {
DiffRow::Hunk { .. } => Some("open"),
DiffRow::Fold { .. } => Some("folded"),
_ => None,
})
.collect()
}
fn labels(app: &App) -> Vec<String> {
let diff = app.diff.as_ref().expect("diff");
diff.fold_groups.iter().map(|g| g.label.clone()).collect()
}
fn press(app: &mut App, keys: &str) {
for c in keys.chars() {
app.handle(key(c));
}
}
fn message(app: &App) -> String {
app.message
.as_ref()
.map(|m| m.text.clone())
.unwrap_or_default()
}
#[test]
fn nothing_starts_folded() {
let app = open(&fixture(&[5, 15, 25]));
assert_eq!(headers(&app), ["open", "open", "open"]);
}
#[test]
fn za_on_any_row_of_a_hunk_folds_it_to_its_header_and_opens_it_again() {
let mut app = open(&fixture(&[5, 15, 25]));
press(&mut app, "]j");
assert!(matches!(cursor_row(&app), Some(DiffRow::Line { .. })));
press(&mut app, "za");
assert_eq!(headers(&app), ["open", "folded", "open"]);
assert!(
matches!(cursor_row(&app), Some(DiffRow::Fold { .. })),
"the cursor lands on the folded header"
);
assert_eq!(labels(&app), ["@@ -12,7 +12,7 @@ line 11 ⋯ 8 lines +1 -1"]);
press(&mut app, "za");
assert_eq!(headers(&app), ["open", "open", "open"]);
assert!(matches!(cursor_row(&app), Some(DiffRow::Hunk { .. })));
}
#[test]
fn brackets_step_over_folded_and_open_hunks_alike() {
let mut app = open(&fixture(&[5, 15, 25]));
press(&mut app, "zM");
assert_eq!(headers(&app), ["folded", "folded", "folded"]);
app.diff.as_mut().expect("diff").cursor = 0;
press(&mut app, "]");
let diff = app.diff.as_ref().expect("diff");
assert_eq!(diff.cursor, 1, "{:?}", rows(&app));
press(&mut app, "]za");
assert_eq!(headers(&app), ["folded", "folded", "open"]);
}
#[test]
fn z_m_folds_every_hunk_and_z_r_opens_them() {
let mut app = open(&fixture(&[5, 15, 25]));
press(&mut app, "zM");
assert_eq!(headers(&app), ["folded", "folded", "folded"]);
assert_eq!(message(&app), "folded every hunk");
press(&mut app, "zR");
assert_eq!(headers(&app), ["open", "open", "open"]);
assert_eq!(message(&app), "opened every hunk");
press(&mut app, "zR");
assert_eq!(message(&app), "no hunk is folded");
}
#[test]
fn a_folded_hunk_counts_the_comments_it_hides() {
let fixture = fixture(&[5]);
let mut app = open(&fixture);
let anchor = diffler_core::session::Anchor {
file: "a.txt".to_owned(),
line: Some(5),
line_end: None,
on_old_side: false,
line_text: None,
};
app.review.session.add_comment(anchor, "reviewer", "why?");
let diff = app.diff.as_mut().expect("diff");
diff.mark_rows_dirty();
diff.ensure_rows(&app.review);
diff.cursor = 0;
press(&mut app, "za");
assert_eq!(
labels(&app),
["@@ -2,7 +2,7 @@ line 1 ⋯ 8 lines +1 -1 · 1 comment"]
);
}
#[test]
fn a_search_opens_the_folded_hunk_holding_its_match() {
let mut app = open(&fixture(&[5, 15, 25]));
press(&mut app, "zM");
app.diff.as_mut().expect("diff").cursor = 0;
press(&mut app, "/LINE 15");
app.handle(code_key(KeyCode::Enter));
assert_eq!(headers(&app), ["folded", "open", "folded"]);
}
#[test]
fn a_hunk_edited_after_folding_comes_back_open() {
let fixture = fixture(&[5, 15]);
let mut app = open(&fixture);
press(&mut app, "zM");
fixture.write(
"a.txt",
&(1..=30)
.map(|i| match i {
5 | 15 => format!("LINE {i}\n"),
14 => "edited 14\n".to_owned(),
_ => format!("line {i}\n"),
})
.collect::<String>(),
);
app.handle(crate::event::AppEvent::RepoChanged);
app.settle_refresh();
app.diff.as_mut().expect("diff").ensure_rows(&app.review);
assert_eq!(headers(&app), ["folded", "open"]);
}
#[test]
fn side_by_side_folds_the_same_hunks() {
let mut app = open(&fixture(&[5, 15]));
press(&mut app, "]za");
app.handle(key('|'));
let diff = app.diff.as_ref().expect("diff");
let kinds: Vec<&str> = diff
.split_rows
.iter()
.filter_map(|row| match row {
SplitRow::Hunk { .. } => Some("open"),
SplitRow::Fold { .. } => Some("folded"),
_ => None,
})
.collect();
assert_eq!(kinds, ["open", "folded"]);
}
}