use travelagent_core::vcs::git::calculate_gap;
use super::{
AnnotatedLine, App, ExpandDirection, FileTreeItem, FindSourceLineResult, GAP_EXPAND_BATCH,
GapId, find_source_line,
};
impl App {
pub fn is_cursor_in_overview(&self) -> bool {
self.diff_state.cursor_line < self.review_comments_render_height()
}
pub fn cursor_down(&mut self, lines: usize) {
let max_line = self.total_lines().saturating_sub(1);
self.diff_state.cursor_line = (self.diff_state.cursor_line + lines).min(max_line);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn cursor_up(&mut self, lines: usize) {
self.diff_state.cursor_line = self.diff_state.cursor_line.saturating_sub(lines);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_down(&mut self, lines: usize) {
let total = self.total_lines();
let max_line = total.saturating_sub(1);
let max_scroll = self.max_scroll_offset();
self.diff_state.cursor_line = (self.diff_state.cursor_line + lines).min(max_line);
self.diff_state.scroll_offset = (self.diff_state.scroll_offset + lines).min(max_scroll);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_up(&mut self, lines: usize) {
self.diff_state.cursor_line = self.diff_state.cursor_line.saturating_sub(lines);
self.diff_state.scroll_offset = self.diff_state.scroll_offset.saturating_sub(lines);
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
pub fn scroll_left(&mut self, cols: usize) {
if self.diff_state.wrap_lines {
return;
}
self.diff_state.scroll_x = self.diff_state.scroll_x.saturating_sub(cols);
}
pub fn scroll_right(&mut self, cols: usize) {
if self.diff_state.wrap_lines {
return;
}
let max_scroll_x = self
.diff_state
.max_content_width
.saturating_sub(self.diff_state.viewport_width);
self.diff_state.scroll_x =
(self.diff_state.scroll_x.saturating_add(cols)).min(max_scroll_x);
}
pub fn toggle_diff_wrap(&mut self) {
let enabled = !self.diff_state.wrap_lines;
self.set_diff_wrap(enabled);
}
pub fn set_diff_wrap(&mut self, enabled: bool) {
self.diff_state.wrap_lines = enabled;
if enabled {
self.diff_state.scroll_x = 0;
}
let status = if self.diff_state.wrap_lines {
"on"
} else {
"off"
};
self.set_message(format!("Diff wrapping: {status}"));
}
pub(super) fn ensure_cursor_visible(&mut self) {
let visible_lines = if self.diff_state.visible_line_count > 0 {
self.diff_state.visible_line_count
} else {
self.diff_state.viewport_height.max(1)
};
let max_scroll = self.max_scroll_offset();
if self.diff_state.cursor_line < self.diff_state.scroll_offset {
self.diff_state.scroll_offset = self.diff_state.cursor_line;
}
if self.diff_state.cursor_line >= self.diff_state.scroll_offset + visible_lines {
self.diff_state.scroll_offset =
(self.diff_state.cursor_line - visible_lines + 1).min(max_scroll);
}
}
pub fn search_in_diff_from_cursor(&mut self) -> bool {
let pattern = self.search_buffer.clone();
if pattern.trim().is_empty() {
self.set_message("Search pattern is empty");
return false;
}
self.last_search_pattern = Some(pattern.clone());
self.search_in_diff(&pattern, self.diff_state.cursor_line, true, true)
}
pub fn search_next_in_diff(&mut self) -> bool {
let Some(pattern) = self.last_search_pattern.clone() else {
self.set_message("No previous search");
return false;
};
self.search_in_diff(&pattern, self.diff_state.cursor_line, true, false)
}
pub fn search_prev_in_diff(&mut self) -> bool {
let Some(pattern) = self.last_search_pattern.clone() else {
self.set_message("No previous search");
return false;
};
self.search_in_diff(&pattern, self.diff_state.cursor_line, false, false)
}
fn search_in_diff(
&mut self,
pattern: &str,
start_idx: usize,
forward: bool,
include_current: bool,
) -> bool {
let total_lines = self.total_lines();
if total_lines == 0 {
self.set_message("No diff content to search");
return false;
}
if forward {
let mut idx = start_idx.min(total_lines.saturating_sub(1));
if !include_current {
idx = idx.saturating_add(1);
}
for line_idx in idx..total_lines {
if let Some(text) = self.line_text_for_search(line_idx)
&& text.contains(pattern)
{
self.diff_state.cursor_line = line_idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
return true;
}
}
} else {
let mut idx = start_idx.min(total_lines.saturating_sub(1));
if !include_current {
idx = idx.saturating_sub(1);
}
let mut line_idx = idx;
loop {
if let Some(text) = self.line_text_for_search(line_idx)
&& text.contains(pattern)
{
self.diff_state.cursor_line = line_idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
return true;
}
if line_idx == 0 {
break;
}
line_idx = line_idx.saturating_sub(1);
}
}
self.set_message(format!("No matches for \"{pattern}\""));
false
}
fn line_text_for_search(&self, line_idx: usize) -> Option<String> {
match self.line_annotations.get(line_idx)? {
AnnotatedLine::ReviewCommentsHeader => Some("Review comments".to_string()),
AnnotatedLine::ReviewComment { comment_idx } => {
let comment = self.engine.session().review_comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::FileHeader { file_idx } => {
let file = self.diff_files.get(*file_idx)?;
Some(format!(
"{} [{}]",
file.display_path_lossy().display(),
file.status.as_char()
))
}
AnnotatedLine::FileComment {
file_idx,
comment_idx,
} => {
let path = self.diff_files.get(*file_idx)?.display_path_lossy();
let review = self.engine.session().files.get(path)?;
let comment = review.file_comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::LineComment {
file_idx,
line,
comment_idx,
..
} => {
let path = self.diff_files.get(*file_idx)?.display_path_lossy();
let review = self.engine.session().files.get(path)?;
let comments = review.line_comments.get(line)?;
let comment = comments.get(*comment_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::Expander { gap_id, direction } => {
let arrow = match direction {
ExpandDirection::Down => "↓",
ExpandDirection::Up => "↑",
ExpandDirection::Both => "↕",
};
let gap = self.gap_size(gap_id)?;
let top_len = self
.gaps
.expanded_top
.get(gap_id)
.map_or(0, std::vec::Vec::len);
let bot_len = self
.gaps
.expanded_bottom
.get(gap_id)
.map_or(0, std::vec::Vec::len);
let remaining = (gap as usize).saturating_sub(top_len + bot_len);
let count = remaining.min(GAP_EXPAND_BATCH);
Some(format!("... {arrow} expand ({count} lines) ..."))
}
AnnotatedLine::HiddenLines { count, .. } => {
Some(format!("... {count} lines hidden ..."))
}
AnnotatedLine::ExpandedContext {
gap_id,
line_idx: context_idx,
} => {
let content = self.get_expanded_line(gap_id, *context_idx)?;
Some(content.content.clone())
}
AnnotatedLine::HunkHeader { file_idx, hunk_idx } => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
Some(hunk.header.clone())
}
AnnotatedLine::DiffLine {
file_idx,
hunk_idx,
line_idx: diff_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let line = hunk.lines.get(*diff_idx)?;
Some(line.content.clone())
}
AnnotatedLine::BinaryOrEmpty { file_idx } => {
let file = self.diff_files.get(*file_idx)?;
if file.is_too_large {
Some("(file too large to display)".to_string())
} else if file.is_binary {
Some("(binary file)".to_string())
} else {
Some("(no changes)".to_string())
}
}
AnnotatedLine::CollapsedFile { file_idx } => {
let file = self.diff_files.get(*file_idx)?;
let (a, d) = file.stat();
Some(format!(
"File collapsed — {} lines, press z to expand",
a + d
))
}
AnnotatedLine::SideBySideLine {
file_idx,
hunk_idx,
del_line_idx,
add_line_idx,
..
} => {
let file = self.diff_files.get(*file_idx)?;
let hunk = file.hunks.get(*hunk_idx)?;
let del_content = del_line_idx
.and_then(|idx| hunk.lines.get(idx))
.map_or("", |l| l.content.as_str());
let add_content = add_line_idx
.and_then(|idx| hunk.lines.get(idx))
.map_or("", |l| l.content.as_str());
Some(format!("{del_content} {add_content}"))
}
AnnotatedLine::OrphanedCommentsHeader { count, .. } => {
Some(format!("Orphaned comments ({count})"))
}
AnnotatedLine::OrphanedComment {
orphan_idx,
file_path,
..
} => {
let review = self.engine.session().files.get(file_path.as_path())?;
let comment = review.orphaned_comments.get(*orphan_idx)?;
Some(comment.content.clone())
}
AnnotatedLine::Spacing => None,
}
}
fn gap_size(&self, gap_id: &GapId) -> Option<u32> {
let file = self.diff_files.get(gap_id.file_idx)?;
let hunk = file.hunks.get(gap_id.hunk_idx)?;
let prev_hunk = if gap_id.hunk_idx > 0 {
file.hunks.get(gap_id.hunk_idx - 1)
} else {
None
};
Some(calculate_gap(
prev_hunk.map(|h| (&h.new_start, &h.new_count)),
hunk.new_start,
))
}
pub fn center_cursor(&mut self) {
let viewport = self.diff_state.viewport_height.max(1);
let half_viewport = viewport / 2;
let max_scroll = self.max_scroll_offset();
self.diff_state.scroll_offset = self
.diff_state
.cursor_line
.saturating_sub(half_viewport)
.min(max_scroll);
}
pub fn go_to_source_line(&mut self, target_lineno: u32) {
let current_file = self.diff_state.current_file_idx;
let result = find_source_line(&self.line_annotations, current_file, target_lineno);
match result {
FindSourceLineResult::Exact(idx) | FindSourceLineResult::Nearest(idx) => {
self.diff_state.cursor_line = idx;
self.ensure_cursor_visible();
self.center_cursor();
self.update_current_file_from_cursor();
if matches!(result, FindSourceLineResult::Nearest(_)) {
self.set_message(format!(
"Line {target_lineno} not in diff, jumped to nearest"
));
}
}
FindSourceLineResult::NotFound => {
self.set_warning(format!("Line {target_lineno} not found in current file"));
}
}
}
pub fn file_list_down(&mut self, n: usize) {
let visible_items = self.build_visible_items();
let max_idx = visible_items.len().saturating_sub(1);
let new_idx = (self.file_list_state.selected() + n).min(max_idx);
self.file_list_state.select(new_idx);
}
pub fn file_list_up(&mut self, n: usize) {
let new_idx = self.file_list_state.selected().saturating_sub(n);
self.file_list_state.select(new_idx);
}
pub fn jump_to_file(&mut self, idx: usize) {
use std::path::Path;
if idx < self.diff_files.len() {
self.diff_state.current_file_idx = idx;
self.diff_state.cursor_line = self.calculate_file_scroll_offset(idx);
let max_scroll = self.max_scroll_offset();
self.diff_state.scroll_offset = self.diff_state.cursor_line.min(max_scroll);
let file_path = self.diff_files[idx].display_path_lossy().clone();
let mut current = file_path.parent();
while let Some(parent) = current {
if parent != Path::new("") {
self.ui_layout
.expanded_dirs
.insert(parent.to_string_lossy().to_string());
}
current = parent.parent();
}
if let Some(tree_idx) = self.file_idx_to_tree_idx(idx) {
self.file_list_state.select(tree_idx);
}
}
}
pub fn next_file(&mut self) {
let visible_items = self.build_visible_items();
let current_file_idx = self.diff_state.current_file_idx;
for item in &visible_items {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx > current_file_idx
{
self.jump_to_file(*file_idx);
return;
}
}
}
pub fn prev_file(&mut self) {
let visible_items = self.build_visible_items();
let current_file_idx = self.diff_state.current_file_idx;
for item in visible_items.iter().rev() {
if let FileTreeItem::File { file_idx, .. } = item
&& *file_idx < current_file_idx
{
self.jump_to_file(*file_idx);
return;
}
}
}
pub fn next_hunk(&mut self) {
let mut cumulative = self.review_comments_render_height();
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path_lossy();
cumulative += 1;
if self.engine.session().is_file_reviewed(path) {
continue;
}
if self.is_file_collapsed(file_idx) {
cumulative += 2; continue;
}
if let Some(review) = self.engine.session().files.get(path) {
cumulative += review.file_comments.len();
}
if file.is_binary || file.hunks.is_empty() {
cumulative += 1; } else {
for hunk in &file.hunks {
if cumulative > self.diff_state.cursor_line {
self.diff_state.cursor_line = cumulative;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
return;
}
cumulative += 1; cumulative += hunk.lines.len(); }
}
cumulative += 1; }
}
pub fn prev_hunk(&mut self) {
let mut hunk_positions: Vec<usize> = Vec::new();
let mut cumulative = self.review_comments_render_height();
for (file_idx, file) in self.diff_files.iter().enumerate() {
let path = file.display_path_lossy();
cumulative += 1;
if self.engine.session().is_file_reviewed(path) {
continue;
}
if self.is_file_collapsed(file_idx) {
cumulative += 2; continue;
}
if let Some(review) = self.engine.session().files.get(path) {
cumulative += review.file_comments.len();
}
if file.is_binary || file.hunks.is_empty() {
cumulative += 1;
} else {
for hunk in &file.hunks {
hunk_positions.push(cumulative);
cumulative += 1;
cumulative += hunk.lines.len();
}
}
cumulative += 1;
}
for &pos in hunk_positions.iter().rev() {
if pos < self.diff_state.cursor_line {
self.diff_state.cursor_line = pos;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
return;
}
}
self.diff_state.cursor_line = 0;
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
}