mod comments;
mod folds;
mod nav;
mod open;
mod review;
mod rowref;
mod rows;
mod slide;
use std::borrow::Cow;
use std::collections::{BTreeSet, HashMap, HashSet};
use diffler_core::classify::{Kind, Rules};
use diffler_core::highlight::StyledRange;
use diffler_core::model::{DiffLine, DiffModel, FileDiff};
use diffler_core::review::Review;
use diffler_core::session::Session;
use diffler_core::source::ReviewSource;
use diffler_core::syntax::ScopeIndex;
use diffler_core::walkthrough::Located;
use super::composer::{Composer, ComposerKind};
use super::rowsel::{RowSelect, RowText};
use super::{App, Flow};
pub use comments::{CommentFacts, CommentGrouping, CommentPaneRow, group_comment_rows};
pub(crate) use rowref::RowPositions;
pub use rows::{
CommentLine, DiffRow, RowCopy, SplitRow, SplitSide, blocks_of, comment_display, summary_display,
};
use rows::{build_rows, build_split_rows};
#[cfg(test)]
pub(crate) use slide::merge_count;
use slide::stop_file_index;
pub(crate) use slide::{Slide, stop_row_offset};
use crate::app::walkthrough::FigureCache;
use crate::config::FileLayout;
use crate::tree::{self, Bucket, TreeNode, TreeRow};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
List,
Diff,
Comments,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollAlign {
Center,
Top,
Bottom,
}
#[derive(Debug, Clone)]
pub(crate) struct BucketFolds(BTreeSet<Bucket>);
impl Default for BucketFolds {
fn default() -> Self {
Self(
[
Bucket::Viewed,
Bucket::Kind(Kind::Generated),
Bucket::Kind(Kind::Assets),
]
.into(),
)
}
}
impl BucketFolds {
fn is_folded(&self, bucket: Bucket) -> bool {
self.0.contains(&bucket)
}
fn toggle_fold(&mut self, bucket: Bucket) {
if !self.0.remove(&bucket) {
self.0.insert(bucket);
}
}
fn unfold(&mut self, bucket: Bucket) -> bool {
self.0.remove(&bucket)
}
}
#[derive(Debug, Clone)]
pub struct FileHighlights {
pub hash: String,
pub old: Vec<Vec<StyledRange>>,
pub new: Vec<Vec<StyledRange>>,
}
#[derive(Debug)]
pub struct FileScope {
pub hash: String,
pub index: ScopeIndex,
}
#[allow(clippy::struct_excessive_bools)] pub struct DiffView {
pub source: ReviewSource,
pub(crate) commit_model: Option<DiffModel>,
pub focus: Pane,
pub(crate) layout: FileLayout,
pub selected: usize,
pub(crate) folded_dirs: BTreeSet<String>,
pub(crate) bucket_folds: BucketFolds,
pub(crate) rules: Rules,
pub(crate) declared: HashMap<String, Kind>,
pub(crate) tree_cursor: usize,
pub cursor: usize,
pub(crate) referenced: Option<(usize, usize)>,
pub(crate) slide: Option<Slide>,
pub(crate) walkthrough_built: Option<(String, u64)>,
pub(crate) figures: FigureCache,
figures_dirty: bool,
pub(crate) unresolved_anchors: HashMap<String, Located>,
pub(crate) pin_broken: bool,
pub scroll: usize,
pub(crate) scroll_align: Option<ScrollAlign>,
pub side_by_side: bool,
pub(crate) split_scroll: usize,
pub(crate) sidebar: ratatui::layout::Rect,
pub(crate) sidebar_scroll: usize,
pub(crate) pane: ratatui::layout::Rect,
pub(crate) comments_open: bool,
pub(crate) comments_cursor: usize,
pub(crate) comments_scroll: usize,
pub(crate) comments_rect: ratatui::layout::Rect,
pub(crate) comment_lines: Vec<Option<usize>>,
pub(crate) comment_grouping: CommentGrouping,
pub(crate) comment_folds: BTreeSet<String>,
pub visual_anchor: Option<usize>,
pub(crate) viewport: u16,
pub(crate) composer: Option<Composer>,
pub(crate) rows: Vec<DiffRow>,
pub(crate) row_copy: Vec<RowCopy>,
pub(crate) line_rows: Vec<Option<usize>>,
rows_dirty: bool,
pub(crate) wrap_width: u16,
wrap_dirty: bool,
pub(crate) split_rows: Vec<SplitRow>,
pub(crate) highlights: HashMap<String, FileHighlights>,
pub(crate) scopes: HashMap<String, FileScope>,
enriched: HashSet<String>,
pub(crate) context: HashMap<String, u32>,
pub(crate) context_files: Vec<FileDiff>,
pub(crate) merged_model: Option<DiffModel>,
pub(crate) image_preview: Option<crate::app::image::ImagePreview>,
pub(crate) image_want: Option<crate::app::image::ImageKey>,
pub(crate) fold_groups: Vec<folds::FoldGroup>,
folded: HashMap<String, HashSet<String>>,
rows_path: String,
}
impl DiffView {
fn new(
source: ReviewSource,
commit_model: Option<DiffModel>,
review: &Review,
layout: FileLayout,
rules: Rules,
side_by_side: bool,
) -> Self {
let mut view = Self {
composer: None,
source,
commit_model,
focus: Pane::List,
layout,
selected: 0,
folded_dirs: BTreeSet::new(),
bucket_folds: BucketFolds::default(),
rules,
declared: HashMap::new(),
tree_cursor: 0,
cursor: 0,
referenced: None,
slide: None,
walkthrough_built: None,
figures: FigureCache::new(),
figures_dirty: false,
unresolved_anchors: HashMap::new(),
pin_broken: false,
scroll: 0,
scroll_align: None,
side_by_side,
split_scroll: 0,
sidebar: ratatui::layout::Rect::default(),
sidebar_scroll: 0,
pane: ratatui::layout::Rect::default(),
comments_open: false,
comments_cursor: 0,
comments_scroll: 0,
comments_rect: ratatui::layout::Rect::default(),
comment_lines: Vec::new(),
comment_grouping: CommentGrouping::Flat,
comment_folds: BTreeSet::from([comments::RESOLVED_FOLD_KEY.to_owned()]),
visual_anchor: None,
viewport: 0,
rows: Vec::new(),
row_copy: Vec::new(),
line_rows: Vec::new(),
rows_dirty: true,
wrap_width: u16::MAX,
wrap_dirty: false,
split_rows: Vec::new(),
highlights: HashMap::new(),
scopes: HashMap::new(),
enriched: HashSet::new(),
context: HashMap::new(),
context_files: Vec::new(),
merged_model: None,
image_preview: None,
image_want: None,
fold_groups: Vec::new(),
folded: HashMap::new(),
rows_path: String::new(),
};
view.ensure_rows(review);
view
}
pub fn model<'a>(&'a self, review: &'a Review) -> &'a DiffModel {
self.commit_model.as_ref().unwrap_or_else(|| review.model())
}
pub(crate) fn model_for_layout<'a>(
layout: FileLayout,
base: &'a DiffModel,
context_files: &'a [FileDiff],
) -> Cow<'a, DiffModel> {
let context: &[FileDiff] = if layout == FileLayout::Walkthrough {
context_files
} else {
&[]
};
Self::model_with_context(base, context)
}
pub(crate) fn rendered_model<'a>(
cached: Option<&'a DiffModel>,
base: &'a DiffModel,
) -> &'a DiffModel {
cached.unwrap_or(base)
}
pub(crate) fn model_for_rows<'a>(&'a self, review: &'a Review) -> Cow<'a, DiffModel> {
Self::model_for_layout(self.layout, self.model(review), &self.context_files)
}
pub(crate) fn is_enriched(&self, path: &str) -> bool {
self.enriched.contains(path)
}
pub(crate) fn mark_enriched(&mut self, path: &str) {
self.enriched.insert(path.to_owned());
}
pub(crate) fn clear_enriched(&mut self) {
self.enriched.clear();
}
pub fn rows(&self) -> &[DiffRow] {
&self.rows
}
pub(crate) fn split_cursor(&self, split: &[SplitRow]) -> (usize, Option<SplitSide>) {
let Some(row) = self.rows.get(self.cursor) else {
return (0, None);
};
match *row {
DiffRow::Hunk { hunk, .. } => (
split
.iter()
.position(|r| matches!(r, SplitRow::Hunk { hunk: h } if *h == hunk))
.unwrap_or(0),
None,
),
DiffRow::Line { hunk, line, .. } => {
for (i, r) in split.iter().enumerate() {
if let SplitRow::Pair {
hunk: h,
left,
right,
} = r
&& *h == hunk
{
if *left == Some(line) && *right == Some(line) {
return (i, None);
}
if *left == Some(line) {
return (i, Some(SplitSide::Left));
}
if *right == Some(line) {
return (i, Some(SplitSide::Right));
}
}
}
(0, None)
}
DiffRow::Comment { comment, line, .. } => (
split
.iter()
.position(|r| {
matches!(r, SplitRow::Comment { comment: c, line: l, .. } if *c == comment && *l == line)
})
.unwrap_or(0),
None,
),
DiffRow::Composer { line } => (
split
.iter()
.position(|r| matches!(r, SplitRow::Composer { line: l } if *l == line))
.unwrap_or(0),
None,
),
DiffRow::Summary { .. } => (0, None),
DiffRow::Fold { group, .. } => {
let hunk = self.fold_groups.get(group).map(|g| g.hunk);
(
split
.iter()
.position(|r| matches!(r, SplitRow::Fold { hunk: h } if Some(*h) == hunk))
.unwrap_or(0),
None,
)
}
}
}
pub fn selected_path(&self, review: &Review) -> Option<String> {
self.model_for_rows(review)
.files
.get(self.selected)
.map(|f| f.path.clone())
}
pub(crate) fn mark_rows_dirty(&mut self) {
self.rows_dirty = true;
}
pub(crate) fn invalidate(&mut self) {
self.mark_rows_dirty();
self.enriched.clear();
}
pub(crate) fn set_wrap_width(&mut self, width: u16) {
if width > 0 && self.wrap_width != width {
self.wrap_width = width;
self.mark_reflow();
}
}
pub(crate) fn mark_reflow(&mut self) {
self.wrap_dirty = true;
}
pub(crate) fn ensure_rows(&mut self, review: &Review) {
if !self.rows_dirty && !self.wrap_dirty {
return;
}
let session = review.session_for(&self.source);
self.ensure_figures(session);
if self.layout == FileLayout::Walkthrough {
self.validate_slide(session);
}
let base = self.commit_model.as_ref().unwrap_or_else(|| review.model());
let model_cow = Self::model_for_layout(self.layout, base, &self.context_files);
let model: &DiffModel = &model_cow;
self.selected = self.selected.min(model.files.len().saturating_sub(1));
let composer = self.composer.as_ref();
let positions = self.capture_positions(review);
let (rows, copy) = build_rows(
model,
session,
self.selected,
self.wrap_width,
composer,
&self.figures,
&self.unresolved_anchors,
);
let (rows, copy) = if self.layout == FileLayout::Walkthrough {
self.window_slide(model, session, rows, copy)
} else {
(rows, copy)
};
let path = model
.files
.get(self.selected)
.map(|f| f.path.clone())
.unwrap_or_default();
let none_folded = HashSet::new();
let folded = self.folded.get(&path).unwrap_or(&none_folded);
let (rows, copy, fold_groups) = folds::apply(&rows, ©, model, folded);
self.rows = rows;
self.row_copy = copy;
let split_rows = build_split_rows(
model,
session,
self.selected,
self.wrap_width,
composer,
&self.figures,
&self.unresolved_anchors,
);
self.split_rows = folds::apply_split(split_rows, model.files.get(self.selected), folded);
self.fold_groups = fold_groups;
self.rows_path = path;
let tree_rows = self.rows_dirty.then(|| self.tree_rows(model, session));
self.merged_model = match model_cow {
Cow::Borrowed(_) => None,
Cow::Owned(model) => Some(model),
};
self.restore_positions(review, positions);
if let Some(tree_rows) = tree_rows {
self.reseat_tree_cursor(&tree_rows);
}
self.rows_dirty = false;
self.wrap_dirty = false;
self.seat_cursor_on_caret();
}
fn seat_cursor_on_caret(&mut self) {
let Some(composer) = self.composer.as_ref() else {
return;
};
let caret = composer.caret_line(self.wrap_width);
if let Some(row) = self
.rows
.iter()
.position(|row| matches!(row, DiffRow::Composer { line } if *line == caret))
{
self.cursor = row;
}
}
pub(crate) fn reveal_selected(&mut self, review: &Review) {
let model = self.model_for_rows(review);
let Some(file) = model.files.get(self.selected) else {
return;
};
let path = file.path.clone();
let viewed = review
.session_for(&self.source)
.is_viewed(&path, &file.content_hash());
let revealed = match self.layout {
FileLayout::Review => {
let bucket = if viewed {
Bucket::Viewed
} else {
Bucket::ToReview
};
self.bucket_folds.unfold(bucket)
}
FileLayout::Kinds => {
let bucket = Bucket::Kind(self.kind_of(&path));
self.bucket_folds.unfold(bucket)
}
FileLayout::Walkthrough => false,
FileLayout::Tree | FileLayout::List => {
let hidden_by = |dir: &String| path.starts_with(&format!("{dir}/"));
let hidden = self.folded_dirs.iter().any(hidden_by);
if hidden {
self.folded_dirs.retain(|dir| !hidden_by(dir));
}
hidden
}
};
self.rows_dirty |= revealed;
}
pub(crate) fn reseat_tree_cursor(&mut self, rows: &[TreeRow]) {
self.tree_cursor = tree_position_of_file(rows, self.selected)
.unwrap_or_else(|| self.tree_cursor.min(rows.len().saturating_sub(1)));
}
fn select(&mut self, selected: usize, review: &Review) {
if self.selected == selected {
return;
}
self.selected = selected;
self.cursor = 0;
self.scroll = 0;
self.visual_anchor = None;
self.referenced = None;
self.rows_dirty = true;
self.ensure_rows(review);
}
pub(crate) fn seat_on(
&mut self,
review: &Review,
file_index: usize,
matches: impl Fn(&DiffRow) -> bool,
) -> Option<(usize, usize)> {
self.select(file_index, review);
self.reveal_selected(review);
self.ensure_rows(review);
let first = self.rows.iter().position(&matches)?;
let last = self.rows.iter().rposition(&matches).unwrap_or(first);
self.cursor = first;
Some((first, last))
}
pub(crate) fn span_rows(
&self,
review: &Review,
file_index: usize,
line: u32,
end: u32,
) -> Option<(usize, usize)> {
let model = self.model_for_rows(review);
let hunks = &model.files.get(file_index)?.hunks;
let covered = |row: &DiffRow| {
let DiffRow::Line { hunk, line: at, .. } = *row else {
return false;
};
hunks
.get(hunk)
.and_then(|hunk| hunk.lines.get(at))
.and_then(|diff_line| diff_line.new_no)
.is_some_and(|no| line <= no && no <= end)
};
let first = self.rows.iter().position(covered)?;
let last = self.rows.iter().rposition(covered).unwrap_or(first);
Some((first, last))
}
pub(crate) fn tree_rows(&self, model: &DiffModel, session: &Session) -> Vec<TreeRow> {
match self.layout {
FileLayout::Review => self.section_rows(model, Self::review_groups(model, session)),
FileLayout::Kinds => self.section_rows(model, self.kind_groups(model, session)),
FileLayout::Walkthrough => match self.active_walkthrough(session) {
Some(walkthrough) => walkthrough
.summary
.is_some()
.then_some(TreeRow {
depth: 0,
node: TreeNode::WalkthroughSummary,
})
.into_iter()
.chain((0..walkthrough.stops.len()).map(|index| TreeRow {
depth: 0,
node: TreeNode::Stop { index },
}))
.collect(),
None => {
tree::visible_rows_promoting(&Self::paths(model), &self.folded_dirs, &|index| {
Self::is_viewed(model, session, index)
})
}
},
FileLayout::Tree | FileLayout::List => {
tree::visible_rows_promoting(&Self::paths(model), &self.folded_dirs, &|index| {
Self::is_viewed(model, session, index)
})
}
}
}
pub(crate) fn display_order(&self, model: &DiffModel, session: &Session) -> Vec<usize> {
match self.layout {
FileLayout::Review => Self::review_groups(model, session),
FileLayout::Kinds => self.kind_groups(model, session),
FileLayout::Walkthrough if self.active_walkthrough(session).is_some() => {
let stops = self
.active_walkthrough(session)
.into_iter()
.flat_map(|walkthrough| &walkthrough.stops);
let mut seen = HashSet::new();
return stops
.filter_map(|id| stop_file_index(model, session, id))
.filter(|index| seen.insert(*index))
.collect();
}
FileLayout::Walkthrough | FileLayout::Tree | FileLayout::List => {
let rows =
tree::visible_rows_promoting(&Self::paths(model), &BTreeSet::new(), &|index| {
Self::is_viewed(model, session, index)
});
return rows.iter().filter_map(row_file_index).collect();
}
}
.into_iter()
.flat_map(|(_, indices)| indices)
.collect()
}
fn paths(model: &DiffModel) -> Vec<&str> {
model.files.iter().map(|file| file.path.as_str()).collect()
}
fn is_viewed(model: &DiffModel, session: &Session, index: usize) -> bool {
model
.files
.get(index)
.is_some_and(|file| session.is_viewed(&file.path, &file.content_hash()))
}
fn viewed_first(model: &DiffModel, session: &Session, indices: &mut [usize]) {
indices.sort_by_key(|&index| !Self::is_viewed(model, session, index));
}
fn section_rows(
&self,
model: &DiffModel,
groups: impl IntoIterator<Item = (Bucket, Vec<usize>)>,
) -> Vec<TreeRow> {
let mut rows = Vec::new();
for (bucket, indices) in groups {
let folded = self.bucket_folds.is_folded(bucket);
rows.push(TreeRow {
depth: 0,
node: TreeNode::Section {
bucket,
count: indices.len(),
folded,
},
});
if folded {
continue;
}
rows.extend(indices.into_iter().filter_map(|index| {
let file = model.files.get(index)?;
Some(TreeRow {
depth: 1,
node: TreeNode::File {
index,
name: file.path.clone(),
},
})
}));
}
rows
}
fn review_groups(model: &DiffModel, session: &Session) -> Vec<(Bucket, Vec<usize>)> {
let (mut fresh, mut viewed) = (Vec::new(), Vec::new());
for index in 0..model.files.len() {
if Self::is_viewed(model, session, index) {
viewed.push(index);
} else {
fresh.push(index);
}
}
vec![(Bucket::ToReview, fresh), (Bucket::Viewed, viewed)]
}
fn kind_groups(&self, model: &DiffModel, session: &Session) -> Vec<(Bucket, Vec<usize>)> {
let mut grouped: HashMap<Kind, Vec<usize>> = HashMap::new();
for (index, file) in model.files.iter().enumerate() {
grouped
.entry(self.kind_of(&file.path))
.or_default()
.push(index);
}
Kind::ALL
.into_iter()
.filter_map(|kind| {
let mut indices = grouped.remove(&kind)?;
Self::viewed_first(model, session, &mut indices);
Some((Bucket::Kind(kind), indices))
})
.collect()
}
pub(crate) fn bucket_files(
&self,
model: &DiffModel,
session: &Session,
bucket: Bucket,
) -> Vec<usize> {
let groups = match bucket {
Bucket::Kind(_) => self.kind_groups(model, session),
Bucket::ToReview | Bucket::Viewed => Self::review_groups(model, session),
};
groups
.into_iter()
.find(|(known, _)| *known == bucket)
.map_or_else(Vec::new, |(_, indices)| indices)
}
pub(crate) fn kind_of(&self, path: &str) -> Kind {
self.rules.kind(path, self.declared.get(path).copied())
}
fn declared_targets(&self, review: &Review) -> Vec<String> {
if self.layout != FileLayout::Kinds {
return Vec::new();
}
self.model(review)
.files
.iter()
.map(|file| file.path.clone())
.collect()
}
pub(crate) fn cycle_layout(&mut self, has_walkthrough: bool, empty: bool) -> FileLayout {
self.layout = match self.layout {
FileLayout::Review => FileLayout::Kinds,
FileLayout::Kinds if has_walkthrough => FileLayout::Walkthrough,
FileLayout::Walkthrough if empty && has_walkthrough => FileLayout::Walkthrough,
FileLayout::Kinds | FileLayout::Walkthrough => FileLayout::Tree,
FileLayout::Tree | FileLayout::List => FileLayout::Review,
};
self.layout
}
pub(crate) fn rows_show(&self, path: &str) -> bool {
self.rows_path == path
}
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 reveal_line(
&mut self,
review: &Review,
(hunk, line): (usize, usize),
) -> Option<usize> {
let hiding = self
.fold_groups
.iter()
.position(|group| group.lines.contains(&(hunk, line)));
if let Some(group) = hiding {
self.open_fold_group(group);
self.ensure_rows(review);
}
let at = self.rows.iter().position(
|row| matches!(*row, DiffRow::Line { hunk: h, line: l, .. } if h == hunk && l == line),
)?;
self.cursor = at;
Some(at)
}
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());
folds::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 row_search_text(&self, file: Option<&FileDiff>, row: &DiffRow) -> Option<String> {
match *row {
DiffRow::Line { hunk, line, .. } => {
Some(file?.hunks.get(hunk)?.lines.get(line)?.text.clone())
}
DiffRow::Fold { group, .. } => Some(self.fold_text(file?, group)),
_ => None,
}
}
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(folds::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(folds::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
}
}
fn sidebar_rows(diff: &DiffView, review: &Review) -> Vec<TreeRow> {
diff.tree_rows(diff.model(review), review.session_for(&diff.source))
}
fn next_unviewed_index(diff: &DiffView, review: &Review, wrap: bool) -> Option<usize> {
let model = diff.model(review);
let session = review.session_for(&diff.source);
let order = diff.display_order(model, session);
let count = order.len();
let at = order.iter().position(|&index| index == diff.selected)?;
(1..=count)
.map(|step| at + step)
.take_while(|&position| wrap || position < count)
.filter_map(|position| order.get(position % count).copied())
.find(|&index| !DiffView::is_viewed(model, session, index))
}
fn row_file_index(row: &TreeRow) -> Option<usize> {
match row.node {
TreeNode::File { index, .. } => Some(index),
TreeNode::Dir { .. }
| TreeNode::Section { .. }
| TreeNode::Stop { .. }
| TreeNode::WalkthroughSummary => None,
}
}
fn tree_position_of_file(rows: &[TreeRow], file_index: usize) -> Option<usize> {
rows.iter()
.position(|row| row_file_index(row) == Some(file_index))
}
fn step_file_row(rows: &[TreeRow], at: usize, forward: bool) -> Option<usize> {
crate::app::step_to(rows, at, forward, |row| {
row_file_index(row).is_some() || matches!(row.node, TreeNode::Stop { .. })
})
}
impl RowSelect for DiffView {
fn cursor(&self) -> usize {
self.cursor
}
fn anchor(&self) -> Option<usize> {
self.visual_anchor
}
fn set_anchor(&mut self, anchor: Option<usize>) {
self.visual_anchor = anchor;
}
}
impl RowText for DiffView {
fn row_count(&self) -> usize {
self.rows.len()
}
fn row_text(&self, row: usize) -> String {
self.row_copy
.get(row)
.map(RowCopy::text)
.unwrap_or_default()
}
}
#[derive(Debug)]
pub struct RediffRequest {
pub about: Option<ReviewSource>,
pub pr_head: Option<(String, String)>,
pub token: u64,
}
impl RediffRequest {
pub fn run(
&self,
repo_root: &std::path::Path,
settings: &diffler_core::diffalgo::DiffSettings,
) -> Result<diffler_core::review::Refreshed, String> {
let against = match &self.about {
Some(ReviewSource::Against { rev }) => Some(rev.as_str()),
_ => None,
};
let pr_head = self
.pr_head
.as_ref()
.map(|(base, head)| (base.as_str(), head.as_str()));
let pinned = self
.about
.as_ref()
.filter(|about| {
matches!(
about,
ReviewSource::Commit { .. }
| ReviewSource::Range { .. }
| ReviewSource::Pr { .. }
)
})
.map(|source| (source, pr_head));
Review::compute_refresh(repo_root, settings, against, pinned).map_err(|err| err.to_string())
}
}
#[derive(Debug, Clone)]
pub struct DeclaredRequest {
pub paths: Vec<String>,
pub token: u64,
}
impl App {
pub(crate) fn queue_declared(&mut self) {
let paths = self
.diff
.as_ref()
.map(|diff| diff.declared_targets(&self.review))
.unwrap_or_default();
self.declared_token += 1;
let token = self.declared_token;
self.pending_declared = (!paths.is_empty()).then_some(DeclaredRequest { paths, token });
}
pub(crate) fn on_declared_kinds(&mut self, kinds: HashMap<String, Kind>, token: u64) -> Flow {
let Some(diff) = self.diff.as_mut().filter(|_| token == self.declared_token) else {
return Flow::Idle;
};
if diff.declared == kinds {
return Flow::Idle;
}
diff.declared = kinds;
diff.invalidate();
diff.ensure_rows(&self.review);
Flow::Continue
}
}
#[cfg(test)]
mod tests {
use diffler_core::feedback::{self, FeedbackOptions};
use diffler_core::model::LineKind;
use diffler_core::session::{Anchor, CommentStatus};
use unicode_width::UnicodeWidthStr;
use crate::app::markdown::MdSpan;
use super::*;
use crate::app::{App, Modal, Screen};
use crate::config::LoadedConfig;
use crate::event::AppEvent;
use crate::test_support::{
Fixture, big_file_fixture, code_key, ctrl_key, huge_span_fixture, key, settle_submit,
standard_fixture, two_hunk_fixture,
};
fn diff_app(fixture: &Fixture) -> App {
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.author = "reviewer".to_owned();
app.open_working_tree_diff(None);
app
}
fn diff_app_with_layout(fixture: &Fixture, layout: crate::config::FileLayout) -> App {
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = layout;
let mut app = App::new(fixture.review(), loaded);
app.author = "reviewer".to_owned();
app.open_working_tree_diff(None);
app
}
fn rows(app: &App) -> Vec<DiffRow> {
app.diff.as_ref().expect("diff view").rows().to_vec()
}
fn focus(app: &App) -> Pane {
app.diff.as_ref().expect("diff view").focus
}
fn selected_path(app: &App) -> String {
app.diff
.as_ref()
.expect("diff view")
.selected_path(&app.review)
.expect("selected file")
}
fn tree_cursor(app: &App) -> usize {
app.diff.as_ref().expect("diff view").tree_cursor
}
fn tree_row_count(app: &App) -> usize {
let diff = app.diff.as_ref().expect("diff view");
sidebar_rows(diff, &app.review).len()
}
fn tree_kinds(app: &App) -> Vec<String> {
let diff = app.diff.as_ref().expect("diff view");
sidebar_rows(diff, &app.review)
.iter()
.map(|row| match &row.node {
crate::tree::TreeNode::Dir { .. } => "dir".to_owned(),
crate::tree::TreeNode::File { name, .. } => format!("file:{name}"),
crate::tree::TreeNode::Section { bucket, count, .. } => {
format!("section:{}:{count}", bucket.label())
}
crate::tree::TreeNode::Stop { index } => format!("stop:{index}"),
crate::tree::TreeNode::WalkthroughSummary => "walkthrough_summary".to_owned(),
})
.collect()
}
fn enter_diff_pane(app: &mut App) {
app.diff.as_mut().expect("diff view").focus = Pane::Diff;
}
fn cursor_to_line(app: &mut App, pred: impl Fn(&DiffRow) -> bool) {
let position = rows(app).iter().position(pred).expect("row present");
app.diff.as_mut().unwrap().cursor = position;
}
fn select_file(app: &mut App, path: &str) {
let index = app
.diff
.as_ref()
.unwrap()
.model(&app.review)
.files
.iter()
.position(|f| f.path == path)
.expect("file present");
app.diff_select_file_index(index);
enter_diff_pane(app);
}
fn added_line_position(app: &App) -> usize {
let diff = app.diff.as_ref().unwrap();
let model = diff.model(&app.review);
rows(app)
.iter()
.position(|row| {
let DiffRow::Line { file, hunk, line } = row else {
return false;
};
model.files.get(*file).is_some_and(|f| {
f.path == "src/lib.rs"
&& f.hunks.get(*hunk).is_some_and(|h| {
h.lines
.get(*line)
.is_some_and(|l| l.new_no.is_some() && l.text.contains("42"))
})
})
})
.expect("added line present")
}
fn type_text(app: &mut App, text: &str) {
for c in text.chars() {
app.handle(key(c));
}
}
fn caret_row(app: &App) -> usize {
let diff = app.diff.as_ref().unwrap();
let composer = diff.composer.as_ref().expect("composer open");
let caret = composer.caret_line(diff.wrap_width);
diff.rows()
.iter()
.position(|row| matches!(row, DiffRow::Composer { line } if *line == caret))
.expect("the caret's own row")
}
#[test]
fn opening_a_review_with_no_files_says_so_and_opens_nothing() {
let fixture = Fixture::new();
fixture.write("a.rs", "fn a() {}\n");
fixture.commit_all("base");
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('D'));
assert!(app.diff.is_none(), "no review opened over a clean tree");
assert_eq!(app.screen(), Screen::Status);
assert_eq!(
app.message.as_ref().map(|m| m.text.as_str()),
Some("nothing to review: working tree clean")
);
fixture.commit_all("nothing");
let head = app.review.vcs.resolve("HEAD").expect("head oid");
app.open_commit_diff(&head);
assert!(app.diff.is_none(), "no review opened over an empty commit");
let message = app.message.as_ref().expect("message").text.clone();
assert!(
message.starts_with("nothing to review in commit"),
"the source names itself: {message}"
);
}
#[test]
fn rows_flatten_the_selected_files_hunks_and_lines_in_order() {
let fixture = two_hunk_fixture();
let app = diff_app(&fixture);
let rows = rows(&app);
assert!(matches!(rows.first(), Some(DiffRow::Hunk { hunk: 0, .. })));
let hunks: Vec<usize> = rows
.iter()
.filter_map(|r| match r {
DiffRow::Hunk { hunk, .. } => Some(*hunk),
_ => None,
})
.collect();
assert_eq!(hunks, vec![0, 1], "both hunks flattened in order");
assert!(
rows.iter()
.any(|r| matches!(r, DiffRow::Line { hunk: 1, .. })),
"second hunk has line rows"
);
}
#[test]
fn open_starts_on_the_sidebar_at_the_first_file() {
let fixture = standard_fixture();
let app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
assert_eq!(app.diff.as_ref().unwrap().selected, 0);
}
#[test]
fn list_jk_moves_over_tree_rows_and_files_under_the_cursor_become_selected() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(
tree_kinds(&app),
vec![
"dir".to_owned(),
"file:lib.rs".to_owned(),
"file:ci.yml".to_owned(),
"file:todo.md".to_owned(),
]
);
assert_eq!(tree_cursor(&app), 2);
assert_eq!(selected_path(&app), "ci.yml");
app.handle(key('g'));
app.handle(key('g'));
assert_eq!(tree_cursor(&app), 0);
assert_eq!(selected_path(&app), "ci.yml", "a dir row keeps the pane");
enter_diff_pane(&mut app);
app.handle(key('j'));
assert!(app.diff.as_ref().unwrap().cursor > 0);
app.diff.as_mut().unwrap().focus = Pane::List;
app.handle(key('j'));
assert_eq!(tree_cursor(&app), 1);
assert_eq!(selected_path(&app), "src/lib.rs");
assert_eq!(app.diff.as_ref().unwrap().cursor, 0, "diff cursor reset");
assert_eq!(app.diff.as_ref().unwrap().scroll, 0);
app.handle(key('j'));
assert_eq!(selected_path(&app), "ci.yml");
app.handle(key('k'));
assert_eq!(selected_path(&app), "src/lib.rs");
}
#[test]
fn the_default_sidebar_layout_is_a_tree_with_dir_rows() {
let fixture = standard_fixture();
let app = diff_app(&fixture);
assert_eq!(
tree_kinds(&app),
vec![
"dir".to_owned(),
"file:lib.rs".to_owned(),
"file:ci.yml".to_owned(),
"file:todo.md".to_owned(),
]
);
}
#[test]
fn a_walkthrough_with_no_summary_lists_only_its_stops() {
let fixture = standard_fixture();
let app = walkthrough_app(&fixture);
assert_eq!(
tree_kinds(&app),
vec![
"stop:0".to_owned(),
"stop:1".to_owned(),
"stop:2".to_owned()
]
);
}
#[test]
fn a_walkthrough_with_a_summary_lists_it_as_the_leading_row() {
let fixture = standard_fixture();
let app = walkthrough_app_with_summary(&fixture);
assert_eq!(
tree_kinds(&app),
vec![
"walkthrough_summary".to_owned(),
"stop:0".to_owned(),
"stop:1".to_owned(),
"stop:2".to_owned()
]
);
}
#[test]
fn the_leading_summary_row_selects_the_summary_slide() {
let fixture = standard_fixture();
let mut app = walkthrough_app_with_summary(&fixture);
assert_eq!(
tree_cursor(&app),
1,
"starts on the first stop, one past the summary"
);
app.diff_tree_to(0);
assert_eq!(
app.diff.as_ref().expect("diff view").slide,
Some(Slide::Summary)
);
assert_eq!(tree_cursor(&app), 0);
}
#[test]
fn the_summary_slide_shows_its_card_and_no_diff_rows() {
let fixture = standard_fixture();
let mut app = walkthrough_app_with_summary(&fixture);
app.diff_tree_to(0);
assert!(
rows(&app)
.iter()
.all(|row| matches!(row, DiffRow::Summary { .. })),
"{:?}",
rows(&app)
);
assert!(!rows(&app).is_empty());
}
#[test]
fn a_mermaid_fence_in_the_summary_becomes_a_figure() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
crate::test_support::set_walkthrough_summary(
&mut app,
"w1",
"why\n\n```mermaid\nflowchart LR\n a --> b\n```\n",
);
app.diff.as_mut().unwrap().invalidate();
app.seat_summary();
let diff = app.diff.as_ref().expect("diff view");
let key = crate::app::walkthrough::summary_figure_key("w1");
assert!(
diff.figures.get(&key).is_some_and(|cached| cached
.blocks
.iter()
.any(|block| matches!(block, crate::app::walkthrough::Block::Figure(_)))),
"the fence parsed into a figure block"
);
}
#[test]
fn the_walkthrough_layout_orders_files_by_their_stops() {
let fixture = standard_fixture();
let app = walkthrough_app(&fixture);
let diff = app.diff.as_ref().expect("diff view");
let model = diff.model(&app.review);
let session = app.review.session_for(&diff.source);
let paths: Vec<&str> = diff
.display_order(model, session)
.into_iter()
.filter_map(|index| model.files.get(index))
.map(|file| file.path.as_str())
.collect();
assert_eq!(paths, ["todo.md", "src/lib.rs"]);
}
#[test]
fn t_cycles_into_the_walkthrough_only_on_its_own_source() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
seat_walkthrough(&mut app);
for _ in 0..3 {
app.handle(key('t'));
}
assert_eq!(
layout_of(&app),
crate::config::FileLayout::Tree,
"a walkthrough elsewhere never enters this source's cycle"
);
app.open_walkthrough("w1", Slide::Stop(0));
let layouts: Vec<crate::config::FileLayout> = (0..4)
.map(|_| {
app.handle(key('t'));
layout_of(&app)
})
.collect();
assert_eq!(
layouts,
[
crate::config::FileLayout::Tree,
crate::config::FileLayout::Review,
crate::config::FileLayout::Kinds,
crate::config::FileLayout::Walkthrough,
]
);
}
#[test]
fn t_stays_on_the_walkthrough_when_the_diff_has_no_files_of_its_own() {
let fixture = Fixture::new();
fixture.write("README.md", "hello\n");
fixture.commit_all("initial commit");
let mut app = App::new(fixture.review(), LoadedConfig::default());
crate::test_support::seat_walkthrough(
&mut app,
"tour",
&[("Base", Some("README.md:1"), "why hello")],
);
app.open_walkthrough("w1", Slide::Stop(0));
assert_eq!(layout_of(&app), crate::config::FileLayout::Walkthrough);
app.handle(key('t'));
assert_eq!(
layout_of(&app),
crate::config::FileLayout::Walkthrough,
"tree/review/kinds would list nothing; t stays put"
);
}
fn layout_of(app: &App) -> crate::config::FileLayout {
app.diff.as_ref().expect("diff view").layout
}
#[test]
fn cycling_into_and_out_of_the_walkthrough_toggles_its_stop_cards() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.author = "reviewer".to_owned();
seat_walkthrough(&mut app);
app.open_walkthrough_diff("w1");
resolve_walkthrough(&mut app);
assert_eq!(layout_of(&app), crate::config::FileLayout::Tree);
for _ in 0..3 {
app.handle(key('t'));
}
assert_eq!(layout_of(&app), crate::config::FileLayout::Walkthrough);
app.diff.as_mut().unwrap().ensure_rows(&app.review);
assert!(
stop_card_rows(&app) > 0,
"the walkthrough shows its stop card once cycled in"
);
let windowed = line_rows(&app);
assert!(
shown_line_numbers(&app).iter().all(|no| *no <= 3),
"and only the current stop's own span"
);
app.handle(key('t'));
assert_eq!(layout_of(&app), crate::config::FileLayout::Tree);
app.diff.as_mut().unwrap().ensure_rows(&app.review);
assert!(
line_rows(&app) > windowed,
"leaving the walkthrough shows the whole file again"
);
assert!(
app.diff.as_ref().expect("diff view").referenced.is_none(),
"leaving the walkthrough drops the span band too"
);
}
fn line_rows(app: &App) -> usize {
rows(app)
.iter()
.filter(|row| matches!(row, DiffRow::Line { .. }))
.count()
}
fn stop_card_rows(app: &App) -> usize {
let diff = app.diff.as_ref().expect("diff view");
let session = app.review.session_for(&diff.source);
let Some(walkthrough) = diff.active_walkthrough(session) else {
return 0;
};
let offset = super::stop_row_offset(walkthrough);
let Some(current) = diff
.tree_cursor
.checked_sub(offset)
.and_then(|stop| walkthrough.stops.get(stop))
.and_then(|id| session.comments.iter().position(|c| c.id == *id))
else {
return 0;
};
rows(app)
.iter()
.filter(|row| matches!(row, DiffRow::Comment { comment, .. } if *comment == current))
.count()
}
fn seat_walkthrough(app: &mut App) {
crate::test_support::seat_walkthrough(
app,
"How the answer moved",
&[
("What changed", None, "the overview"),
("The list", Some("todo.md:1"), "why a list"),
("The answer", Some("src/lib.rs#answer"), "why 42"),
],
);
}
fn walkthrough_app(fixture: &Fixture) -> App {
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = crate::config::FileLayout::Walkthrough;
let mut app = App::new(fixture.review(), loaded);
app.author = "reviewer".to_owned();
seat_walkthrough(&mut app);
app.open_walkthrough_diff("w1");
resolve_walkthrough(&mut app);
app
}
fn walkthrough_app_with_summary(fixture: &Fixture) -> App {
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = crate::config::FileLayout::Walkthrough;
let mut app = App::new(fixture.review(), loaded);
app.author = "reviewer".to_owned();
seat_walkthrough(&mut app);
crate::test_support::set_walkthrough_summary(&mut app, "w1", "the shape of the change");
app.open_walkthrough_diff("w1");
resolve_walkthrough(&mut app);
app
}
fn walkthrough_app_with(fixture: &Fixture, stops: &[(&str, Option<&str>, &str)]) -> App {
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = crate::config::FileLayout::Walkthrough;
let mut app = App::new(fixture.review(), loaded);
app.author = "reviewer".to_owned();
crate::test_support::seat_walkthrough(&mut app, "tour", stops);
app.open_walkthrough_diff("w1");
resolve_walkthrough(&mut app);
app.diff.as_mut().unwrap().ensure_rows(&app.review);
app
}
fn resolve_walkthrough(app: &mut App) {
let Some(request) = app.pending_walkthrough.take() else {
return;
};
let root = app.review.repo_root.clone();
let contents = request
.files
.iter()
.filter_map(|path| Some((path.clone(), std::fs::read_to_string(root.join(path)).ok()?)))
.collect();
app.handle(AppEvent::WalkthroughAnchors {
contents,
pin_broken: false,
token: request.token,
});
}
#[test]
fn moving_onto_a_stop_seats_the_cursor_on_its_span() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(key('j'));
app.handle(key('j'));
assert_eq!(selected_path(&app), "src/lib.rs");
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(
diff.cursor, 1,
"the cursor lands on the first row of the span"
);
let last = diff.rows().len().saturating_sub(1);
let lines: Vec<u32> = (diff.cursor..=last)
.filter_map(|row| match diff.rows().get(row) {
Some(DiffRow::Line { hunk, line, .. }) => diff
.model(&app.review)
.files
.get(diff.selected)
.and_then(|file| file.hunks.get(*hunk))
.and_then(|hunk| hunk.lines.get(*line))
.and_then(|line| line.new_no),
_ => None,
})
.collect();
assert_eq!(lines, [1, 2, 3], "the slide shows the whole definition");
}
#[test]
fn an_anchorless_stop_leaves_the_pane_alone() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(key('j'));
let seated = selected_path(&app);
assert_eq!(seated, "todo.md");
app.handle(key('k'));
assert_eq!(selected_path(&app), seated, "stop 1 anchors nothing");
assert_eq!(
app.diff.as_ref().expect("diff view").referenced,
None,
"and bands nothing"
);
}
#[test]
fn r_on_a_stop_card_replies_to_that_stop() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(key('j'));
app.handle(key('j'));
enter_diff_pane(&mut app);
let card = rows(&app)
.iter()
.position(|row| matches!(row, DiffRow::Comment { line: 0, .. }))
.expect("a stop card");
app.diff.as_mut().expect("diff view").cursor = card;
app.handle(key('r'));
assert!(app.composer_open());
type_text(&mut app, "why not 43?");
app.handle(key('\n'));
let source = app.active_review_source();
let stop = app
.review
.session_for(&source)
.comment("stop-2")
.expect("the third stop's comment");
assert_eq!(stop.replies.len(), 1);
assert_eq!(stop.replies[0].body, "why not 43?");
assert_eq!(stop.status, CommentStatus::Replied);
}
#[test]
fn the_comments_pane_lists_the_stops() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(key('C'));
assert_eq!(
app.comment_order(),
vec![
"stop-2".to_owned(),
"stop-0".to_owned(),
"stop-1".to_owned()
],
"by file as the diff orders them, then by line"
);
}
fn add_comment(app: &mut App, file: &str, line: u32, body: &str) -> String {
let source = app.active_review_source();
let id = app
.review
.session_for_mut(&source)
.add_comment(
Anchor {
file: file.to_owned(),
line: Some(line),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
body,
)
.id
.clone();
let diff = app.diff.as_mut().expect("diff view");
diff.invalidate();
diff.ensure_rows(&app.review);
id
}
fn shown_comment_ids(app: &App) -> Vec<String> {
let diff = app.diff.as_ref().expect("diff view");
let session = app.review.session_for(&diff.source);
rows(app)
.iter()
.filter_map(|row| match row {
DiffRow::Comment {
comment, line: 0, ..
} => session.comments.get(*comment).map(|c| c.id.clone()),
_ => None,
})
.collect()
}
fn select_comment(app: &mut App, id: &str) {
let at = app
.comment_order()
.iter()
.position(|known| known == id)
.expect("the comment is listed");
app.comments_to(at);
}
#[test]
fn selecting_a_comment_from_the_pane_enters_the_slide_that_holds_it() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let human = add_comment(&mut app, "src/lib.rs", 2, "why 42?");
assert_eq!(tree_cursor(&app), 0, "the reader starts on the first stop");
app.handle(key('C'));
select_comment(&mut app, &human);
assert_eq!(tree_cursor(&app), 2, "the sidebar follows to that stop");
assert_eq!(selected_path(&app), "src/lib.rs");
assert_eq!(shown_line_numbers(&app), [1, 2, 3], "the stop's own region");
assert_eq!(
shown_comment_ids(&app),
vec![human, "stop-2".to_owned()],
"cards in line order: line 2's comment before the stop's own card, \
which sits under line 3, the last line of its span"
);
}
#[test]
fn a_comment_outside_every_region_opens_as_its_own_slide() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let human = add_comment(&mut app, "ci.yml", 1, "why on push?");
app.handle(key('C'));
select_comment(&mut app, &human);
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(diff.slide, Some(Slide::AdHoc(human.clone())));
assert_eq!(selected_path(&app), "ci.yml");
assert_eq!(shown_line_numbers(&app), [1]);
assert_eq!(shown_comment_ids(&app), vec![human]);
}
#[test]
fn a_comment_on_a_deleted_line_bands_the_deleted_row_and_its_hunk() {
let fixture = two_hunk_fixture();
let mut app = walkthrough_app_with(
&fixture,
&[("Somewhere else", None, "unrelated to the deletion")],
);
let source = app.active_review_source();
let human = app
.review
.session_for_mut(&source)
.add_comment(
Anchor {
file: "data.txt".to_owned(),
line: Some(1),
line_end: None,
on_old_side: true,
line_text: None,
},
"reviewer",
"why drop this line?",
)
.id
.clone();
app.diff.as_mut().expect("diff view").invalidate();
app.diff
.as_mut()
.expect("diff view")
.ensure_rows(&app.review);
app.handle(key('C'));
select_comment(&mut app, &human);
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(diff.slide, Some(Slide::AdHoc(human.clone())));
let shown = rows(&app);
assert!(
shown
.iter()
.any(|row| matches!(row, DiffRow::Hunk { hunk: 0, .. })),
"the deleted row's hunk header must be windowed in: {shown:?}"
);
assert!(
shown.iter().any(|row| matches!(
row,
DiffRow::Line {
hunk: 0,
line: 0,
..
}
)),
"the deleted row itself must be windowed in, not only the card: {shown:?}"
);
}
#[test]
fn stepping_past_a_slides_last_comment_enters_the_next_slide() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let human = add_comment(&mut app, "todo.md", 1, "is this list still right?");
app.handle(key('C'));
select_comment(&mut app, &human);
assert_eq!(tree_cursor(&app), 1, "the second stop holds it");
assert_eq!(
shown_comment_ids(&app),
vec!["stop-1".to_owned(), human],
"that slide holds both"
);
app.handle(key('}'));
assert_eq!(tree_cursor(&app), 2, "the walk moved on to the next stop");
assert_eq!(selected_path(&app), "src/lib.rs");
assert_eq!(shown_line_numbers(&app), [1, 2, 3]);
assert_eq!(shown_comment_ids(&app), vec!["stop-2".to_owned()]);
}
fn shown_line_numbers(app: &App) -> Vec<u32> {
let diff = app.diff.as_ref().expect("diff view");
let model = diff.model(&app.review);
rows(app)
.iter()
.filter_map(|row| {
let DiffRow::Line { file, hunk, line } = row else {
return None;
};
model
.files
.get(*file)?
.hunks
.get(*hunk)?
.lines
.get(*line)?
.new_no
})
.collect()
}
#[test]
fn a_stops_window_is_its_anchored_rows_its_hunk_header_and_its_card() {
let fixture = big_file_fixture();
let app = walkthrough_app_with(
&fixture,
&[("The change", Some("big.txt:100"), "why line 100 changed")],
);
assert_eq!(
shown_line_numbers(&app),
[100],
"the anchored line and no context around it"
);
assert!(
rows(&app)
.iter()
.any(|row| matches!(row, DiffRow::Hunk { .. })),
"the hunk header it sits under stays"
);
assert!(stop_card_rows(&app) > 0, "the card still draws");
}
#[test]
fn a_wide_span_shows_every_row_it_covers() {
let fixture = huge_span_fixture();
let app = walkthrough_app_with(
&fixture,
&[("The rewrite", Some("big.txt:51-120"), "why 70 lines moved")],
);
assert_eq!(shown_line_numbers(&app).len(), 70);
}
#[test]
fn an_anchor_with_no_span_shows_only_its_card() {
let fixture = standard_fixture();
let app = walkthrough_app_with(
&fixture,
&[("Whole file", Some("todo.md"), "look at the whole file")],
);
assert!(
rows(&app)
.iter()
.all(|row| matches!(row, DiffRow::Comment { .. })),
"no hunks at all: {:?}",
rows(&app)
);
}
#[test]
fn cycling_out_of_the_walkthrough_restores_the_full_file_rows() {
let fixture = huge_span_fixture();
let mut app = walkthrough_app_with(
&fixture,
&[("The rewrite", Some("big.txt:51-120"), "why 70 lines moved")],
);
let windowed = shown_line_numbers(&app).len();
app.handle(key('t'));
app.diff.as_mut().unwrap().ensure_rows(&app.review);
assert_eq!(
layout_of(&app),
crate::config::FileLayout::Tree,
"left the walkthrough"
);
assert!(
shown_line_numbers(&app).len() > windowed,
"the whole hunk shows again, not just the window"
);
}
#[test]
fn ctrl_n_steps_stops_in_the_walkthrough_layout() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(ctrl_key('n'));
assert_eq!(tree_cursor(&app), 1);
assert_eq!(selected_path(&app), "todo.md");
app.handle(ctrl_key('n'));
assert_eq!(tree_cursor(&app), 2);
assert_eq!(selected_path(&app), "src/lib.rs");
app.handle(ctrl_key('p'));
assert_eq!(tree_cursor(&app), 1);
}
#[test]
fn m_marks_the_current_slide_seen_and_advances_to_the_next() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
assert_eq!(app.diff.as_ref().unwrap().slide, Some(Slide::Stop(0)));
app.handle(key('m'));
let source = app.active_review_source();
assert!(app.review.session_for(&source).is_stop_seen("stop-0"));
assert_eq!(app.diff.as_ref().unwrap().slide, Some(Slide::Stop(1)));
}
#[test]
fn u_jumps_to_the_next_unseen_slide_skipping_seen_ones() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let source = app.active_review_source();
app.review.session_for_mut(&source).mark_stop_seen("stop-1");
app.handle(key('u'));
assert_eq!(
app.diff.as_ref().unwrap().slide,
Some(Slide::Stop(2)),
"stop-1 is seen, so u skips past it to stop-2"
);
}
#[test]
fn u_says_every_slide_is_seen_when_none_is_left() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let source = app.active_review_source();
for id in ["stop-0", "stop-1", "stop-2"] {
app.review.session_for_mut(&source).mark_stop_seen(id);
}
app.handle(key('u'));
assert_eq!(
app.message.as_ref().map(|m| m.text.as_str()),
Some("every slide is seen")
);
}
#[test]
fn seen_progress_shows_in_the_sidebar_heading_not_the_status_header() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
let source = app.active_review_source();
app.review.session_for_mut(&source).mark_stop_seen("stop-0");
let content = crate::test_support::render(&mut app).backend().to_string();
assert!(
content.contains("How the answer moved (1/3)"),
"sidebar heading: {content}"
);
app.handle(key('q'));
let content = crate::test_support::render(&mut app).backend().to_string();
assert!(
content.contains("Walkthroughs (1)"),
"status header: {content}"
);
}
#[test]
fn d_on_the_leading_sidebar_row_asks_then_deletes_the_whole_walkthrough() {
let fixture = standard_fixture();
let mut app = walkthrough_app_with_summary(&fixture);
app.diff.as_mut().unwrap().tree_cursor = 0;
app.handle(key('d'));
assert!(matches!(app.modal, Some(crate::app::Modal::Confirm { .. })));
app.handle(key('y'));
assert!(app.diff.is_none(), "the diff closes with it");
let source = diffler_core::source::ReviewSource::walkthrough("w1");
assert!(
app.review
.all_reviews()
.expect("all reviews")
.into_iter()
.all(|(s, _)| s != source),
"the review file is gone"
);
}
#[test]
fn d_on_a_stop_row_asks_then_deletes_just_that_stop() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.diff.as_mut().unwrap().tree_cursor = 0;
app.handle(key('d'));
app.handle(key('y'));
let source = app.active_review_source();
let walkthrough = app
.review
.session_for(&source)
.walkthrough
.as_ref()
.expect("the walkthrough is kept");
assert_eq!(walkthrough.stops.len(), 2);
assert!(!walkthrough.stops.contains(&"stop-0".to_owned()));
}
#[test]
fn gg_and_g_jump_to_the_first_and_last_visible_row() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let last = tree_row_count(&app) - 1;
app.handle(key('G'));
assert_eq!(tree_cursor(&app), last);
assert_eq!(selected_path(&app), "todo.md");
app.handle(key('g'));
app.handle(key('g'));
assert_eq!(tree_cursor(&app), 0, "back to the first visible row");
}
#[test]
fn folding_a_dir_hides_its_subtree_and_unfolding_restores_it() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('g'));
app.handle(key('g'));
assert_eq!(tree_kinds(&app).len(), 4);
app.handle(key('z'));
app.handle(key('a'));
assert_eq!(
tree_kinds(&app),
vec![
"dir".to_owned(),
"file:ci.yml".to_owned(),
"file:todo.md".to_owned(),
],
"folded src/ hides lib.rs"
);
app.handle(key('\n'));
assert_eq!(
tree_kinds(&app).len(),
4,
"unfolded src/ shows lib.rs again"
);
}
#[test]
fn cr_on_a_file_row_focuses_the_diff_pane() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert!(matches!(
tree_kinds(&app).get(tree_cursor(&app)).map(String::as_str),
Some("file:ci.yml")
));
app.handle(key('\n'));
assert_eq!(focus(&app), Pane::Diff);
}
#[test]
fn h_and_l_focus_the_panes_and_clamp() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.handle(key('l'));
assert_eq!(focus(&app), Pane::Diff);
app.handle(key('l'));
assert_eq!(
focus(&app),
Pane::Diff,
"repeats stay on the diff, no cycle"
);
app.handle(key('h'));
assert_eq!(focus(&app), Pane::List);
app.handle(key('h'));
assert_eq!(
focus(&app),
Pane::List,
"repeats stay on the sidebar, no cycle"
);
}
#[test]
fn arrow_keys_focus_the_panes_like_h_and_l() {
use crossterm::event::KeyCode;
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(code_key(KeyCode::Right));
assert_eq!(focus(&app), Pane::Diff);
app.handle(code_key(KeyCode::Left));
assert_eq!(focus(&app), Pane::List);
}
#[test]
fn tab_on_a_file_at_the_repo_root_says_there_is_nothing_to_fold() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let before = tree_kinds(&app);
app.handle(key('\t'));
assert_eq!(tree_kinds(&app), before, "nothing folded");
assert!(
app.message
.as_ref()
.is_some_and(|m| m.text.contains("nothing to fold")),
"{:?}",
app.message
);
}
#[test]
fn tab_folds_the_directory_the_sidebar_cursor_sits_in() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('k'));
let file = selected_path(&app);
assert!(tree_kinds(&app).contains(&"file:lib.rs".to_owned()));
app.handle(key('\t'));
assert!(
!tree_kinds(&app).contains(&"file:lib.rs".to_owned()),
"tab folds the folder away: {:?}",
tree_kinds(&app)
);
assert_eq!(selected_path(&app), file, "and steps to no other file");
app.handle(key('\t'));
assert!(tree_kinds(&app).contains(&"file:lib.rs".to_owned()));
}
#[test]
fn cr_from_the_list_focuses_the_diff_and_cr_back_returns() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('\n'));
assert_eq!(focus(&app), Pane::Diff);
app.handle(key('\n'));
assert_eq!(focus(&app), Pane::List);
}
#[test]
fn ctrl_n_switches_the_selected_file_from_the_diff_pane_keeping_focus() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
let first = selected_path(&app);
app.handle(ctrl_key('n'));
assert_eq!(focus(&app), Pane::Diff, "focus stays on the diff");
assert_ne!(selected_path(&app), first, "selection advanced");
app.handle(ctrl_key('p'));
assert_eq!(selected_path(&app), first);
}
#[test]
fn ctrl_n_and_ctrl_p_walk_only_file_rows_skipping_directories() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('g'));
app.handle(key('g'));
assert!(matches!(
tree_kinds(&app).get(tree_cursor(&app)).map(String::as_str),
Some("dir")
));
app.handle(ctrl_key('n'));
assert_eq!(selected_path(&app), "src/lib.rs");
let mut visited = vec![selected_path(&app)];
for _ in 0..2 {
app.handle(ctrl_key('n'));
assert!(matches!(
tree_kinds(&app).get(tree_cursor(&app)).map(String::as_str),
Some(kind) if kind.starts_with("file:")
));
visited.push(selected_path(&app));
}
assert_eq!(visited, vec!["src/lib.rs", "ci.yml", "todo.md"]);
app.handle(ctrl_key('p'));
assert_eq!(selected_path(&app), "ci.yml");
}
#[test]
fn brace_keys_walk_between_comments() {
let fixture = two_hunk_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let anchor = |line: u32| Anchor {
file: "data.txt".to_owned(),
line: Some(line),
line_end: None,
on_old_side: false,
line_text: None,
};
app.review.session.add_comment(anchor(1), "r", "first");
app.review.session.add_comment(anchor(20), "r", "second");
app.open_working_tree_diff(None);
{
let diff = app.diff.as_mut().unwrap();
diff.focus = Pane::Diff;
diff.cursor = 0;
diff.invalidate();
}
app.diff.as_mut().unwrap().ensure_rows(&app.review);
let on_header = |app: &App| {
let diff = app.diff.as_ref().unwrap();
matches!(
diff.rows().get(diff.cursor),
Some(DiffRow::Comment { line: 0, .. })
)
};
app.handle(key('}'));
assert!(on_header(&app), "}} lands on a comment header");
let first = app.diff.as_ref().unwrap().cursor;
app.handle(key('}'));
assert!(on_header(&app));
let second = app.diff.as_ref().unwrap().cursor;
assert!(second > first, "}} advances to the next comment");
app.handle(key('{'));
assert_eq!(
app.diff.as_ref().unwrap().cursor,
first,
"{{ returns to the previous comment"
);
}
#[test]
fn comment_rows_appear_under_their_anchored_line() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('c'));
assert!(app.composer_open());
type_text(&mut app, "why 42?");
app.handle(key('\n'));
assert!(!app.composer_open());
let comment = &app.review.session.comments[0];
assert_eq!(comment.author, "reviewer");
assert_eq!(comment.body, "why 42?");
assert_eq!(comment.anchor.file, "src/lib.rs");
assert_eq!(comment.anchor.line, Some(2));
assert!(!comment.anchor.on_old_side);
assert_eq!(comment.anchor.line_text.as_deref(), Some(" 42"));
let diff = app.diff.as_mut().unwrap();
diff.ensure_rows(&app.review);
let rows = rows(&app);
let line_position = added_line_position(&app);
let block: Vec<_> = rows
.iter()
.skip(line_position + 1)
.take_while(|r| matches!(r, DiffRow::Comment { .. }))
.collect();
assert_eq!(
block.len(),
3,
"comment block right under the line: {rows:?}"
);
assert!(block.iter().all(|r| matches!(
r,
DiffRow::Comment {
outdated: false,
..
}
)));
}
fn composer_with_typed_note(app: &mut App) {
select_file(app, "src/lib.rs");
let position = added_line_position(app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('c'));
type_text(app, "why 42?");
}
#[test]
fn ctrl_g_sends_the_composer_buffer_to_the_editor_and_reads_it_back() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
composer_with_typed_note(&mut app);
app.handle(ctrl_key('g'));
let request = app.pending_editor.take().expect("editor queued");
let (path, target) = match &request.purpose {
crate::editor::EditorPurpose::TextBox { path, target } => (path.clone(), *target),
other => panic!("expected a text box purpose, got {other:?}"),
};
assert_eq!(target, crate::editor::TextBoxTarget::Composer);
assert!(
path.starts_with(std::env::temp_dir()),
"scratch, not the gitdir: {}",
path.display()
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "why 42?");
assert!(
app.composer_open(),
"the composer stays put while suspended"
);
std::fs::write(&path, "edited externally").unwrap();
app.editor_finished(request.purpose, Ok(true));
assert!(!path.exists(), "the scratch file is removed");
let composer = app
.diff
.as_ref()
.unwrap()
.composer
.as_ref()
.expect("composer stays open");
assert_eq!(composer.buffer, "edited externally");
}
#[test]
fn a_cancelled_composer_edit_keeps_the_buffer() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
composer_with_typed_note(&mut app);
app.handle(ctrl_key('g'));
let request = app.pending_editor.take().expect("editor queued");
let path = match &request.purpose {
crate::editor::EditorPurpose::TextBox { path, .. } => path.clone(),
other => panic!("expected a text box purpose, got {other:?}"),
};
std::fs::write(&path, "an edit the editor never saved").unwrap();
app.editor_finished(request.purpose, Ok(false));
assert!(!path.exists(), "the scratch file is removed regardless");
let composer = app
.diff
.as_ref()
.unwrap()
.composer
.as_ref()
.expect("composer stays open");
assert_eq!(
composer.buffer, "why 42?",
"the cancelled edit changed nothing"
);
}
#[test]
fn a_failed_composer_editor_keeps_the_buffer_and_says_so() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
composer_with_typed_note(&mut app);
app.handle(ctrl_key('g'));
let request = app.pending_editor.take().expect("editor queued");
let path = match &request.purpose {
crate::editor::EditorPurpose::TextBox { path, .. } => path.clone(),
other => panic!("expected a text box purpose, got {other:?}"),
};
app.editor_finished(request.purpose, Err("boom".to_owned()));
assert!(!path.exists(), "the scratch file is removed regardless");
let composer = app
.diff
.as_ref()
.unwrap()
.composer
.as_ref()
.expect("composer stays open");
assert_eq!(composer.buffer, "why 42?");
let message = app.message.clone().expect("message");
assert_eq!(message.severity, crate::app::Severity::Error);
assert!(message.text.contains("editor failed"));
assert!(message.text.contains("boom"));
}
#[test]
fn outdated_comment_is_flagged_when_the_line_text_drifts() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.review.session.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: Some(2),
line_end: None,
on_old_side: false,
line_text: Some(" 43".to_owned()),
},
"reviewer",
"stale snapshot",
);
let diff = app.diff.as_mut().unwrap();
diff.invalidate();
diff.ensure_rows(&app.review);
assert!(
rows(&app)
.iter()
.any(|r| matches!(r, DiffRow::Comment { outdated: true, .. })),
"drifted line_text flags the comment outdated"
);
}
#[test]
fn comment_for_a_departed_line_attaches_at_the_top() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.review.session.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: Some(99),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"moved on",
);
let diff = app.diff.as_mut().unwrap();
diff.invalidate();
diff.ensure_rows(&app.review);
let rows = rows(&app);
assert!(
matches!(rows.first(), Some(DiffRow::Comment { outdated: true, .. })),
"orphaned comment sits at the top, flagged outdated: {rows:?}"
);
}
#[test]
fn scoped_open_selects_the_file_and_focuses_the_diff() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.open_working_tree_file("src/lib.rs");
assert_eq!(focus(&app), Pane::Diff);
assert_eq!(selected_path(&app), "src/lib.rs");
assert_eq!(app.diff.as_ref().unwrap().cursor, 0, "starts at the top");
}
#[test]
fn visual_selection_comments_a_new_side_range() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| {
matches!(
r,
DiffRow::Line {
hunk: 0,
line: 1,
..
}
)
});
app.handle(key('V'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
app.handle(key('j'));
app.handle(key('j'));
app.handle(key('c'));
type_text(&mut app, "this block");
app.handle(key('\n'));
let comment = &app.review.session.comments[0];
assert_eq!(comment.anchor.line, Some(1));
assert_eq!(comment.anchor.line_end, Some(3));
assert!(!comment.anchor.on_old_side);
assert_eq!(comment.anchor.line_text.as_deref(), Some("line 3"));
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_none(),
"selection ends once the comment lands"
);
}
#[test]
fn visual_selection_anchored_on_a_deleted_line_uses_the_old_side() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| {
matches!(
r,
DiffRow::Line {
hunk: 0,
line: 0,
..
}
)
});
app.handle(key('V'));
app.handle(key('j'));
app.handle(key('j'));
app.handle(key('c'));
type_text(&mut app, "old side");
app.handle(key('\n'));
let comment = &app.review.session.comments[0];
assert!(comment.anchor.on_old_side);
assert_eq!(comment.anchor.line, Some(1));
assert_eq!(comment.anchor.line_end, Some(2));
}
#[test]
fn escape_cancels_visual_selection() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { .. }));
app.handle(key('V'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
app.handle(AppEvent::Key(crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
)));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_none());
app.handle(key('V'));
app.handle(key('V'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_none());
}
#[test]
fn reply_and_resolve_walk_the_comment_lifecycle() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('c'));
type_text(&mut app, "question");
app.handle(key('\n'));
settle_submit(&mut app);
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position + 1;
app.handle(key('r'));
assert!(app.composer_open());
type_text(&mut app, "answer");
app.handle(key('\n'));
let comment = &app.review.session.comments[0];
assert_eq!(comment.status, CommentStatus::Replied);
assert_eq!(comment.replies.len(), 1);
assert_eq!(comment.replies[0].body, "answer");
settle_submit(&mut app);
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position + 1;
app.handle(key('R'));
assert_eq!(
app.review.session.comments[0].status,
CommentStatus::Resolved
);
let reloaded = diffler_core::store::load(&fixture.root).unwrap();
assert_eq!(reloaded.comments[0].status, CommentStatus::Resolved);
}
fn composer_rows(app: &App) -> Vec<usize> {
app.diff
.as_ref()
.expect("diff view")
.rows()
.iter()
.enumerate()
.filter(|(_, row)| matches!(row, DiffRow::Composer { .. }))
.map(|(index, _)| index)
.collect()
}
#[test]
fn the_composer_takes_the_rows_under_the_line_it_comments_on() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let line = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = line;
app.handle(key('c'));
let rows = composer_rows(&app);
assert_eq!(
rows.first().copied(),
Some(line + 1),
"the card opens directly under its line"
);
assert!(rows.len() >= 3, "header, a body row, and the hint footer");
assert!(
rows.windows(2).all(|pair| pair[1] == pair[0] + 1),
"the card is one contiguous block: {rows:?}"
);
}
#[test]
fn the_composer_grows_a_row_when_the_text_wraps() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.diff.as_mut().unwrap().set_wrap_width(60);
app.handle(key('c'));
let before = composer_rows(&app).len();
type_text(
&mut app,
&"x".repeat(super::super::composer::card_budget(60) + 1),
);
assert_eq!(
composer_rows(&app).len(),
before + 1,
"one more row once the text passes the wrap budget"
);
}
#[test]
fn a_draft_and_the_comment_it_becomes_wrap_to_the_same_budget() {
let width = 48;
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().set_wrap_width(width);
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
type_text(
&mut app,
&"w".repeat(super::super::composer::card_budget(width)),
);
let drafted = composer_rows(&app).len();
app.handle(key('\n'));
app.diff.as_mut().unwrap().ensure_rows(&app.review);
let landed = app
.diff
.as_ref()
.unwrap()
.rows()
.iter()
.filter(|row| matches!(row, DiffRow::Comment { .. }))
.count();
assert_eq!(drafted, 3, "header, the one full row, footer");
assert_eq!(
drafted, landed,
"writing a comment and reading it back take the same rows"
);
}
fn cursor_to_dir(app: &mut App, dir: &str) {
let review = &app.review;
let diff = app.diff.as_ref().expect("diff view");
let rows = super::sidebar_rows(diff, review);
let at = rows
.iter()
.position(
|row| matches!(&row.node, crate::tree::TreeNode::Dir { path, .. } if path == dir),
)
.unwrap_or_else(|| panic!("a tree row for {dir}"));
let diff = app.diff.as_mut().expect("diff view");
diff.focus = Pane::List;
diff.tree_cursor = at;
}
fn viewed_paths(app: &App) -> Vec<String> {
let source = app.active_review_source();
let session = app.review.session_for(&source);
let mut out: Vec<String> = app
.diff
.as_ref()
.unwrap()
.model(&app.review)
.files
.iter()
.filter(|f| session.is_viewed(&f.path, &f.content_hash()))
.map(|f| f.path.clone())
.collect();
out.sort();
out
}
fn nested_fixture() -> Fixture {
let fixture = Fixture::new();
fixture.write("src/lib.rs", "pub fn a() {}\n");
fixture.write("src/main.rs", "fn main() {}\n");
fixture.write("src/deep/inner.rs", "pub fn b() {}\n");
fixture.write("docs/readme.md", "hi\n");
fixture.commit_all("initial");
fixture.write("src/lib.rs", "pub fn a() -> u32 {\n 1\n}\n");
fixture.write("src/main.rs", "fn main() {\n println!();\n}\n");
fixture.write("src/deep/inner.rs", "pub fn b() -> u32 {\n 2\n}\n");
fixture.write("docs/readme.md", "hi there\n");
fixture
}
#[test]
fn viewing_a_directory_marks_the_whole_subtree_and_nothing_outside_it() {
let fixture = nested_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
cursor_to_dir(&mut app, "src");
app.handle(key('m'));
assert_eq!(
viewed_paths(&app),
["src/deep/inner.rs", "src/lib.rs", "src/main.rs"],
"every file under src, including the nested one"
);
}
#[test]
fn viewing_a_nested_directory_leaves_its_parent_alone() {
let fixture = nested_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
cursor_to_dir(&mut app, "src/deep");
app.handle(key('m'));
assert_eq!(viewed_paths(&app), ["src/deep/inner.rs"]);
}
#[test]
fn viewing_an_already_viewed_directory_puts_it_back() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
cursor_to_dir(&mut app, "src");
app.handle(key('m'));
assert!(!viewed_paths(&app).is_empty());
cursor_to_dir(&mut app, "src");
app.handle(key('m'));
assert!(
viewed_paths(&app).is_empty(),
"a second press unmarks the subtree"
);
}
fn cursor_to_section(app: &mut App, label: &str) {
let review = &app.review;
let diff = app.diff.as_ref().expect("diff view");
let rows = super::sidebar_rows(diff, review);
let at = rows
.iter()
.position(|row| {
matches!(&row.node, crate::tree::TreeNode::Section { bucket, .. }
if bucket.label() == label)
})
.unwrap_or_else(|| panic!("a section row for {label}: {rows:?}"));
let diff = app.diff.as_mut().expect("diff view");
diff.focus = Pane::List;
diff.tree_cursor = at;
}
fn kinds_fixture() -> Fixture {
let fixture = Fixture::new();
fixture.write("src/lib.rs", "pub fn a() {}\n");
fixture.write("tests/snapshots/render.snap", "old frame\n");
fixture.write("package-lock.json", "{\n \"lockfileVersion\": 3\n}\n");
fixture.commit_all("initial");
fixture.write("src/lib.rs", "pub fn a() -> u32 {\n 1\n}\n");
fixture.write("tests/snapshots/render.snap", "new frame\n");
fixture.write("package-lock.json", "{\n \"lockfileVersion\": 4\n}\n");
fixture
}
#[test]
fn viewing_a_kind_section_marks_every_file_it_holds() {
let fixture = kinds_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
let rows = super::sidebar_rows(app.diff.as_ref().expect("diff"), &app.review);
assert!(
rows.iter().any(|row| matches!(&row.node,
crate::tree::TreeNode::Section { bucket, folded, .. }
if bucket.label() == "Generated" && *folded)),
"Generated opens folded, so the mark cannot read the rows: {rows:?}"
);
cursor_to_section(&mut app, "Generated");
app.handle(key('m'));
assert_eq!(
viewed_paths(&app),
["package-lock.json", "tests/snapshots/render.snap"],
"the whole bucket, folded though it is"
);
}
#[test]
fn viewing_a_kind_section_again_puts_it_back() {
let fixture = kinds_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
cursor_to_section(&mut app, "Generated");
app.handle(key('m'));
assert!(!viewed_paths(&app).is_empty());
cursor_to_section(&mut app, "Generated");
app.handle(key('m'));
assert!(viewed_paths(&app).is_empty(), "a second press unmarks it");
}
#[test]
fn viewing_the_to_review_section_clears_the_whole_review() {
let fixture = kinds_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
cursor_to_section(&mut app, "To review");
app.handle(key('m'));
assert_eq!(
viewed_paths(&app),
[
"package-lock.json",
"src/lib.rs",
"tests/snapshots/render.snap"
]
);
}
#[test]
fn viewing_one_kind_leaves_the_others_alone() {
let fixture = kinds_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
cursor_to_section(&mut app, "Source");
app.handle(key('m'));
assert_eq!(viewed_paths(&app), ["src/lib.rs"]);
}
fn tree_cursor_row(app: &App) -> String {
let diff = app.diff.as_ref().expect("diff view");
let rows = super::sidebar_rows(diff, &app.review);
match &rows[diff.tree_cursor].node {
crate::tree::TreeNode::Dir { path, .. } => format!("dir {path}"),
crate::tree::TreeNode::Section { bucket, .. } => format!("section {}", bucket.label()),
crate::tree::TreeNode::File { name, .. } => format!("file {name}"),
crate::tree::TreeNode::Stop { index } => format!("stop {index}"),
crate::tree::TreeNode::WalkthroughSummary => "walkthrough_summary".to_owned(),
}
}
fn flat_fixture() -> Fixture {
let fixture = Fixture::new();
for name in ["a.rs", "b.rs", "c.rs", "d.rs"] {
fixture.write(name, "fn x() {}\n");
}
fixture.commit_all("base");
for name in ["a.rs", "b.rs", "c.rs", "d.rs"] {
fixture.write(name, "fn x() -> u32 {\n 1\n}\n");
}
fixture
}
#[test]
fn marking_the_bottom_file_keeps_the_reader_at_the_bottom() {
for layout in [
crate::config::FileLayout::Tree,
crate::config::FileLayout::Kinds,
] {
let fixture = flat_fixture();
let mut app = diff_app_with_layout(&fixture, layout);
app.handle(key('G'));
assert_eq!(selected_path(&app), "d.rs", "{layout:?}");
let row = app.diff.as_ref().expect("diff").tree_cursor;
app.handle(key('m'));
assert_eq!(
selected_path(&app),
"c.rs",
"{layout:?}: the next one to read, not the file that moved"
);
assert_eq!(
app.diff.as_ref().expect("diff").tree_cursor,
row,
"{layout:?}: and the cursor holds its row"
);
}
}
#[test]
fn marking_from_the_bottom_walks_up_through_what_is_left() {
let fixture = flat_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
app.handle(key('G'));
let mut walked = vec![selected_path(&app)];
for _ in 0..3 {
app.handle(key('m'));
walked.push(selected_path(&app));
}
assert_eq!(walked, ["d.rs", "c.rs", "b.rs", "a.rs"]);
}
#[test]
fn marking_with_a_file_below_still_steps_down() {
let fixture = flat_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
assert_eq!(selected_path(&app), "a.rs");
app.handle(key('m'));
assert_eq!(selected_path(&app), "b.rs");
}
#[test]
fn brackets_step_the_sidebar_folder_by_folder() {
let fixture = nested_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
assert_eq!(tree_cursor_row(&app), "file readme.md");
app.handle(key('['));
assert_eq!(
tree_cursor_row(&app),
"dir docs",
"back to the folder holding it"
);
app.handle(key(']'));
assert_eq!(tree_cursor_row(&app), "dir src");
app.handle(key(']'));
assert_eq!(
tree_cursor_row(&app),
"dir src/deep",
"a nested folder counts"
);
app.handle(key('['));
assert_eq!(tree_cursor_row(&app), "dir src", "and back up");
}
#[test]
fn brackets_step_the_sidebar_section_by_section() {
let fixture = kinds_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
app.handle(key(']'));
assert_eq!(tree_cursor_row(&app), "section Generated");
app.handle(key('['));
assert_eq!(tree_cursor_row(&app), "section Source");
}
#[test]
fn stepping_past_the_last_group_stands_still() {
let fixture = nested_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
app.handle(key('G'));
let last = tree_cursor_row(&app);
app.handle(key(']'));
assert_eq!(tree_cursor_row(&app), last);
}
#[test]
fn brackets_in_the_diff_pane_still_step_hunks() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('\n'));
assert_eq!(focus(&app), Pane::Diff);
let before = app.diff.as_ref().expect("diff").cursor;
app.handle(key(']'));
let diff = app.diff.as_ref().expect("diff");
assert!(
matches!(diff.rows.get(diff.cursor), Some(DiffRow::Hunk { .. }))
|| diff.cursor == before
);
}
#[test]
fn viewing_a_file_row_still_marks_only_that_file() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Tree);
select_file(&mut app, "src/lib.rs");
app.handle(key('m'));
assert_eq!(viewed_paths(&app), ["src/lib.rs"]);
}
#[test]
fn a_file_level_composer_opens_above_the_diff() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.handle(key('c'));
assert_eq!(
composer_rows(&app).first().copied(),
Some(0),
"a whole-file comment is written where it renders, at the top"
);
}
#[test]
fn a_reply_composer_opens_under_the_thread_it_answers() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let line = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = line;
app.handle(key('c'));
type_text(&mut app, "question");
app.handle(key('\n'));
settle_submit(&mut app);
app.diff.as_mut().unwrap().cursor = added_line_position(&app) + 1;
app.handle(key('r'));
let last_comment = app
.diff
.as_ref()
.unwrap()
.rows()
.iter()
.rposition(|row| matches!(row, DiffRow::Comment { .. }))
.expect("the thread being replied to");
assert_eq!(
composer_rows(&app).first().copied(),
Some(last_comment + 1),
"the draft reply sits under the whole block"
);
assert!(last_comment > line, "and the block sits under its line");
}
#[test]
fn an_edit_composer_stands_in_for_the_block_it_rewrites() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let line = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = line;
app.handle(key('c'));
type_text(&mut app, "note");
app.handle(key('\n'));
settle_submit(&mut app);
app.diff.as_mut().unwrap().cursor = added_line_position(&app) + 1;
app.handle(key('c'));
assert_eq!(composer_rows(&app).first().copied(), Some(line + 1));
assert!(
!app.diff
.as_ref()
.unwrap()
.rows()
.iter()
.any(|row| matches!(row, DiffRow::Comment { .. })),
"the comment being edited gives up its rows to the draft"
);
}
#[test]
fn the_pane_cursor_rides_the_caret_so_a_tall_card_scrolls_with_it() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().set_wrap_width(60);
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
let caret_row = |app: &App| {
let diff = app.diff.as_ref().unwrap();
let caret = diff.composer.as_ref().unwrap().caret_line(diff.wrap_width);
diff.rows()
.iter()
.position(|row| matches!(row, DiffRow::Composer { line } if *line == caret))
};
assert_eq!(Some(app.diff.as_ref().unwrap().cursor), caret_row(&app));
for _ in 0..6 {
type_text(
&mut app,
&"y".repeat(super::super::composer::card_budget(60)),
);
}
let rows = composer_rows(&app);
assert!(rows.len() > 6, "the card grew tall: {}", rows.len());
assert_eq!(Some(app.diff.as_ref().unwrap().cursor), caret_row(&app));
assert!(
app.diff.as_ref().unwrap().cursor > rows[0],
"the cursor left the header behind"
);
}
#[test]
fn reopening_the_same_source_keeps_the_draft_on_screen() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let file = app.diff.as_ref().unwrap().selected;
assert_ne!(file, 0, "the draft is not on the file a reload defaults to");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
type_text(&mut app, "still writing");
app.open_working_tree_diff(None);
assert_eq!(
app.diff
.as_ref()
.and_then(|d| d.composer.as_ref())
.map(|c| c.buffer.clone()),
Some("still writing".to_owned())
);
assert_eq!(app.diff.as_ref().unwrap().selected, file, "same file");
assert!(
!composer_rows(&app).is_empty(),
"an open composer the pane cannot draw would still eat every key"
);
}
#[test]
fn esc_closes_the_composer_and_gives_its_rows_back() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
type_text(&mut app, "never mind");
app.handle(crate::test_support::esc_key());
assert!(!app.composer_open());
assert!(composer_rows(&app).is_empty());
assert!(app.review.session.comments.is_empty());
}
#[test]
fn a_blank_composer_submits_as_a_cancel() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
type_text(&mut app, " ");
app.handle(key('\n'));
assert!(!app.composer_open());
assert!(app.review.session.comments.is_empty());
}
#[test]
fn c_over_a_comment_edits_it() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('c'));
type_text(&mut app, "old note");
app.handle(key('\n'));
settle_submit(&mut app);
app.diff.as_mut().unwrap().cursor = added_line_position(&app) + 1;
app.handle(key('c'));
let composer = app
.diff
.as_ref()
.and_then(|d| d.composer.as_ref())
.expect("the composer opens over the comment");
assert_eq!(
composer.buffer, "old note",
"prefilled with the existing body"
);
for _ in 0.."old note".len() {
app.handle(crate::test_support::key_backspace());
}
type_text(&mut app, "new note");
app.handle(key('\n'));
assert_eq!(app.review.session.comments.len(), 1, "edited, not added");
assert_eq!(app.review.session.comments[0].body, "new note");
}
#[test]
fn reply_off_a_comment_row_hints() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
app.handle(key('r'));
let message = app.message.expect("message");
assert!(message.text.contains("comment"));
}
#[test]
fn c_in_the_file_list_comments_on_the_whole_file() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.handle(key('c'));
assert!(
app.composer_open(),
"c on a file opens a comment, not a hint"
);
type_text(&mut app, "whole-file note");
app.handle(key('\n'));
let comment = app.review.session.comments.first().expect("a file comment");
assert_eq!(comment.anchor.line, None, "file-level anchor (no line)");
assert!(comment.body.contains("whole-file note"));
}
#[test]
fn line_scoped_keys_in_the_list_hint_to_move_into_the_diff() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.handle(key('r')); let message = app.message.expect("message");
assert!(message.text.contains("move into the diff"));
}
#[test]
fn marking_viewed_steps_to_the_file_listed_under_it() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(
tree_kinds(&app),
vec!["dir", "file:lib.rs", "file:ci.yml", "file:todo.md"],
"directories first, so the sidebar order is not the model order"
);
assert_eq!(selected_path(&app), "ci.yml");
app.handle(key('m'));
assert!(app.is_path_viewed("ci.yml"));
assert_eq!(
selected_path(&app),
"todo.md",
"the row under ci.yml, not the next file in the diff"
);
app.handle(key('m'));
assert!(app.is_path_viewed("todo.md"));
assert_eq!(
selected_path(&app),
"todo.md",
"nothing below: the selection stays"
);
}
fn grouped_fixture() -> Fixture {
let fixture = Fixture::new();
for name in ["a.rs", "b.rs", "c.rs"] {
fixture.write(&format!("src/{name}"), "fn one() {}\n");
}
fixture.commit_all("base");
for name in ["a.rs", "b.rs", "c.rs"] {
fixture.write(&format!("src/{name}"), "fn one() -> u8 {\n 1\n}\n");
}
fixture
}
#[test]
fn a_viewed_file_sorts_to_the_top_of_its_directory() {
let fixture = grouped_fixture();
let mut app = diff_app(&fixture);
assert_eq!(
tree_kinds(&app),
vec!["dir", "file:a.rs", "file:b.rs", "file:c.rs"]
);
select_file(&mut app, "src/b.rs");
app.handle(key('m'));
assert_eq!(
tree_kinds(&app),
vec!["dir", "file:b.rs", "file:a.rs", "file:c.rs"],
"b.rs sorts above what is left to read"
);
assert_eq!(
selected_path(&app),
"src/c.rs",
"the row that was under b.rs"
);
app.handle(key('m'));
assert_eq!(
tree_kinds(&app),
vec!["dir", "file:b.rs", "file:c.rs", "file:a.rs"],
"the viewed run keeps the order the diff gave it"
);
}
#[test]
fn the_walk_stops_at_a_folded_group_and_says_what_is_left() {
let fixture = Fixture::new();
fixture.write("ci.yml", "on: push\n");
fixture.write("Cargo.lock", "# generated\n");
fixture.commit_all("base");
fixture.write("ci.yml", "on: [push]\n");
fixture.write("Cargo.lock", "# generated, again\n");
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
assert_eq!(
tree_kinds(&app),
vec!["section:Config:1", "file:ci.yml", "section:Generated:1"]
);
select_file(&mut app, "ci.yml");
app.handle(key('m'));
assert_eq!(
selected_path(&app),
"ci.yml",
"the folded section is not walked into"
);
assert_eq!(
app.message.as_ref().map(|m| m.text.as_str()),
Some("end of the list, 1 still unviewed")
);
app.handle(key('u'));
assert_eq!(selected_path(&app), "Cargo.lock");
}
#[test]
fn a_viewed_file_sorts_to_the_top_of_its_kind_section() {
let fixture = grouped_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
assert_eq!(
tree_kinds(&app),
vec![
"section:Source:3",
"file:src/a.rs",
"file:src/b.rs",
"file:src/c.rs"
]
);
select_file(&mut app, "src/b.rs");
app.handle(key('m'));
assert_eq!(
tree_kinds(&app),
vec![
"section:Source:3",
"file:src/b.rs",
"file:src/a.rs",
"file:src/c.rs"
]
);
assert_eq!(selected_path(&app), "src/c.rs");
}
#[test]
fn capital_u_clears_all_viewed_marks() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let paths: Vec<String> = app
.review
.model()
.files
.iter()
.map(|f| f.path.clone())
.collect();
for path in &paths {
select_file(&mut app, path);
app.handle(key('m'));
}
assert!(
paths.iter().all(|p| app.is_path_viewed(p)),
"all marked first"
);
app.handle(key('U'));
assert!(
paths.iter().all(|p| !app.is_path_viewed(p)),
"U cleared every viewed mark"
);
}
#[test]
fn unmarking_viewed_does_not_move_the_selection() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let first = selected_path(&app);
assert_eq!(first, "ci.yml");
app.handle(key('m'));
select_file(&mut app, &first);
app.handle(key('m'));
assert!(!app.is_path_viewed(&first));
assert_eq!(
selected_path(&app),
first,
"unmarking keeps the selection in place"
);
}
#[test]
fn viewed_can_be_marked_from_the_diff_pane() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let first = selected_path(&app);
enter_diff_pane(&mut app);
app.handle(key('m'));
assert!(app.is_path_viewed(&first));
assert_ne!(selected_path(&app), first);
}
#[test]
fn review_layout_buckets_files_with_the_viewed_pile_folded() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
assert_eq!(
tree_kinds(&app),
[
"section:To review:3",
"file:ci.yml",
"file:src/lib.rs",
"file:todo.md",
"section:Viewed:0",
]
);
app.handle(key('m'));
assert_eq!(
tree_kinds(&app),
[
"section:To review:2",
"file:src/lib.rs",
"file:todo.md",
"section:Viewed:1",
]
);
assert_eq!(selected_path(&app), "src/lib.rs");
}
#[test]
fn toggling_the_viewed_bucket_reveals_and_hides_its_files() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
app.handle(key('m'));
app.handle(key('G'));
app.handle(key('z'));
app.handle(key('a'));
assert_eq!(
tree_kinds(&app),
[
"section:To review:2",
"file:src/lib.rs",
"file:todo.md",
"section:Viewed:1",
"file:ci.yml",
]
);
app.handle(key('\n'));
assert_eq!(
tree_kinds(&app).last().map(String::as_str),
Some("section:Viewed:1")
);
}
#[test]
fn editing_a_viewed_file_returns_it_to_the_to_review_bucket() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
app.handle(key('m'));
assert_eq!(tree_kinds(&app)[0], "section:To review:2");
fixture.write("ci.yml", "on: push\njobs: {}\n");
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
assert_eq!(
tree_kinds(&app)[0],
"section:To review:3",
"stale viewed mark drops the file back into to-review"
);
}
#[test]
fn t_cycles_the_sidebar_through_the_tree_the_review_buckets_and_the_kinds() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert!(tree_kinds(&app).contains(&"dir".to_owned()));
app.handle(key('t'));
assert_eq!(tree_kinds(&app)[0], "section:To review:3");
app.handle(key('t'));
assert_eq!(tree_kinds(&app)[0], "section:Source:1");
app.handle(key('t'));
assert!(
tree_kinds(&app).contains(&"dir".to_owned()),
"wraps back to the tree"
);
}
#[test]
fn the_kinds_layout_groups_by_what_a_file_is_and_hides_the_empty_buckets() {
let fixture = standard_fixture();
let app = diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
assert_eq!(
tree_kinds(&app),
vec![
"section:Source:1",
"file:src/lib.rs",
"section:Docs:1",
"file:todo.md",
"section:Config:1",
"file:ci.yml",
],
"one header per kind the diff has, none for the kinds it does not"
);
}
#[test]
fn the_repos_declared_kinds_land_from_the_worker_and_regroup_the_sidebar() {
let fixture = standard_fixture();
let app = &mut diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
assert!(tree_kinds(app).contains(&"section:Source:1".to_owned()));
let request = app.pending_declared.take().expect("a queued lookup");
assert!(request.paths.contains(&"src/lib.rs".to_owned()));
let kinds = HashMap::from([("src/lib.rs".to_owned(), Kind::Generated)]);
app.handle(AppEvent::DeclaredKinds {
kinds,
token: request.token,
});
assert!(
tree_kinds(app).contains(&"section:Generated:1".to_owned()),
"{:?}",
tree_kinds(app)
);
}
#[test]
fn an_answer_for_a_replaced_file_list_is_dropped() {
let fixture = standard_fixture();
let app = &mut diff_app_with_layout(&fixture, crate::config::FileLayout::Kinds);
let stale = app.pending_declared.take().expect("a queued lookup").token;
app.queue_declared();
app.handle(AppEvent::DeclaredKinds {
kinds: HashMap::from([("src/lib.rs".to_owned(), Kind::Generated)]),
token: stale,
});
assert!(tree_kinds(app).contains(&"section:Source:1".to_owned()));
}
#[test]
fn a_reader_glob_moves_a_file_between_kind_buckets() {
let fixture = standard_fixture();
let mut loaded = LoadedConfig::default();
loaded.config.ui.diff_file_layout = crate::config::FileLayout::Kinds;
loaded.config.classify.tests = vec!["src/**".to_owned()];
let mut app = App::new(fixture.review(), loaded);
app.open_working_tree_diff(None);
assert!(
tree_kinds(&app).contains(&"section:Tests:1".to_owned()),
"{:?}",
tree_kinds(&app)
);
}
#[test]
fn unmarking_in_the_review_layout_reseats_the_cursor_on_the_moved_row() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
app.handle(key('m'));
app.handle(key('m'));
app.handle(key('m'));
app.handle(key('G'));
app.handle(key('z'));
app.handle(key('a'));
app.handle(key('j'));
assert_eq!(selected_path(&app), "ci.yml");
app.handle(key('m'));
assert_eq!(
tree_kinds(&app),
[
"section:To review:1",
"file:ci.yml",
"section:Viewed:2",
"file:src/lib.rs",
"file:todo.md",
]
);
assert_eq!(tree_cursor(&app), 1, "cursor sits on the moved file row");
}
#[test]
fn marking_the_last_unviewed_file_keeps_the_cursor_in_range() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::Review);
for _ in 0..app
.diff
.as_ref()
.expect("diff")
.model(&app.review)
.files
.len()
{
app.handle(key('m'));
}
let rows = tree_row_count(&app);
assert_eq!(rows, 2, "both buckets, all files folded away");
assert!(
tree_cursor(&app) < rows,
"cursor clamps into the shrunken row list"
);
}
#[test]
fn cycling_the_sidebar_layout_drops_a_committed_search() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('/'));
app.handle(key('l'));
app.handle(key('\n'));
assert!(app.search.is_some(), "search committed on the tree rows");
app.handle(key('t'));
assert!(
app.search.is_none(),
"layout swap invalidates the search row indices"
);
}
#[test]
fn u_jumps_to_the_next_unviewed_file_wrapping_past_viewed_ones() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('m')); select_file(&mut app, "todo.md");
app.handle(key('u'));
assert_eq!(
selected_path(&app),
"src/lib.rs",
"wraps past the viewed ci.yml"
);
}
#[test]
fn u_with_everything_viewed_says_so() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
for path in ["ci.yml", "src/lib.rs", "todo.md"] {
select_file(&mut app, path);
app.handle(key('m'));
}
let before = selected_path(&app);
app.handle(key('u'));
assert_eq!(selected_path(&app), before);
let message = app.message.expect("message");
assert!(message.text.contains("every file is viewed"));
}
#[test]
fn y_copies_the_selected_files_feedback_as_markdown() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('c'));
type_text(&mut app, "why 42?");
app.handle(key('\n'));
app.review.session.add_comment(
Anchor {
file: "todo.md".to_owned(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"other file",
);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('y'));
let payload = app
.pending_clipboard
.clone()
.expect("clipboard text queued");
let toast = app.message.clone().expect("toast");
assert_eq!(toast.text, "copied 1 comment (file)");
let repo = fixture.root.file_name().unwrap().to_string_lossy();
let expected = feedback::to_markdown(
&app.review.session,
app.review.model(),
&FeedbackOptions {
title: &format!("Review feedback: {repo} @ main (1 comment)"),
file_filter: Some("src/lib.rs"),
include_resolved: false,
},
);
assert!(expected.contains("why 42?"));
assert!(!expected.contains("other file"), "file filter applies");
assert_eq!(payload, expected);
app.handle(key('Y'));
let message = app.message.clone().expect("toast");
assert_eq!(message.text, "copied 2 comments (all)");
}
#[test]
fn y_with_a_visual_selection_copies_the_diff_lines() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('V'));
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("selection copied to the clipboard");
assert!(
text.starts_with('+'),
"added line keeps its marker: {text:?}"
);
assert!(text.contains("42"), "the line text is copied: {text:?}");
assert!(
!text.contains(" 1 "),
"gutter line numbers are stripped: {text:?}"
);
assert_eq!(
app.diff.as_ref().unwrap().visual_anchor,
None,
"the selection clears after copying"
);
}
#[test]
fn selecting_two_code_lines_yanks_them_with_their_markers() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| {
matches!(
r,
DiffRow::Line {
hunk: 0,
line: 0,
..
}
)
});
app.handle(key('V'));
app.handle(key('j'));
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("selection copied to the clipboard");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines, ["-line 1", "+line one"], "{lines:?}");
}
#[test]
fn selecting_a_stops_card_yanks_its_prose_lines() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
enter_diff_pane(&mut app);
app.diff.as_mut().unwrap().cursor = 0;
app.handle(key('V'));
app.handle(key('G'));
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("selection copied to the clipboard");
assert!(text.contains("the overview"), "{text:?}");
}
#[test]
fn selecting_the_summary_yanks_its_lines() {
let fixture = standard_fixture();
let mut app = walkthrough_app_with_summary(&fixture);
app.diff_tree_to(0);
enter_diff_pane(&mut app);
app.diff.as_mut().unwrap().cursor = 0;
app.handle(key('V'));
app.handle(key('G'));
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("selection copied to the clipboard");
assert!(text.contains("the shape of the change"), "{text:?}");
}
#[test]
fn selecting_across_a_card_and_the_code_below_it_yanks_both_in_order() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.review.session.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"why this file changed",
);
app.diff.as_mut().unwrap().invalidate();
app.diff.as_mut().unwrap().ensure_rows(&app.review);
let first_line = rows(&app)
.iter()
.position(|r| matches!(r, DiffRow::Line { .. }))
.expect("a code line follows the card");
app.diff.as_mut().unwrap().cursor = 0;
app.handle(key('V'));
app.diff.as_mut().unwrap().cursor = first_line;
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("selection copied to the clipboard");
let lines: Vec<&str> = text.lines().collect();
let card_at = lines
.iter()
.position(|line| line.contains("why this file changed"))
.expect("the card's prose is copied");
let code_at = lines
.iter()
.position(|line| {
line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')
})
.expect("a code line is copied");
assert!(
card_at < code_at,
"the card reads before the code under it: {lines:?}"
);
}
#[test]
fn y_with_no_selection_still_exports_comments_as_markdown() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.review.session.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"why 42?",
);
app.diff.as_mut().unwrap().invalidate();
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_none(),
"no selection is active"
);
app.handle(key('y'));
let text = app
.pending_clipboard
.clone()
.expect("clipboard text queued");
assert!(text.contains("why 42?"), "exported as markdown: {text:?}");
}
#[test]
fn yanking_a_selection_drops_the_anchor() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { .. }));
app.handle(key('V'));
app.handle(key('y'));
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_none(),
"the yank drops the anchor"
);
}
#[test]
fn y_with_no_comments_hints_instead_of_copying() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.diff.as_mut().unwrap().focus = Pane::Diff;
app.handle(key('y'));
assert_eq!(app.pending_clipboard, None);
let message = app.message.expect("message");
assert!(message.text.contains("no comments"));
}
#[test]
fn e_on_a_diff_line_opens_the_editor_at_that_line() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.config.editor.command = Some("vim".to_owned());
select_file(&mut app, "src/lib.rs");
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position;
app.handle(key('e'));
let request = app.pending_editor.clone().expect("editor request");
assert_eq!(
request.purpose,
crate::editor::EditorPurpose::OpenFile {
path: "src/lib.rs".to_owned(),
}
);
let absolute = fixture.root.join("src/lib.rs");
assert_eq!(
request.cmd,
vec![
"vim".to_owned(),
"+2".to_owned(),
absolute.to_string_lossy().into_owned(),
]
);
}
#[test]
fn e_from_the_list_opens_the_selected_file_without_a_line() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.config.editor.command = Some("vim".to_owned());
assert_eq!(focus(&app), Pane::List);
app.handle(key('e'));
let request = app.pending_editor.clone().expect("editor request");
assert!(
request.cmd.iter().all(|arg| !arg.starts_with('+')),
"no line jump from the list: {:?}",
request.cmd
);
}
#[test]
fn half_page_motions_move_by_the_viewport() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
app.diff.as_mut().unwrap().viewport = 10;
app.handle(ctrl_key('d'));
assert_eq!(app.diff.as_ref().unwrap().cursor, 5);
app.handle(ctrl_key('u'));
assert_eq!(app.diff.as_ref().unwrap().cursor, 0);
}
#[test]
fn list_focus_half_page_walks_the_file_list() {
let fixture = Fixture::new();
for index in 0..30 {
fixture.write(&format!("f{index:02}.txt"), "one\n");
}
fixture.commit_all("base");
for index in 0..30 {
fixture.write(&format!("f{index:02}.txt"), "one\ntwo\n");
}
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.diff.as_mut().unwrap().sidebar.height = 10;
app.diff.as_mut().unwrap().viewport = 10;
app.handle(ctrl_key('d'));
assert_eq!(tree_cursor(&app), 5, "half the sidebar's rows");
assert_eq!(
app.diff.as_ref().unwrap().cursor,
0,
"the diff pane stays where it was"
);
assert_eq!(focus(&app), Pane::List);
app.handle(ctrl_key('u'));
assert_eq!(tree_cursor(&app), 0);
app.handle(ctrl_key('f'));
assert_eq!(tree_cursor(&app), 9);
}
#[test]
fn comments_focus_half_page_walks_the_cards() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
for line in 1..=6 {
app.review.session.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: Some(line),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"look here",
);
}
app.handle(key('C'));
assert_eq!(focus(&app), Pane::Comments);
crate::test_support::render(&mut app);
let cursor = |app: &App| app.diff.as_ref().expect("diff").comments_cursor();
assert_eq!(cursor(&app), 0);
app.handle(ctrl_key('d'));
let tall = cursor(&app);
assert!(tall > 1, "a page covers several cards, moved {tall}");
app.handle(ctrl_key('u'));
assert_eq!(cursor(&app), 0, "and comes back by the same step");
app.diff.as_mut().expect("diff").comments_rect.height = 6;
app.handle(ctrl_key('d'));
let short = cursor(&app);
assert!(
short < tall,
"a 6-row pane pages {short} cards, a full-height one {tall}"
);
}
#[test]
fn hunk_jumps_move_between_headers() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
let first = app.diff.as_ref().unwrap().cursor;
assert!(matches!(rows(&app)[first], DiffRow::Hunk { hunk: 0, .. }));
app.handle(key(']'));
assert!(matches!(
rows(&app)[app.diff.as_ref().unwrap().cursor],
DiffRow::Hunk { hunk: 1, .. }
));
app.handle(key('['));
assert_eq!(app.diff.as_ref().unwrap().cursor, first);
}
#[test]
fn noop_refresh_preserves_the_visual_selection() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { .. }));
app.handle(key('V'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_some(),
"no-op refresh keeps the selection"
);
}
fn cursor_line_no(app: &App) -> u32 {
let diff = app.diff.as_ref().unwrap();
let model = diff.model(&app.review);
let DiffRow::Line { hunk, line, .. } = diff.rows()[diff.cursor] else {
panic!("cursor sits on a line")
};
model.files[0].hunks[hunk].lines[line].new_no.unwrap()
}
#[test]
fn the_cursor_stays_on_the_same_line_after_a_comment_lands_above_it() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
app.author = "reviewer".to_owned();
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| {
matches!(
r,
DiffRow::Line {
hunk: 1,
line: 2,
..
}
)
});
let before_row = app.diff.as_ref().unwrap().cursor;
let before_line = cursor_line_no(&app);
app.review.session.add_comment(
Anchor {
file: "data.txt".to_owned(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"look here first",
);
let diff = app.diff.as_mut().unwrap();
diff.mark_rows_dirty();
diff.ensure_rows(&app.review);
assert_ne!(
app.diff.as_ref().unwrap().cursor,
before_row,
"the new comment pushed the row down"
);
assert_eq!(
cursor_line_no(&app),
before_line,
"and the cursor followed it"
);
}
#[test]
fn the_cursor_stays_on_the_same_line_after_a_refresh_rebuilds_every_hunk() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| {
matches!(
r,
DiffRow::Line {
hunk: 1,
line: 2,
..
}
)
});
let before = cursor_line_no(&app);
let lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
let mut edited = lines;
edited[0] = "line one".to_owned();
edited[9] = "line ten edited".to_owned();
edited[19] = "line twenty".to_owned();
fixture.write("data.txt", &(edited.join("\n") + "\n"));
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
let diff = app.diff.as_ref().unwrap();
assert_eq!(
diff.rows()
.iter()
.filter(|r| matches!(r, DiffRow::Hunk { .. }))
.count(),
3,
"the new edit split off a third hunk, shifting the one the cursor was in"
);
assert_eq!(
cursor_line_no(&app),
before,
"the cursor followed the same line even though its hunk index moved"
);
}
#[test]
fn a_refresh_elsewhere_in_the_repo_keeps_the_visual_selection() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { .. }));
app.handle(key('V'));
fixture.write("zzz.md", "new\n");
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_some(),
"the selected rows are still there, so the selection survives"
);
}
#[test]
fn a_refresh_while_composing_leaves_the_cursor_on_the_caret_line() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { .. }));
app.handle(key('c'));
type_text(&mut app, "still typing");
assert!(app.composer_open(), "composer stayed open");
assert_eq!(
app.diff.as_ref().unwrap().cursor,
caret_row(&app),
"typing seats the cursor on the caret"
);
fixture.write("zzz.md", "new\n");
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
assert!(app.composer_open(), "the draft survives the refresh");
assert_eq!(
app.diff.as_ref().unwrap().cursor,
caret_row(&app),
"the refresh leaves the cursor on the caret's line, not the card's header"
);
}
#[test]
fn a_selection_whose_rows_are_gone_ends_rather_than_covering_something_else() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
enter_diff_pane(&mut app);
cursor_to_line(&mut app, |r| matches!(r, DiffRow::Line { hunk: 0, .. }));
app.handle(key('V'));
app.handle(key('j'));
assert!(app.diff.as_ref().unwrap().visual_anchor.is_some());
let lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
let mut edited = lines;
edited[19] = "line twenty".to_owned();
fixture.write("data.txt", &(edited.join("\n") + "\n"));
app.handle(AppEvent::RepoChanged);
app.settle_refresh();
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_none(),
"the selected rows are gone, so the selection ends rather than \
latching onto whatever now sits at the old index"
);
}
#[test]
fn a_banded_span_still_covers_the_same_lines_after_a_card_lands_inside_it() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
app.author = "reviewer".to_owned();
let id = app
.review
.session
.add_comment(
Anchor {
file: "data.txt".to_owned(),
line: Some(2),
line_end: Some(4),
on_old_side: false,
line_text: None,
},
"reviewer",
"range comment",
)
.id
.clone();
app.diff.as_mut().unwrap().mark_rows_dirty();
assert!(app.focus_comment(&id));
let line_no = |app: &App, row: usize| -> u32 {
let diff = app.diff.as_ref().unwrap();
let model = diff.model(&app.review);
let DiffRow::Line { hunk, line, .. } = diff.rows()[row] else {
panic!("band row is a line")
};
model.files[0].hunks[hunk].lines[line].new_no.unwrap()
};
let (start, end) = app
.diff
.as_ref()
.unwrap()
.referenced
.expect("a banded span");
assert_eq!(line_no(&app, start), 2);
assert_eq!(line_no(&app, end), 4);
app.review.session.add_comment(
Anchor {
file: "data.txt".to_owned(),
line: Some(3),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"in between",
);
let diff = app.diff.as_mut().unwrap();
diff.mark_rows_dirty();
diff.ensure_rows(&app.review);
let (start, end) = app
.diff
.as_ref()
.unwrap()
.referenced
.expect("the band survives a card landing inside it");
assert_eq!(line_no(&app, start), 2, "band still opens on line 2");
assert_eq!(line_no(&app, end), 4, "band still closes on line 4");
assert!(
end - start > 2,
"the new card's rows now sit inside the band"
);
}
#[test]
fn refresh_keeps_the_selected_file_by_path_when_files_shift() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
let path = selected_path(&app);
fixture.write("aaa.rs", "fn nope() {}\n");
app.handle(ctrl_key('r'));
assert_eq!(
selected_path(&app),
path,
"selection follows its file across the refresh"
);
}
#[test]
fn commit_diff_survives_refresh_untouched() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let oid = app.status.recent[0].oid.clone();
app.open_commit_diff(&oid);
assert_eq!(app.screen(), Screen::Diff);
let before = rows(&app).len();
fixture.write("zzz.md", "new\n");
app.handle(ctrl_key('r'));
assert_eq!(rows(&app).len(), before, "commit model is immutable");
}
#[test]
fn viewed_on_a_commit_diff_persists_to_that_source_not_the_working_tree() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let oid = app.status.recent[0].oid.clone();
app.open_commit_diff(&oid);
let path = selected_path(&app);
app.handle(key('m'));
let source = ReviewSource::commit(&oid);
assert!(
app.review.session_for(&source).viewed.contains_key(&path),
"viewed mark lands on the commit source"
);
assert!(
app.review.session.viewed.is_empty(),
"the working-tree session is untouched"
);
let mut reopened = App::new(fixture.review(), LoadedConfig::default());
reopened.open_commit_diff(&oid);
assert!(
reopened
.review
.session_for(&source)
.viewed
.contains_key(&path)
);
}
#[test]
fn yank_and_editor_on_a_commit_diff_use_that_sources_comments() {
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.review.session.add_comment(
diffler_core::session::Anchor {
file: "todo.md".into(),
line: Some(1),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"working-tree decoy",
);
let oid = app.status.recent[0].oid.clone();
app.open_commit_diff(&oid);
select_file(&mut app, "src/lib.rs");
let line_row = rows(&app)
.iter()
.position(|row| matches!(row, DiffRow::Line { .. }))
.expect("a diff line in the commit");
app.diff.as_mut().unwrap().focus = Pane::Diff;
app.diff.as_mut().unwrap().cursor = line_row;
app.handle(key('c'));
type_text(&mut app, "commit note");
app.handle(key('\n'));
app.handle(key('Y'));
let markdown = app.pending_clipboard.take().expect("yanked feedback");
assert!(markdown.contains("commit note"), "commit comment exported");
assert!(
!markdown.contains("working-tree decoy"),
"working-tree comments stay out of a commit yank"
);
let comment_row = rows(&app)
.iter()
.position(|row| matches!(row, DiffRow::Comment { .. }))
.expect("comment row present");
app.diff.as_mut().unwrap().cursor = comment_row;
app.handle(key('e'));
let request = app.pending_editor.take().expect("editor request");
let argv = request.cmd.join(" ");
assert!(
argv.contains("src/lib.rs") && !argv.contains("todo.md"),
"editor resolves the commit comment, not the working-tree one: {argv}"
);
}
#[test]
fn comment_display_lines_cover_body_and_replies() {
let mut session = Session::default();
let id = session
.add_comment(
Anchor {
file: "a.rs".to_owned(),
line: Some(1),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"first\nsecond",
)
.id
.clone();
session.reply(&id, "agent", "done\nand verified");
let lines = comment_display(&session.comments[0], u16::MAX, None, None, None);
let plain = |s: &str| MdSpan {
text: s.to_owned(),
..MdSpan::default()
};
assert_eq!(
lines,
vec![
CommentLine::Header,
CommentLine::Body(vec![plain("first")]),
CommentLine::Body(vec![plain("second")]),
CommentLine::Reply {
author: "agent".to_owned(),
spans: vec![plain("done")],
first: true,
},
CommentLine::Reply {
author: "agent".to_owned(),
spans: vec![plain("and verified")],
first: false,
},
CommentLine::Footer,
]
);
}
#[test]
fn comment_display_wraps_long_text_to_the_pane_width() {
let mut session = Session::default();
let id = session
.add_comment(
Anchor {
file: "a.rs".to_owned(),
line: Some(1),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"a body long enough that it cannot fit on one row",
)
.id
.clone();
session.reply(&id, "agent", "a reply that also runs past the pane");
let lines = comment_display(&session.comments[0], 30, None, None, None);
let budget = 30 - 4;
let text = |runs: &[MdSpan]| runs.iter().map(|s| s.text.clone()).collect::<String>();
for line in &lines {
match line {
CommentLine::Body(runs) => assert!(text(runs).width() <= budget, "{runs:?}"),
CommentLine::Reply { spans, first, .. } => {
let head = if *first { "â”” agent: ".width() } else { 2 };
assert!(text(spans).width() + head <= budget, "{spans:?}");
}
_ => {}
}
}
let bodies = lines
.iter()
.filter(|l| matches!(l, CommentLine::Body(_)))
.count();
assert!(bodies > 1, "the long body wrapped onto several rows");
let joined: Vec<String> = lines
.iter()
.filter_map(|l| match l {
CommentLine::Body(runs) => Some(text(runs)),
_ => None,
})
.collect();
assert_eq!(
joined.join(" "),
"a body long enough that it cannot fit on one row"
);
}
#[test]
fn comment_display_hard_splits_an_unbreakable_token() {
let mut session = Session::default();
session.add_comment(
Anchor {
file: "a.rs".to_owned(),
line: Some(1),
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"https://example.invalid/a/very/long/unbroken/path/segment/thing",
);
let lines = comment_display(&session.comments[0], 24, None, None, None);
for line in &lines {
if let CommentLine::Body(runs) = line {
let width: usize = runs.iter().map(|s| s.text.width()).sum();
assert!(width <= 20, "{runs:?}");
}
}
}
#[test]
fn deleting_a_comment_asks_for_confirmation_first() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.handle(key('c'));
type_text(&mut app, "why 42?");
app.handle(key('\n'));
let diff = app.diff.as_mut().unwrap();
diff.ensure_rows(&app.review);
let comment_row = rows(&app)
.iter()
.position(|r| matches!(r, DiffRow::Comment { .. }))
.expect("comment row present");
app.diff.as_mut().unwrap().cursor = comment_row;
app.delete_comment_at_cursor();
assert!(
matches!(app.modal, Some(Modal::Confirm { .. })),
"delete asks first"
);
assert_eq!(
app.review.session.comments.len(),
1,
"nothing deleted before confirming"
);
app.confirm_modal();
assert_eq!(
app.review.session.comments.len(),
0,
"confirm deletes the comment"
);
}
#[test]
fn claiming_a_comment_toggles_authorship_and_back() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let crate::mcp::McpResponse::Added { .. } =
app.handle_mcp(crate::mcp::McpRequestKind::AddComment {
file: "src/lib.rs".to_owned(),
line: 2,
line_end: None,
body: "looks off".to_owned(),
as_human: false,
})
else {
panic!("expected an added comment");
};
select_file(&mut app, "src/lib.rs");
let diff = app.diff.as_mut().unwrap();
diff.ensure_rows(&app.review);
let comment_row = rows(&app)
.iter()
.position(|r| matches!(r, DiffRow::Comment { .. }))
.expect("comment row present");
app.diff.as_mut().unwrap().cursor = comment_row;
app.claim_comment_at_cursor();
assert_eq!(app.review.session.comments[0].author, "reviewer");
app.claim_comment_at_cursor();
assert_eq!(
app.review.session.comments[0].author,
crate::mcp::AGENT_AUTHOR
);
}
#[test]
fn claiming_on_a_row_that_is_not_a_comment_says_so() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
select_file(&mut app, "src/lib.rs");
app.diff.as_mut().unwrap().cursor = added_line_position(&app);
app.claim_comment_at_cursor();
let message = app.message.clone().expect("info message");
assert!(message.text.contains("move onto a comment"), "{message:?}");
}
#[test]
fn claiming_all_comments_asks_first_claims_every_agent_comment_and_leaves_a_human_one() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let crate::mcp::McpResponse::Added { id: agent_id } =
app.handle_mcp(crate::mcp::McpRequestKind::AddComment {
file: "src/lib.rs".to_owned(),
line: 2,
line_end: None,
body: "agent found this".to_owned(),
as_human: false,
})
else {
panic!("expected an added comment");
};
let human_id = app
.review
.session
.add_comment(
Anchor {
file: "src/lib.rs".to_owned(),
line: None,
line_end: None,
on_old_side: false,
line_text: None,
},
"reviewer",
"human's own",
)
.id
.clone();
app.claim_all_comments_start();
assert!(
matches!(app.modal, Some(Modal::Confirm { .. })),
"claiming all asks first"
);
assert_eq!(
app.review.session.comment(&agent_id).unwrap().author,
crate::mcp::AGENT_AUTHOR,
"nothing claimed before confirming"
);
app.handle(key('n'));
assert!(app.modal.is_none(), "declining closes the dialog");
assert_eq!(
app.review.session.comment(&agent_id).unwrap().author,
crate::mcp::AGENT_AUTHOR,
"declining changes nothing"
);
app.claim_all_comments_start();
app.handle(key('y'));
assert_eq!(
app.review.session.comment(&agent_id).unwrap().author,
"reviewer",
"confirming claims the agent comment"
);
assert_eq!(
app.review.session.comment(&human_id).unwrap().author,
"reviewer",
"the human comment was always theirs, untouched"
);
}
#[test]
fn claiming_a_stop_on_a_walkthrough_is_refused() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.handle(key('j'));
app.handle(key('j'));
enter_diff_pane(&mut app);
let card = rows(&app)
.iter()
.position(|row| matches!(row, DiffRow::Comment { line: 0, .. }))
.expect("a stop card");
app.diff.as_mut().expect("diff view").cursor = card;
app.claim_comment_at_cursor();
let message = app.message.clone().expect("info message");
assert!(message.text.contains("claimable"), "{message:?}");
let source = app.active_review_source();
assert_eq!(
app.review
.session_for(&source)
.comment("stop-2")
.unwrap()
.author,
crate::mcp::AGENT_AUTHOR,
"the stop stays the agent's"
);
}
#[test]
fn claiming_all_comments_on_a_walkthrough_is_refused_without_a_prompt() {
let fixture = standard_fixture();
let mut app = walkthrough_app(&fixture);
app.claim_all_comments_start();
assert!(app.modal.is_none(), "no confirm prompt for a walkthrough");
let message = app.message.clone().expect("info message");
assert!(message.text.contains("claimable"), "{message:?}");
let source = app.active_review_source();
assert!(
app.review
.session_for(&source)
.comments
.iter()
.all(|c| c.author == crate::mcp::AGENT_AUTHOR),
"every stop stays the agent's"
);
}
#[test]
fn build_split_rows_aligns_old_and_new_sides() {
let fixture = standard_fixture();
let app = diff_app(&fixture);
let diff = app.diff.as_ref().unwrap();
let model = diff.model(&app.review);
let session = app.review.session_for(&ReviewSource::WorkingTree);
for (index, file) in model.files.iter().enumerate() {
for row in build_split_rows(
model,
session,
index,
u16::MAX,
None,
&diff.figures,
&diff.unresolved_anchors,
) {
let SplitRow::Pair { hunk, left, right } = row else {
continue;
};
assert!(left.is_some() || right.is_some(), "a pair fills a side");
let lines = &file.hunks[hunk].lines;
if let Some(l) = left {
assert!(matches!(
lines[l].kind,
LineKind::Deleted | LineKind::Context
));
}
if let Some(r) = right {
assert!(matches!(lines[r].kind, LineKind::Added | LineKind::Context));
}
if left == right {
assert!(matches!(lines[left.unwrap()].kind, LineKind::Context));
}
}
}
}
#[test]
fn toggle_side_by_side_flips_the_mode() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.diff.as_mut().unwrap().focus = Pane::Diff;
assert!(!app.diff.as_ref().unwrap().side_by_side);
app.handle(key('|'));
assert!(app.diff.as_ref().unwrap().side_by_side);
app.handle(key('|'));
assert!(!app.diff.as_ref().unwrap().side_by_side);
}
#[test]
fn yank_in_the_sidebar_copies_the_path_the_row_names() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.diff.as_mut().unwrap().focus = Pane::List;
app.handle(key('y'));
let path = app
.diff
.as_ref()
.and_then(|diff| {
let model = diff.model(&app.review);
model.files.get(diff.selected).map(|file| file.path.clone())
})
.expect("a file under the cursor");
assert_eq!(app.pending_clipboard.as_deref(), Some(path.as_str()));
assert!(
app.message
.as_ref()
.is_some_and(|m| !m.text.contains("no comments")),
"the sidebar never reaches the feedback copy"
);
}
#[test]
fn commenting_in_side_by_side_is_redirected_to_unified() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
let diff = app.diff.as_mut().unwrap();
diff.focus = Pane::Diff;
diff.side_by_side = true;
app.handle(key('c'));
assert!(app.modal.is_none(), "no comment modal opens in split mode");
assert!(
app.message
.as_ref()
.is_some_and(|m| m.text.contains("unified")),
"the message points to the unified view"
);
}
#[test]
fn paren_jumps_land_on_definition_starts() {
use crate::test_support::standard_fixture;
let fixture = standard_fixture();
fixture.write(
"src/lib.rs",
"pub fn answer() -> u32 {\n 42\n}\n\npub fn other() -> u32 {\n 7\n}\n",
);
let mut app =
crate::app::App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_working_tree_diff(Some("src/lib.rs"));
let (path, content) = ("src/lib.rs".to_owned(), fixture_content(&app));
let index = app.highlighter.scope_index(&path, &content);
assert!(!index.is_empty(), "rust definitions indexed");
let hash = current_hash(&app);
if let Some(diff) = app.diff.as_mut() {
diff.scopes
.insert(path, crate::app::diff::FileScope { hash, index });
diff.ensure_rows(&app.review);
diff.focus = Pane::Diff;
}
app.handle(crate::test_support::key(')'));
let diff = app.diff.as_ref().expect("diff");
let on_def = matches!(
diff.rows().get(diff.cursor),
Some(DiffRow::Line { file, hunk, line })
if app.review.model().files.get(*file)
.and_then(|f| f.hunks.get(*hunk))
.and_then(|h| h.lines.get(*line))
.is_some_and(|l| l.text.contains("fn "))
);
assert!(on_def, "cursor row {} is a definition line", diff.cursor);
}
fn against_paths(app: &App) -> Vec<String> {
app.diff
.as_ref()
.and_then(|diff| diff.commit_model.as_ref())
.expect("pinned model")
.files
.iter()
.map(|f| f.path.clone())
.collect()
}
fn against_main(fixture: &Fixture) -> App {
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('d'));
app.handle(key('d'));
app
}
#[test]
fn the_diff_transient_reviews_the_branch_against_its_base() {
let fixture = crate::test_support::branch_fixture();
let app = against_main(&fixture);
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(diff.source, ReviewSource::against("main"));
assert_eq!(app.against_rev(), Some("main"));
assert_eq!(against_paths(&app), ["dirty.rs", "landed.rs"]);
assert_eq!(app.screen(), Screen::Diff);
}
#[test]
fn an_against_review_tracks_edits_through_the_refresh() {
let fixture = crate::test_support::branch_fixture();
let mut app = against_main(&fixture);
fixture.write("later.rs", "pub fn later() {}\n");
app.queue_refresh();
app.settle_refresh();
assert_eq!(against_paths(&app), ["dirty.rs", "landed.rs", "later.rs"]);
}
#[test]
fn a_refresh_answering_for_another_rev_leaves_the_open_review_alone() {
let fixture = crate::test_support::branch_fixture();
let mut app = against_main(&fixture);
app.apply_refresh(diffler_core::review::Refreshed {
status: diffler_core::vcs::StatusModel::default(),
model: DiffModel::default(),
against: Some(("develop".to_owned(), Ok(DiffModel::default()))),
pinned: None,
});
assert_eq!(against_paths(&app), ["dirty.rs", "landed.rs"]);
}
#[test]
fn the_branch_picker_reviews_against_the_chosen_branch() {
let fixture = crate::test_support::branch_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('d'));
app.handle(key('b'));
assert!(matches!(app.modal, Some(Modal::RevList { .. })));
app.handle(code_key(crossterm::event::KeyCode::Tab));
for c in "main".chars() {
app.handle(key(c));
}
app.handle(key('\n'));
assert!(app.modal.is_none());
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(diff.source, ReviewSource::against("main"));
}
#[test]
fn the_commit_picker_reviews_against_the_chosen_commit() {
let fixture = crate::test_support::branch_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('d'));
app.handle(key('s'));
let Some(Modal::RevList { entries, .. }) = &app.modal else {
panic!("rev picker open, got {:?}", app.modal);
};
assert!(entries[0].label.contains("feature work"), "{entries:?}");
app.handle(key('\n'));
assert_eq!(against_paths(&app), ["dirty.rs"]);
}
#[test]
fn a_rev_that_does_not_resolve_reports_instead_of_panicking() {
let fixture = crate::test_support::branch_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.open_against_diff("no-such-branch");
assert!(app.diff.is_none());
assert!(
matches!(&app.message, Some(m) if m.severity == crate::app::Severity::Error),
"{:?}",
app.message
);
}
#[test]
fn w_goes_back_to_the_plain_working_tree_review() {
let fixture = crate::test_support::branch_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
app.handle(key('d'));
app.handle(key('w'));
let diff = app.diff.as_ref().expect("diff view");
assert_eq!(diff.source, ReviewSource::WorkingTree);
assert_eq!(app.against_rev(), None);
}
fn fixture_content(app: &crate::app::App) -> String {
app.review
.model()
.files
.iter()
.find(|f| f.path == "src/lib.rs")
.and_then(|f| f.new_text.clone())
.expect("new text")
}
fn current_hash(app: &crate::app::App) -> String {
app.review
.model()
.files
.iter()
.find(|f| f.path == "src/lib.rs")
.map(diffler_core::model::FileDiff::sides_hash)
.expect("hash")
}
}