use std::collections::{BTreeSet, HashMap, HashSet};
use diffler_core::feedback::{self, FeedbackOptions};
use diffler_core::highlight::StyledRange;
use diffler_core::model::{DiffModel, FileDiff, LineKind};
use diffler_core::review::Review;
use diffler_core::session::{Anchor, Comment, CommentStatus, Session};
pub use diffler_core::source::ReviewSource as DiffSource;
use super::{App, InputOp, Modal, Screen};
use crate::clipboard;
use crate::config::FileLayout;
use crate::keymap::Action;
use crate::tree::{self, TreeNode, TreeRow};
use crate::ui::diff_render::SplitSide;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
List,
Diff,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffRow {
Hunk {
file: usize,
hunk: usize,
},
Line {
file: usize,
hunk: usize,
line: usize,
},
Comment {
comment: usize,
line: usize,
outdated: bool,
},
}
pub struct FileHighlights {
pub hash: String,
pub old: Vec<Vec<StyledRange>>,
pub new: Vec<Vec<StyledRange>>,
}
pub struct DiffView {
pub source: DiffSource,
pub(crate) commit_model: Option<DiffModel>,
pub focus: Pane,
layout: FileLayout,
pub selected: usize,
pub(crate) folded_dirs: BTreeSet<String>,
pub(crate) tree_cursor: usize,
pub cursor: usize,
pub scroll: usize,
pub side_by_side: bool,
pub(crate) split_scroll: usize,
pub visual_anchor: Option<usize>,
pub(crate) viewport: u16,
pub(crate) rows: Vec<DiffRow>,
rows_dirty: bool,
pub(crate) highlights: HashMap<String, FileHighlights>,
enriched: HashSet<String>,
}
impl DiffView {
fn new(
source: DiffSource,
commit_model: Option<DiffModel>,
review: &Review,
layout: FileLayout,
side_by_side: bool,
) -> Self {
let mut view = Self {
source,
commit_model,
focus: Pane::List,
layout,
selected: 0,
folded_dirs: BTreeSet::new(),
tree_cursor: 0,
cursor: 0,
scroll: 0,
side_by_side,
split_scroll: 0,
visual_anchor: None,
viewport: 0,
rows: Vec::new(),
rows_dirty: true,
highlights: HashMap::new(),
enriched: HashSet::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 enrich_selected(&mut self, review_model: Option<&mut DiffModel>) {
let selected = self.selected;
let model = match self.commit_model.as_mut() {
Some(model) => model,
None => match review_model {
Some(model) => model,
None => return,
},
};
let Some(file) = model.files.get_mut(selected) else {
return;
};
if self.enriched.insert(file.path.clone()) {
diffler_core::pairing::enrich_file(file);
}
}
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,
),
}
}
pub fn selected_path(&self, review: &Review) -> Option<String> {
self.model(review)
.files
.get(self.selected)
.map(|f| f.path.clone())
}
pub(crate) fn invalidate(&mut self) {
self.rows_dirty = true;
self.visual_anchor = None;
self.enriched.clear();
}
pub(crate) fn ensure_rows(&mut self, review: &Review) {
if !self.rows_dirty {
return;
}
let model = self.commit_model.as_ref().unwrap_or_else(|| review.model());
self.selected = self.selected.min(model.files.len().saturating_sub(1));
self.rows = build_rows(model, review.session_for(&self.source), self.selected);
self.rows_dirty = false;
self.cursor = self.cursor.min(self.rows.len().saturating_sub(1));
self.scroll = self.scroll.min(self.rows.len().saturating_sub(1));
let tree_rows = self.tree_rows(model);
self.tree_cursor = tree_position_of_file(&tree_rows, self.selected)
.unwrap_or_else(|| self.tree_cursor.min(tree_rows.len().saturating_sub(1)));
}
pub fn selection(&self) -> Option<(usize, usize)> {
let anchor = self.visual_anchor?;
Some((anchor.min(self.cursor), anchor.max(self.cursor)))
}
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.rows_dirty = true;
self.ensure_rows(review);
}
pub(crate) fn tree_rows(&self, model: &DiffModel) -> Vec<TreeRow> {
match self.layout {
FileLayout::List => model
.files
.iter()
.enumerate()
.map(|(index, file)| TreeRow {
depth: 0,
node: TreeNode::File {
index,
name: file.path.clone(),
},
})
.collect(),
FileLayout::Tree => {
let paths: Vec<&str> = model.files.iter().map(|f| f.path.as_str()).collect();
tree::visible_rows(&paths, &self.folded_dirs)
}
}
}
}
fn tree_position_of_file(rows: &[TreeRow], file_index: usize) -> Option<usize> {
rows.iter()
.position(|row| matches!(&row.node, TreeNode::File { index, .. } if *index == file_index))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CommentLine {
Header,
Body(String),
Reply {
author: String,
text: String,
first: bool,
},
Footer,
}
pub fn comment_display(comment: &Comment) -> Vec<CommentLine> {
let mut lines = vec![CommentLine::Header];
lines.extend(
comment
.body
.lines()
.map(|l| CommentLine::Body(l.to_owned())),
);
for reply in &comment.replies {
let mut first = true;
for text in reply.body.lines() {
lines.push(CommentLine::Reply {
author: reply.author.clone(),
text: text.to_owned(),
first,
});
first = false;
}
}
lines.push(CommentLine::Footer);
lines
}
fn anchor_target(file: &FileDiff, anchor: &Anchor) -> Option<(usize, usize)> {
let target = anchor.line_end.or(anchor.line)?;
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
let found = hunk.lines.iter().position(|l| {
let no = if anchor.on_old_side {
l.old_no
} else {
l.new_no
};
no == Some(target)
});
if let Some(line_idx) = found {
return Some((hunk_idx, line_idx));
}
}
None
}
fn push_comment_rows(rows: &mut Vec<DiffRow>, session: &Session, comments: &[(usize, bool)]) {
for &(comment, outdated) in comments {
let Some(c) = session.comments.get(comment) else {
continue;
};
let count = comment_display(c).len();
rows.extend((0..count).map(|line| DiffRow::Comment {
comment,
line,
outdated,
}));
}
}
type CommentBuckets = (
HashMap<(usize, usize), Vec<(usize, bool)>>,
Vec<(usize, bool)>,
);
fn collect_comments(file: &FileDiff, session: &Session, model: &DiffModel) -> CommentBuckets {
let mut by_line: HashMap<(usize, usize), Vec<(usize, bool)>> = HashMap::new();
let mut unanchored: Vec<(usize, bool)> = Vec::new();
for (comment_idx, comment) in session.comments.iter().enumerate() {
if comment.anchor.file != file.path {
continue;
}
let outdated = comment.anchor.is_outdated(model);
match anchor_target(file, &comment.anchor) {
Some((hunk, line)) => by_line
.entry((hunk, line))
.or_default()
.push((comment_idx, outdated)),
None => unanchored.push((comment_idx, outdated)),
}
}
(by_line, unanchored)
}
fn build_rows(model: &DiffModel, session: &Session, selected: usize) -> Vec<DiffRow> {
let mut rows = Vec::new();
let Some(file) = model.files.get(selected) else {
return rows;
};
let (by_line, unanchored) = collect_comments(file, session, model);
push_comment_rows(&mut rows, session, &unanchored);
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
file: selected,
hunk: hunk_idx,
});
for line_idx in 0..hunk.lines.len() {
rows.push(DiffRow::Line {
file: selected,
hunk: hunk_idx,
line: line_idx,
});
if let Some(list) = by_line.get(&(hunk_idx, line_idx)) {
push_comment_rows(&mut rows, session, list);
}
}
}
rows
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplitRow {
Hunk {
hunk: usize,
},
Pair {
hunk: usize,
left: Option<usize>,
right: Option<usize>,
},
Comment {
comment: usize,
line: usize,
outdated: bool,
},
}
fn push_split_comments(rows: &mut Vec<SplitRow>, session: &Session, comments: &[(usize, bool)]) {
for &(comment, outdated) in comments {
let Some(c) = session.comments.get(comment) else {
continue;
};
let count = comment_display(c).len();
rows.extend((0..count).map(|line| SplitRow::Comment {
comment,
line,
outdated,
}));
}
}
fn flush_change_block(
rows: &mut Vec<SplitRow>,
session: &Session,
by_line: &HashMap<(usize, usize), Vec<(usize, bool)>>,
hunk: usize,
dels: &[usize],
adds: &[usize],
) {
for k in 0..dels.len().max(adds.len()) {
let left = dels.get(k).copied();
let right = adds.get(k).copied();
rows.push(SplitRow::Pair { hunk, left, right });
for line in [left, right].into_iter().flatten() {
if let Some(list) = by_line.get(&(hunk, line)) {
push_split_comments(rows, session, list);
}
}
}
}
pub(crate) fn build_split_rows(
model: &DiffModel,
session: &Session,
selected: usize,
) -> Vec<SplitRow> {
let mut rows = Vec::new();
let Some(file) = model.files.get(selected) else {
return rows;
};
let (by_line, unanchored) = collect_comments(file, session, model);
push_split_comments(&mut rows, session, &unanchored);
for (hunk_idx, hunk) in file.hunks.iter().enumerate() {
rows.push(SplitRow::Hunk { hunk: hunk_idx });
let mut dels: Vec<usize> = Vec::new();
let mut adds: Vec<usize> = Vec::new();
for (line_idx, line) in hunk.lines.iter().enumerate() {
match line.kind {
LineKind::Context => {
flush_change_block(&mut rows, session, &by_line, hunk_idx, &dels, &adds);
dels.clear();
adds.clear();
rows.push(SplitRow::Pair {
hunk: hunk_idx,
left: Some(line_idx),
right: Some(line_idx),
});
if let Some(list) = by_line.get(&(hunk_idx, line_idx)) {
push_split_comments(&mut rows, session, list);
}
}
LineKind::Deleted => dels.push(line_idx),
LineKind::Added => adds.push(line_idx),
}
}
flush_change_block(&mut rows, session, &by_line, hunk_idx, &dels, &adds);
}
rows
}
impl App {
pub(crate) fn open_working_tree_diff(&mut self, scope: Option<&str>) {
self.open_working_tree_diff_focused(scope, Pane::List);
}
pub(crate) fn open_working_tree_file(&mut self, path: &str) {
self.open_working_tree_diff_focused(Some(path), Pane::Diff);
}
fn open_working_tree_diff_focused(&mut self, scope: Option<&str>, focus: Pane) {
let mut view = DiffView::new(
DiffSource::WorkingTree,
None,
&self.review,
self.config.ui.diff_file_layout,
self.config.ui.side_by_side,
);
if let Some(path) = scope
&& let Some(index) = self
.review
.model()
.files
.iter()
.position(|f| f.path == path)
{
view.selected = index;
view.invalidate();
view.ensure_rows(&self.review);
}
view.focus = focus;
self.diff = Some(view);
self.screens.push(Screen::Diff);
}
pub(crate) fn open_commit_diff(&mut self, oid: &str) {
match self.review.vcs.commit_diff(oid) {
Ok(model) => {
let source = DiffSource::commit(oid);
if let Err(err) = self.review.ensure_source(&source) {
self.error(err.to_string());
return;
}
let view = DiffView::new(
source,
Some(model),
&self.review,
self.config.ui.diff_file_layout,
self.config.ui.side_by_side,
);
self.diff = Some(view);
self.screens.push(Screen::Diff);
}
Err(err) => self.error(err.to_string()),
}
}
pub(crate) fn open_range_diff(&mut self, oldest: &str, newest: &str) {
match self.review.vcs.range_diff(oldest, newest) {
Ok(model) => {
let source = DiffSource::range(oldest, newest);
if let Err(err) = self.review.ensure_source(&source) {
self.error(err.to_string());
return;
}
let view = DiffView::new(
source,
Some(model),
&self.review,
self.config.ui.diff_file_layout,
self.config.ui.side_by_side,
);
self.diff = Some(view);
self.screens.push(Screen::Diff);
}
Err(err) => self.error(err.to_string()),
}
}
pub(super) fn dispatch_diff(&mut self, action: Action) {
if let Some(diff) = self.diff.as_mut() {
diff.ensure_rows(&self.review);
} else {
return;
}
match action {
Action::NextFile => return self.diff_step_file(true),
Action::PrevFile => return self.diff_step_file(false),
Action::ToggleFocus => return self.diff_toggle_focus(),
Action::ToggleSideBySide => return self.toggle_side_by_side(),
Action::NextComment => {
self.diff_focus(Pane::Diff);
return self.diff_jump_comment(true);
}
Action::PrevComment => {
self.diff_focus(Pane::Diff);
return self.diff_jump_comment(false);
}
_ => {}
}
match self.diff.as_ref().map(|d| d.focus) {
Some(Pane::List) => self.dispatch_diff_list(action),
Some(Pane::Diff) => self.dispatch_diff_pane(action),
None => {}
}
}
fn dispatch_diff_list(&mut self, action: Action) {
match action {
Action::MoveDown => self.diff_tree_step(1),
Action::MoveUp => self.diff_tree_step(-1),
Action::GoTop => self.diff_tree_to(0),
Action::GoBottom => self.diff_tree_to(usize::MAX),
Action::HalfPageDown => self.diff_move(self.diff_half_page()),
Action::HalfPageUp => self.diff_move(-self.diff_half_page()),
Action::Open => self.diff_tree_activate(),
Action::ToggleFold => self.diff_toggle_dir_fold(),
Action::MarkViewed => self.diff_toggle_viewed(),
Action::OpenEditor => self.editor_at_diff_cursor(),
Action::CopyFileFeedback => self.copy_feedback(true),
Action::CopyAllFeedback => self.copy_feedback(false),
Action::Comment | Action::VisualSelect | Action::Reply | Action::Resolve => {
self.info("move into the diff to comment");
}
_ => {}
}
}
fn dispatch_diff_pane(&mut self, action: Action) {
match action {
Action::MoveDown => self.diff_move(1),
Action::MoveUp => self.diff_move(-1),
Action::GoTop => self.diff_move(isize::MIN),
Action::GoBottom => self.diff_move(isize::MAX),
Action::HalfPageDown => self.diff_move(self.diff_half_page()),
Action::HalfPageUp => self.diff_move(-self.diff_half_page()),
Action::NextHunk => self.diff_jump(true, |row| matches!(row, DiffRow::Hunk { .. })),
Action::PrevHunk => self.diff_jump(false, |row| matches!(row, DiffRow::Hunk { .. })),
Action::Open => self.diff_focus(Pane::List),
Action::Comment | Action::VisualSelect | Action::Reply | Action::Resolve
if self.diff.as_ref().is_some_and(|d| d.side_by_side) =>
{
self.info("switch to the unified view (|) to comment");
}
Action::Comment => self.comment_at_cursor(),
Action::VisualSelect => self.toggle_visual(),
Action::Reply => self.reply_at_cursor(),
Action::Resolve => self.resolve_at_cursor(),
Action::MarkViewed => self.diff_toggle_viewed(),
Action::CopyFileFeedback => self.copy_feedback(true),
Action::CopyAllFeedback => self.copy_feedback(false),
Action::OpenEditor => self.editor_at_diff_cursor(),
Action::ToggleFold => {}
other => {
self.info(format!("{} is not implemented yet", other.name()));
}
}
}
fn toggle_side_by_side(&mut self) {
let Some(diff) = self.diff.as_mut() else {
return;
};
diff.side_by_side = !diff.side_by_side;
diff.split_scroll = 0;
self.info(if self.diff.as_ref().is_some_and(|d| d.side_by_side) {
"side-by-side"
} else {
"unified"
});
}
fn diff_toggle_focus(&mut self) {
let Some(diff) = self.diff.as_mut() else {
return;
};
diff.focus = match diff.focus {
Pane::List => Pane::Diff,
Pane::Diff => Pane::List,
};
}
fn diff_focus(&mut self, pane: Pane) {
if let Some(diff) = self.diff.as_mut() {
diff.focus = pane;
}
}
fn diff_tree_step(&mut self, delta: isize) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let rows = diff.tree_rows(diff.model(&self.review));
if rows.is_empty() {
return;
}
let target = diff
.tree_cursor
.saturating_add_signed(delta)
.min(rows.len() - 1);
self.diff_tree_to(target);
}
fn diff_tree_to(&mut self, target: usize) {
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let rows = diff.tree_rows(diff.model(review));
if rows.is_empty() {
return;
}
let target = target.min(rows.len() - 1);
diff.tree_cursor = target;
if let Some(TreeRow {
node: TreeNode::File { index, .. },
..
}) = rows.get(target)
{
let index = *index;
diff.select(index, review);
diff.tree_cursor = target;
}
}
fn diff_tree_activate(&mut self) {
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let rows = diff.tree_rows(diff.model(review));
match rows.get(diff.tree_cursor).map(|r| &r.node) {
Some(TreeNode::File { .. }) => self.diff_focus(Pane::Diff),
Some(TreeNode::Dir { .. }) => self.diff_toggle_dir_fold(),
None => {}
}
}
fn diff_toggle_dir_fold(&mut self) {
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let rows = diff.tree_rows(diff.model(review));
let Some(TreeRow {
node: TreeNode::Dir { path, .. },
..
}) = rows.get(diff.tree_cursor)
else {
return;
};
let path = path.clone();
if !diff.folded_dirs.remove(&path) {
diff.folded_dirs.insert(path);
}
let rows = diff.tree_rows(diff.model(review));
diff.tree_cursor = diff.tree_cursor.min(rows.len().saturating_sub(1));
}
fn diff_step_file(&mut self, forward: bool) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let rows = diff.tree_rows(diff.model(&self.review));
let is_file = |row: &TreeRow| matches!(row.node, TreeNode::File { .. });
let target = if forward {
rows.iter()
.enumerate()
.skip(diff.tree_cursor + 1)
.find(|(_, row)| is_file(row))
.map(|(index, _)| index)
} else {
rows.iter()
.enumerate()
.take(diff.tree_cursor)
.rfind(|(_, row)| is_file(row))
.map(|(index, _)| index)
};
if let Some(target) = target {
self.diff_tree_to(target);
}
}
fn editor_at_diff_cursor(&mut self) {
let target = self.diff.as_ref().and_then(|diff| {
let model = diff.model(&self.review);
let file = model.files.get(diff.selected)?;
if diff.focus == Pane::List {
return Some((file.path.clone(), None));
}
match diff.rows.get(diff.cursor) {
Some(DiffRow::Hunk { .. }) | None => Some((file.path.clone(), None)),
Some(DiffRow::Line { hunk, line, .. }) => {
let line = file.hunks.get(*hunk)?.lines.get(*line)?;
Some((file.path.clone(), line.new_no.or(line.old_no)))
}
Some(DiffRow::Comment { comment, .. }) => self
.review
.session
.comments
.get(*comment)
.map(|c| (c.anchor.file.clone(), c.anchor.line_end.or(c.anchor.line))),
}
});
match target {
Some((path, line)) => self.request_editor(&path, line),
None => self.info("no file under the cursor"),
}
}
fn diff_move(&mut self, delta: isize) {
let Some(diff) = self.diff.as_mut() else {
return;
};
let last = diff.rows.len().saturating_sub(1);
diff.cursor = diff.cursor.saturating_add_signed(delta).min(last);
}
fn diff_half_page(&self) -> isize {
let viewport = self.diff.as_ref().map_or(0, |d| d.viewport);
let half = if viewport == 0 {
20
} else {
i64::from(viewport) / 2
};
isize::try_from(half).unwrap_or(20).max(1)
}
fn diff_jump_comment(&mut self, forward: bool) {
self.diff_jump(forward, |row| {
matches!(row, DiffRow::Comment { line: 0, .. })
});
}
fn diff_jump(&mut self, forward: bool, target: impl Fn(&DiffRow) -> bool) {
let Some(diff) = self.diff.as_mut() else {
return;
};
let position = if forward {
diff.rows
.iter()
.enumerate()
.skip(diff.cursor + 1)
.find(|(_, row)| target(row))
.map(|(index, _)| index)
} else {
diff.rows
.iter()
.enumerate()
.take(diff.cursor)
.rfind(|(_, row)| target(row))
.map(|(index, _)| index)
};
if let Some(position) = position {
diff.cursor = position;
}
}
pub(crate) fn diff_cursor_path(&self) -> Option<String> {
let diff = self.diff.as_ref()?;
diff.selected_path(&self.review)
}
pub(crate) fn enrich_diff_selected_file(&mut self) {
let on_commit = self
.diff
.as_ref()
.is_some_and(|diff| diff.commit_model.is_some());
if on_commit {
if let Some(diff) = self.diff.as_mut() {
diff.enrich_selected(None);
}
} else if self.diff.is_some() {
let model = self.review.model_mut();
if let Some(diff) = self.diff.as_mut() {
diff.enrich_selected(Some(model));
}
}
}
pub(super) fn restore_diff_cursor(&mut self, path: Option<String>) {
let Some(path) = path else {
return;
};
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let model = diff.commit_model.as_ref().unwrap_or_else(|| review.model());
match model.files.iter().position(|f| f.path == path) {
Some(index) if index != diff.selected => diff.selected = index,
Some(_) => return,
None => {}
}
diff.invalidate();
diff.ensure_rows(review);
}
fn toggle_visual(&mut self) {
let Some(diff) = self.diff.as_mut() else {
return;
};
if diff.visual_anchor.take().is_some() {
return;
}
if matches!(diff.rows.get(diff.cursor), Some(DiffRow::Line { .. })) {
diff.visual_anchor = Some(diff.cursor);
} else {
self.info("move to a diff line to start a selection");
}
}
fn comment_anchor(&self) -> Option<Anchor> {
let diff = self.diff.as_ref()?;
let model = diff.model(&self.review);
let line_at = |row: &DiffRow| -> Option<(
usize,
&diffler_core::model::Hunk,
&diffler_core::model::DiffLine,
)> {
let DiffRow::Line { file, hunk, line } = row else {
return None;
};
let hunk_data = model.files.get(*file)?.hunks.get(*hunk)?;
Some((*file, hunk_data, hunk_data.lines.get(*line)?))
};
let anchor_row = diff.visual_anchor.unwrap_or(diff.cursor);
let (file_idx, hunk, line) = line_at(diff.rows.get(anchor_row)?)?;
let file = model.files.get(file_idx)?;
let on_old_side = line.new_no.is_none();
let number = |l: &diffler_core::model::DiffLine| {
if on_old_side { l.old_no } else { l.new_no }
};
let Some((start, end)) = diff.selection() else {
return Some(Anchor {
file: file.path.clone(),
line: Some(number(line)?),
line_end: None,
on_old_side,
hunk: Some(hunk.id.clone()),
line_text: Some(line.text.clone()),
});
};
let mut numbered: Vec<(u32, String)> = Vec::new();
for index in start..=end {
let Some(row) = diff.rows.get(index) else {
continue;
};
if !matches!(row, DiffRow::Line { file, .. } if *file == file_idx) {
continue;
}
let Some((_, _, l)) = line_at(row) else {
continue;
};
if let Some(no) = number(l) {
numbered.push((no, l.text.clone()));
}
}
let (first, _) = numbered.iter().min_by_key(|(no, _)| *no)?.clone();
let (last, last_text) = numbered.iter().max_by_key(|(no, _)| *no)?.clone();
Some(Anchor {
file: file.path.clone(),
line: Some(first),
line_end: (last > first).then_some(last),
on_old_side,
hunk: Some(hunk.id.clone()),
line_text: Some(last_text),
})
}
fn comment_at_cursor(&mut self) {
let Some(anchor) = self.comment_anchor() else {
self.info("move to a diff line to comment");
return;
};
let title = match (anchor.line, anchor.line_end) {
(Some(line), Some(end)) => format!("Comment {}:{line}-{end}", anchor.file),
(Some(line), None) => format!("Comment {}:{line}", anchor.file),
_ => format!("Comment {}", anchor.file),
};
self.modal = Some(Modal::Input {
title,
buffer: String::new(),
cursor: 0,
on_submit: InputOp::Comment { anchor },
});
}
fn comment_at_cursor_row(&self) -> Option<&Comment> {
let diff = self.diff.as_ref()?;
let DiffRow::Comment { comment, .. } = diff.rows.get(diff.cursor)? else {
return None;
};
self.review.session_for(&diff.source).comments.get(*comment)
}
fn reply_at_cursor(&mut self) {
let Some(comment) = self.comment_at_cursor_row() else {
self.info("move onto a comment to reply");
return;
};
let comment_id = comment.id.clone();
let author = comment.author.clone();
self.modal = Some(Modal::Input {
title: format!("Reply to {author}"),
buffer: String::new(),
cursor: 0,
on_submit: InputOp::Reply { comment_id },
});
}
fn resolve_at_cursor(&mut self) {
let Some(comment) = self.comment_at_cursor_row() else {
self.info("move onto a comment to resolve");
return;
};
if comment.status == CommentStatus::Resolved {
self.info("already resolved");
return;
}
let id = comment.id.clone();
let source = self.active_review_source();
self.review.session_for_mut(&source).resolve(&id);
self.after_session_change();
self.info("comment resolved");
}
fn diff_toggle_viewed(&mut self) {
let Some(path) = self.diff_cursor_path() else {
return;
};
let source = self.active_review_source();
let hash = self.diff.as_ref().and_then(|diff| {
diff.model(&self.review)
.files
.iter()
.find(|f| f.path == path)
.map(FileDiff::content_hash)
});
let Some(hash) = hash else {
self.info(format!("{path} is not part of the review diff"));
return;
};
let session = self.review.session_for_mut(&source);
let viewed = session.is_viewed(&path, &hash);
if viewed {
session.unmark_viewed(&path);
} else {
session.mark_viewed(&path, &hash);
}
if let Err(err) = self.review.save_for(&source) {
self.error(err.to_string());
}
if !viewed {
self.diff_advance_to_unviewed();
}
}
fn diff_advance_to_unviewed(&mut self) {
let review = &self.review;
let next = self.diff.as_ref().and_then(|diff| {
let model = diff.model(review);
let session = review.session_for(&diff.source);
model
.files
.iter()
.enumerate()
.skip(diff.selected + 1)
.find(|(_, file)| !session.is_viewed(&file.path, &file.content_hash()))
.map(|(index, _)| index)
});
if let Some(index) = next {
self.diff_select_file_index(index);
}
}
fn diff_select_file_index(&mut self, index: usize) {
let review = &self.review;
if let Some(diff) = self.diff.as_mut() {
let count = diff.model(review).files.len();
if count == 0 {
return;
}
diff.select(index.min(count - 1), review);
}
}
fn copy_feedback(&mut self, file_only: bool) {
let filter = if file_only {
let Some(path) = self.diff_cursor_path() else {
self.info("no file under the cursor");
return;
};
Some(path)
} else {
None
};
let session = &self.review.session;
let count = session
.comments
.iter()
.filter(|c| c.status != CommentStatus::Resolved)
.filter(|c| filter.as_deref().is_none_or(|f| c.anchor.file == f))
.count();
let noun = if count == 1 { "comment" } else { "comments" };
if count == 0 {
self.info("no comments to copy");
return;
}
let repo = self
.review
.repo_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let branch = self.head.branch.clone().unwrap_or_else(|| "?".to_owned());
let title = format!("Review feedback — {repo} @ {branch} ({count} {noun})");
let markdown = feedback::to_markdown(
session,
self.review.model(),
&FeedbackOptions {
title: &title,
file_filter: filter.as_deref(),
include_resolved: false,
},
);
self.pending_osc = Some(clipboard::osc52(&markdown));
let scope = if file_only { "file" } else { "all" };
self.info(format!("copied {count} {noun} ({scope})"));
}
}
#[cfg(test)]
mod tests {
use diffler_core::session::CommentStatus;
use super::*;
use crate::app::Screen;
use crate::config::LoadedConfig;
use crate::event::AppEvent;
use crate::test_support::{Fixture, ctrl_key, key, 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");
diff.tree_rows(diff.model(&app.review)).len()
}
fn tree_kinds(app: &App) -> Vec<String> {
let diff = app.diff.as_ref().expect("diff view");
diff.tree_rows(diff.model(&app.review))
.iter()
.map(|row| match &row.node {
crate::tree::TreeNode::Dir { .. } => "dir".to_owned(),
crate::tree::TreeNode::File { name, .. } => format!("file:{name}"),
})
.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));
}
}
#[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 the_list_sidebar_layout_is_flat_with_full_paths_and_no_dirs() {
let fixture = standard_fixture();
let app = diff_app_with_layout(&fixture, crate::config::FileLayout::List);
assert_eq!(
tree_kinds(&app),
vec![
"file:ci.yml".to_owned(),
"file:src/lib.rs".to_owned(),
"file:todo.md".to_owned(),
]
);
}
#[test]
fn list_sidebar_jk_walks_files_and_selects_them() {
let fixture = standard_fixture();
let mut app = diff_app_with_layout(&fixture, crate::config::FileLayout::List);
assert_eq!(tree_cursor(&app), 0);
assert_eq!(selected_path(&app), "ci.yml");
app.handle(key('j'));
assert_eq!(selected_path(&app), "src/lib.rs");
app.handle(key('j'));
assert_eq!(selected_path(&app), "todo.md");
app.handle(key('k'));
assert_eq!(selected_path(&app), "src/lib.rs");
}
#[test]
fn list_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 tab_toggles_focus_between_the_panes() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
app.handle(key('\t'));
assert_eq!(focus(&app), Pane::Diff);
app.handle(key('\t'));
assert_eq!(focus(&app), Pane::List);
}
#[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 bracket_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,
hunk: None,
line_text: None,
};
app.review.session.add_comment("r", anchor(1), "first");
app.review.session.add_comment("r", anchor(20), "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!(matches!(app.modal, Some(Modal::Input { .. })));
type_text(&mut app, "why 42?");
app.handle(key('\n'));
assert_eq!(app.modal, None);
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!(comment.anchor.hunk.is_some(), "anchor carries the hunk id");
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,
..
}
)));
}
#[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(
"reviewer",
Anchor {
file: "src/lib.rs".to_owned(),
line: Some(2),
line_end: None,
on_old_side: false,
hunk: None,
line_text: Some(" 43".to_owned()),
},
"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(
"reviewer",
Anchor {
file: "src/lib.rs".to_owned(),
line: Some(99),
line_end: None,
on_old_side: false,
hunk: None,
line_text: None,
},
"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'));
let position = added_line_position(&app);
app.diff.as_mut().unwrap().cursor = position + 1;
app.handle(key('r'));
assert!(matches!(app.modal, Some(Modal::Input { .. })));
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");
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);
}
#[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 comment_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('c'));
let message = app.message.expect("message");
assert!(message.text.contains("move into the diff"));
assert!(app.review.session.comments.is_empty());
}
#[test]
fn marking_viewed_advances_selection_to_the_next_unviewed_file() {
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();
assert_eq!(paths.len(), 3, "walk needs several files: {paths:?}");
app.handle(key('v'));
assert!(app.is_path_viewed(&paths[0]));
assert_eq!(selected_path(&app), paths[1]);
app.handle(key('v'));
assert_eq!(selected_path(&app), paths[2]);
app.handle(key('v'));
assert!(paths.iter().all(|p| app.is_path_viewed(p)));
assert_eq!(
selected_path(&app),
paths[2],
"no unviewed file below: selection stays"
);
}
#[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('v'));
assert_eq!(selected_path(&app), "src/lib.rs");
app.handle(key('j'));
assert_eq!(selected_path(&app), first);
app.handle(key('v'));
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('v'));
assert!(app.is_path_viewed(&first));
assert_ne!(selected_path(&app), first);
}
#[test]
fn y_copies_the_selected_files_feedback_as_osc52_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(
"reviewer",
Anchor {
file: "todo.md".to_owned(),
line: None,
line_end: None,
on_old_side: false,
hunk: None,
line_text: None,
},
"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_osc.clone().expect("osc52 payload 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, clipboard::osc52(&expected));
app.handle(key('Y'));
let message = app.message.clone().expect("toast");
assert_eq!(message.text, "copied 2 comments (all)");
}
#[test]
fn y_with_no_comments_hints_instead_of_copying() {
let fixture = standard_fixture();
let mut app = diff_app(&fixture);
app.handle(key('y'));
assert_eq!(app.pending_osc, 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_scrolls_the_diff_pane_keeping_the_selection() {
let fixture = two_hunk_fixture();
let mut app = diff_app(&fixture);
assert_eq!(focus(&app), Pane::List);
let selected_before = selected_path(&app);
let tree_before = tree_cursor(&app);
app.diff.as_mut().unwrap().viewport = 10;
assert_eq!(app.diff.as_ref().unwrap().cursor, 0);
app.handle(ctrl_key('d'));
assert_eq!(app.diff.as_ref().unwrap().cursor, 5);
assert_eq!(selected_path(&app), selected_before);
assert_eq!(tree_cursor(&app), tree_before);
assert_eq!(focus(&app), Pane::List, "focus stays on the sidebar");
app.handle(ctrl_key('u'));
assert_eq!(app.diff.as_ref().unwrap().cursor, 0);
assert_eq!(selected_path(&app), selected_before);
}
#[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);
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_some(),
"no-op refresh keeps the selection"
);
}
#[test]
fn real_change_refresh_clears_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);
assert!(
app.diff.as_ref().unwrap().visual_anchor.is_none(),
"rows shifted: a stale anchor would dangle"
);
}
#[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('v'));
let source = diffler_core::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 comment_display_lines_cover_body_and_replies() {
let mut session = Session::default();
let id = session
.add_comment(
"reviewer",
Anchor {
file: "a.rs".to_owned(),
line: Some(1),
line_end: None,
on_old_side: false,
hunk: None,
line_text: None,
},
"first\nsecond",
)
.id
.clone();
session.reply(&id, "agent", "done\nand verified");
let lines = comment_display(&session.comments[0]);
assert_eq!(
lines,
vec![
CommentLine::Header,
CommentLine::Body("first".to_owned()),
CommentLine::Body("second".to_owned()),
CommentLine::Reply {
author: "agent".to_owned(),
text: "done".to_owned(),
first: true,
},
CommentLine::Reply {
author: "agent".to_owned(),
text: "and verified".to_owned(),
first: false,
},
CommentLine::Footer,
]
);
}
#[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(&DiffSource::WorkingTree);
for (index, file) in model.files.iter().enumerate() {
for row in build_split_rows(model, session, index) {
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 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"
);
}
}