use super::{DiffRow, Pane, ScrollAlign, next_unviewed_index, sidebar_rows};
use crate::app::{App, MouseGesture, hit_index, page_step};
use crate::keymap::Action;
use crate::tree::{TreeNode, TreeRow};
fn foldable_at(rows: &[TreeRow], at: usize) -> Option<usize> {
let row = rows.get(at)?;
let header = |node: &TreeNode| matches!(node, TreeNode::Dir { .. } | TreeNode::Section { .. });
if header(&row.node) {
return Some(at);
}
rows.get(..at)?
.iter()
.rposition(|above| above.depth < row.depth && header(&above.node))
}
impl App {
pub(crate) fn dispatch_diff(&mut self, action: Action) {
let scope = self.diff.as_ref().map(|d| (d.selected, d.focus));
self.dispatch_diff_inner(action);
if self.search.is_some() && self.diff.as_ref().map(|d| (d.selected, d.focus)) != scope {
self.search = None;
}
}
fn dispatch_diff_inner(&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::NextUnviewed => return self.diff_jump_unviewed(),
Action::CycleSidebarMode => return self.diff_cycle_sidebar_mode(),
Action::MoveLeft => return self.diff_focus(self.pane_left()),
Action::MoveRight => return self.diff_focus(self.pane_right()),
Action::ToggleSideBySide => return self.toggle_side_by_side(),
Action::SubmitReview => return self.submit_pr_review(),
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),
Some(Pane::Comments) => self.dispatch_comments(action),
None => {}
}
}
fn pane_left(&self) -> Pane {
match self.diff.as_ref().map(|diff| diff.focus) {
Some(Pane::Comments) => Pane::Diff,
_ => Pane::List,
}
}
fn pane_right(&self) -> Pane {
let Some(diff) = self.diff.as_ref() else {
return Pane::Diff;
};
match diff.focus {
Pane::Diff | Pane::Comments if diff.comments_open => Pane::Comments,
_ => Pane::Diff,
}
}
fn dispatch_comments(&mut self, action: Action) {
match action {
Action::MoveDown => self.comments_step(1),
Action::MoveUp => self.comments_step(-1),
Action::GoTop => self.comments_to(0),
Action::GoBottom => self.comments_to(usize::MAX),
Action::HalfPageDown | Action::FullPageDown => self.comments_step(5),
Action::HalfPageUp | Action::FullPageUp => self.comments_step(-5),
Action::Open | Action::MoveRight | Action::MoveLeft => self.diff_focus(Pane::Diff),
Action::DeleteComment => self.delete_selected_comment(),
Action::Reply
| Action::Resolve
| Action::Comment
| Action::VisualSelect
| Action::MarkViewed
| Action::CopyFileFeedback
| Action::OpenEditor
if self.selected_comment_is_orphan() =>
{
self.info("that comment's file is not in this diff");
}
other => self.dispatch_diff_pane(other),
}
}
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_page(false)),
Action::HalfPageUp => self.diff_move(-self.diff_page(false)),
Action::FullPageDown => self.diff_move(self.diff_page(true)),
Action::FullPageUp => self.diff_move(-self.diff_page(true)),
Action::Open => self.diff_tree_activate(),
Action::ToggleFold => self.diff_toggle_dir_fold(),
Action::MarkViewed => self.diff_toggle_viewed(),
Action::UnviewAll => self.diff_unview_all(),
Action::OpenEditor => self.editor_at_diff_cursor(),
Action::CopyFileFeedback => self.copy_file_or_selection(),
Action::CopyAllFeedback => self.copy_feedback(false),
Action::DeleteAllComments => self.delete_all_comments_start(),
Action::Comment => self.comment_on_selected_file(),
Action::VisualSelect | Action::Reply | Action::Resolve | Action::DeleteComment => {
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_page(false)),
Action::HalfPageUp => self.diff_move(-self.diff_page(false)),
Action::FullPageDown => self.diff_move(self.diff_page(true)),
Action::FullPageUp => self.diff_move(-self.diff_page(true)),
Action::NextHunk => self.diff_jump(true, |row| matches!(row, DiffRow::Hunk { .. })),
Action::PrevHunk => self.diff_jump(false, |row| matches!(row, DiffRow::Hunk { .. })),
Action::NextFunction => self.diff_jump_function(true),
Action::PrevFunction => self.diff_jump_function(false),
Action::CenterCursor => self.diff_align(ScrollAlign::Center),
Action::CursorTop => self.diff_align(ScrollAlign::Top),
Action::CursorBottom => self.diff_align(ScrollAlign::Bottom),
Action::ExpandContext => self.expand_context(),
Action::CollapseContext => self.collapse_context(),
Action::ExpandWholeFile => self.expand_whole_file(),
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::DeleteComment => self.delete_comment_at_cursor(),
Action::DeleteAllComments => self.delete_all_comments_start(),
Action::MarkViewed => self.diff_toggle_viewed(),
Action::UnviewAll => self.diff_unview_all(),
Action::CopyFileFeedback => self.copy_file_or_selection(),
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_focus(&mut self, pane: Pane) {
if let Some(diff) = self.diff.as_mut() {
diff.focus = pane;
}
}
fn diff_align(&mut self, align: ScrollAlign) {
if let Some(diff) = self.diff.as_mut() {
diff.scroll_align = Some(align);
}
}
pub(crate) fn diff_mouse(&mut self, gesture: MouseGesture) {
use MouseGesture;
match gesture {
MouseGesture::Scroll { col, down, .. } => {
let delta = if down { 3 } else { -3 };
let in_sidebar = self.diff.as_ref().is_some_and(|d| col < d.pane.x);
let in_comments = self.comments_col(col);
if in_comments {
self.comments_step(delta);
} else if in_sidebar {
self.diff_tree_step(delta);
} else {
self.diff_move(delta);
}
}
MouseGesture::Press { col, row } => self.diff_press_at(col, row),
MouseGesture::DoublePress { col, row } => self.diff_activate_at(col, row),
MouseGesture::Drag { col, row } => self.diff_drag_to(col, row),
MouseGesture::Cancel => {
if let Some(diff) = self.diff.as_mut() {
diff.visual_anchor = None;
}
}
}
}
fn diff_press_at(&mut self, col: u16, row: u16) {
if let Some(index) = self.comments_row_at(col, row) {
self.diff_focus(Pane::Comments);
self.comments_to(index);
return;
}
if let Some(index) = self.diff_sidebar_row_at(col, row) {
self.diff_tree_to(index);
return;
}
if let Some(index) = self.diff_pane_row_at(col, row)
&& let Some(diff) = self.diff.as_mut()
{
diff.cursor = index;
diff.visual_anchor = None;
}
}
fn diff_activate_at(&mut self, col: u16, row: u16) {
if let Some(index) = self.diff_sidebar_row_at(col, row) {
self.diff_tree_to(index);
self.diff_tree_activate();
return;
}
if let Some(index) = self.diff_pane_row_at(col, row) {
if let Some(diff) = self.diff.as_mut() {
diff.cursor = index;
diff.visual_anchor = None;
}
self.diff_focus(Pane::Diff);
self.comment_at_cursor();
}
}
fn diff_drag_to(&mut self, col: u16, row: u16) {
if let Some(index) = self.diff_pane_row_at(col, row)
&& let Some(diff) = self.diff.as_mut()
{
if diff.visual_anchor.is_none() {
diff.visual_anchor = Some(diff.cursor);
}
diff.cursor = index;
}
}
fn diff_sidebar_row_at(&self, col: u16, row: u16) -> Option<usize> {
let diff = self.diff.as_ref()?;
let index = hit_index(diff.sidebar, diff.sidebar_scroll, col, row)?;
(index < sidebar_rows(diff, &self.review).len()).then_some(index)
}
fn diff_pane_row_at(&self, col: u16, row: u16) -> Option<usize> {
let diff = self.diff.as_ref()?;
if diff.side_by_side {
return None;
}
let pane = diff.pane;
let inside = col >= pane.x
&& col < pane.x + pane.width
&& row >= pane.y
&& row < pane.y + pane.height;
if !inside {
return None;
}
diff.line_rows
.get((row - pane.y) as usize)
.copied()
.flatten()
}
fn diff_tree_step(&mut self, delta: isize) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let rows = sidebar_rows(diff, &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);
}
pub(crate) fn diff_tree_to(&mut self, target: usize) {
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let rows = sidebar_rows(diff, 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 = sidebar_rows(diff, review);
match rows.get(diff.tree_cursor).map(|r| &r.node) {
Some(TreeNode::File { .. }) => self.diff_focus(Pane::Diff),
Some(TreeNode::Dir { .. } | TreeNode::Section { .. }) => 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 = sidebar_rows(diff, review);
let Some(target) = foldable_at(&rows, diff.tree_cursor) else {
self.info("nothing to fold here");
return;
};
match rows.get(target).map(|row| &row.node) {
Some(TreeNode::Dir { path, .. }) => {
let path = path.clone();
if !diff.folded_dirs.remove(&path) {
diff.folded_dirs.insert(path);
}
}
Some(TreeNode::Section { bucket, .. }) => diff.bucket_folds.toggle_fold(*bucket),
_ => return,
}
let rows = sidebar_rows(diff, review);
diff.tree_cursor = target.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 = sidebar_rows(diff, &self.review);
if let Some(target) = super::step_file_row(&rows, diff.tree_cursor, forward) {
self.diff_tree_to(target);
}
}
fn diff_jump_unviewed(&mut self) {
let Some(diff) = self.diff.as_ref() else {
return;
};
if diff.model(&self.review).files.is_empty() {
self.info("nothing to review");
return;
}
match next_unviewed_index(diff, &self.review, true) {
Some(index) => self.diff_select_file_index(index),
None => self.info("every file is viewed"),
}
}
fn diff_cycle_sidebar_mode(&mut self) {
let review = &self.review;
let Some(diff) = self.diff.as_mut() else {
return;
};
let layout = diff.cycle_layout();
let rows = sidebar_rows(diff, review);
diff.reseat_tree_cursor(&rows);
self.search = None;
self.queue_declared();
self.info(format!("sidebar: {layout}"));
}
fn editor_at_diff_cursor(&mut self) {
match self.diff_cursor_file_line() {
Some((path, line)) => self.request_editor(&path, line),
None => self.info("no file under the cursor"),
}
}
pub(crate) fn diff_cursor_file_line(&self) -> Option<(String, Option<u32>)> {
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 { .. } | DiffRow::Composer { .. }) | 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_for(&diff.source)
.comments
.get(*comment)
.map(|c| (c.anchor.file.clone(), c.anchor.line_end.or(c.anchor.line))),
}
})
}
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_page(&self, full: bool) -> isize {
let step = page_step(self.diff.as_ref().map_or(0, |d| d.viewport), full);
isize::try_from(step).unwrap_or(20)
}
fn diff_jump_comment(&mut self, forward: bool) {
self.diff_jump(forward, |row| {
matches!(row, DiffRow::Comment { line: 0, .. })
});
}
fn diff_jump_function(&mut self, forward: bool) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let Some(path) = diff.selected_path(&self.review) else {
return;
};
let Some(scope) = diff.scopes.get(&path) else {
self.info("no definition index for this file (yet)");
return;
};
let starts: std::collections::HashSet<u32> = scope
.index
.def_starts()
.into_iter()
.filter_map(|row| u32::try_from(row + 1).ok())
.collect();
if starts.is_empty() {
self.info("no definitions in this file");
return;
}
let model = diff
.commit_model
.clone()
.unwrap_or_else(|| self.review.model().clone());
let file = model.files.iter().position(|f| f.path == path);
self.diff_jump(forward, |row| {
let DiffRow::Line {
file: f,
hunk,
line,
} = row
else {
return false;
};
Some(*f) == file
&& model
.files
.get(*f)
.and_then(|fd| fd.hunks.get(*hunk))
.and_then(|h| h.lines.get(*line))
.and_then(|l| l.new_no)
.is_some_and(|no| starts.contains(&no))
});
}
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 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");
}
}
}