use std::io;
use std::path::{Path, PathBuf};
use chrono::Utc;
use ratatui::style::Color;
use std::collections::HashMap;
use travelagent_core::anchor_map::AnchorMap;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{
CommentType, DiffFile, DiffHunk, DiffLine, FileStatus, LineOrigin, LineSide, ReviewSession,
SessionDiffSource,
};
use travelagent_core::persistence::load_latest_session_for_context;
use travelagent_core::syntax::SyntaxHighlighter;
use travelagent_core::vcs::{CommitInfo, VcsBackend, VcsInfo};
use super::{
App, DiffSource, DiffState, FileListState, InputMode, STAGED_SELECTION_ID,
UNSTAGED_SELECTION_ID,
};
impl App {
pub(super) fn default_comment_type(&self) -> CommentType {
Self::first_comment_type(&self.comment.types)
}
pub fn comment_type_label(&self, comment_type: &CommentType) -> String {
if let Some(definition) = self
.comment
.types
.iter()
.find(|definition| definition.id == comment_type.id())
{
return definition.label.to_ascii_uppercase();
}
comment_type.to_label()
}
pub fn comment_type_color(&self, comment_type: &CommentType) -> Color {
if let Some(definition) = self
.comment
.types
.iter()
.find(|definition| definition.id == comment_type.id())
&& let Some(color) = definition.color
{
return color;
}
match comment_type.id() {
"note" => self.theme.comment_note,
"suggestion" => self.theme.comment_suggestion,
"issue" => self.theme.comment_issue,
"praise" => self.theme.comment_praise,
"question" => self.theme.comment_question,
_ => self.theme.fg_secondary,
}
}
pub(super) fn load_or_create_commit_range_session(
vcs_info: &VcsInfo,
commit_ids: &[String],
) -> Result<ReviewSession> {
let newest_commit_id = commit_ids.last().ok_or(TrvError::NoChanges)?.clone();
let loaded = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::CommitRange,
Some(commit_ids),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded.unwrap_or_else(|| {
let mut s = ReviewSession::new(
vcs_info.root_path.clone(),
newest_commit_id,
vcs_info.branch_name.clone(),
SessionDiffSource::CommitRange,
);
s.commit_range = Some(commit_ids.to_vec());
s
});
if session.commit_range.is_none() {
session.commit_range = Some(commit_ids.to_vec());
session.updated_at = chrono::Utc::now();
}
Ok(session)
}
pub(super) fn load_or_create_staged_unstaged_and_commits_session(
vcs_info: &VcsInfo,
commit_ids: &[String],
) -> Result<ReviewSession> {
let newest_commit_id = commit_ids.last().ok_or(TrvError::NoChanges)?.clone();
let loaded = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::StagedUnstagedAndCommits,
Some(commit_ids),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded.unwrap_or_else(|| {
let mut s = ReviewSession::new(
vcs_info.root_path.clone(),
newest_commit_id,
vcs_info.branch_name.clone(),
SessionDiffSource::StagedUnstagedAndCommits,
);
s.commit_range = Some(commit_ids.to_vec());
s
});
if session.commit_range.is_none() {
session.commit_range = Some(commit_ids.to_vec());
session.updated_at = chrono::Utc::now();
}
Ok(session)
}
pub(super) fn load_or_create_session(
vcs_info: &VcsInfo,
diff_source: SessionDiffSource,
) -> ReviewSession {
let new_session = || {
ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
diff_source,
)
};
let Ok(found) = load_latest_session_for_context(
&vcs_info.root_path,
vcs_info.branch_name.as_deref(),
&vcs_info.head_commit,
diff_source,
None,
) else {
return new_session();
};
let Some((_path, mut session)) = found else {
return new_session();
};
let mut updated = false;
if session.branch_name.is_none() && vcs_info.branch_name.is_some() {
session.branch_name = vcs_info.branch_name.clone();
updated = true;
}
if vcs_info.branch_name.is_some() && session.base_commit != vcs_info.head_commit {
session.base_commit = vcs_info.head_commit.clone();
updated = true;
}
if updated {
session.updated_at = chrono::Utc::now();
}
session
}
pub(super) fn staged_commit_entry() -> CommitInfo {
CommitInfo {
id: STAGED_SELECTION_ID.to_string(),
short_id: "STAGED".to_string(),
branch_name: None,
summary: "Staged changes".to_string(),
body: None,
author: String::new(),
time: Utc::now(),
}
}
pub(super) fn unstaged_commit_entry() -> CommitInfo {
CommitInfo {
id: UNSTAGED_SELECTION_ID.to_string(),
short_id: "UNSTAGED".to_string(),
branch_name: None,
summary: "Unstaged changes".to_string(),
body: None,
author: String::new(),
time: Utc::now(),
}
}
pub fn session_alias(&self) -> Option<&str> {
self.engine.session().alias.as_deref()
}
pub fn set_session_alias(&mut self, alias: Option<&str>) {
let normalized = alias
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let session = self.engine.session_mut();
if session.alias != normalized {
session.alias = normalized;
session.updated_at = chrono::Utc::now();
self.dirty = true;
}
}
pub(super) fn insert_commit_message_if_single(&mut self) {
self.diff_files.retain(|f| !f.is_commit_message);
let commit = if let Some((start, end)) = self.commit_select.selection_range {
if start == end {
self.inline_selector.commits.get(start)
} else {
None
}
} else if self.inline_selector.commits.len() == 1 {
self.inline_selector.commits.first()
} else {
None
};
let Some(commit) = commit else { return };
if Self::is_special_commit(commit) {
return;
}
let mut full_message = commit.summary.clone();
if let Some(ref body) = commit.body {
full_message.push('\n');
full_message.push('\n');
full_message.push_str(body);
}
let diff_lines: Vec<DiffLine> = full_message
.lines()
.enumerate()
.map(|(i, line)| DiffLine {
origin: LineOrigin::Context,
content: line.to_string(),
old_lineno: None,
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let line_count = diff_lines.len() as u32;
let commit_msg_file = DiffFile {
old_path: None,
new_path: Some(PathBuf::from("Commit Message")),
status: FileStatus::Added,
hunks: vec![DiffHunk {
header: String::new(),
lines: diff_lines,
old_start: 0,
old_count: 0,
new_start: 1,
new_count: line_count,
}],
is_binary: false,
is_too_large: false,
is_commit_message: true,
};
self.diff_files.insert(0, commit_msg_file);
self.engine
.session_mut()
.add_file(PathBuf::from("Commit Message"), FileStatus::Added);
}
pub(super) fn is_staged_commit(commit: &CommitInfo) -> bool {
commit.id == STAGED_SELECTION_ID
}
pub(super) fn is_unstaged_commit(commit: &CommitInfo) -> bool {
commit.id == UNSTAGED_SELECTION_ID
}
pub(super) fn is_special_commit(commit: &CommitInfo) -> bool {
Self::is_staged_commit(commit) || Self::is_unstaged_commit(commit)
}
pub(super) fn special_commit_count(&self) -> usize {
self.commit_select
.list
.iter()
.take_while(|commit| Self::is_special_commit(commit))
.count()
}
pub(super) fn loaded_history_commit_count(&self) -> usize {
self.commit_select
.list
.len()
.saturating_sub(self.special_commit_count())
}
pub(super) fn filter_ignored_diff_files(
repo_root: &Path,
diff_files: Vec<DiffFile>,
) -> Vec<DiffFile> {
travelagent_core::trvignore::filter_diff_files(repo_root, diff_files)
}
pub(super) fn filter_by_path(diff_files: Vec<DiffFile>, path: &str) -> Vec<DiffFile> {
let path = path.trim_end_matches('/');
diff_files
.into_iter()
.filter(|f| {
let display = f.display_path_lossy().to_string_lossy();
display == path || display.starts_with(&format!("{path}/"))
})
.collect()
}
pub(super) fn require_non_empty_diff_files(diff_files: Vec<DiffFile>) -> Result<Vec<DiffFile>> {
if diff_files.is_empty() {
return Err(TrvError::NoChanges);
}
Ok(diff_files)
}
pub(super) fn get_working_tree_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let mut diff_files = vcs.get_working_tree_diff()?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
pub(super) fn get_staged_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let mut diff_files = vcs.get_staged_diff()?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
pub(super) fn get_unstaged_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let mut diff_files = match vcs.get_unstaged_diff() {
Ok(diff_files) => diff_files,
Err(TrvError::UnsupportedOperation(_)) => vcs.get_working_tree_diff()?,
Err(e) => return Err(e),
};
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
pub(super) fn get_commit_range_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let mut diff_files = vcs.get_commit_range_diff(commit_ids)?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
pub(super) fn get_working_tree_with_commits_diff_with_ignore(
vcs: &dyn VcsBackend,
repo_root: &Path,
commit_ids: &[String],
highlighter: &SyntaxHighlighter,
path_filter: Option<&str>,
) -> Result<Vec<DiffFile>> {
let mut diff_files = vcs.get_working_tree_with_commits_diff(commit_ids)?;
travelagent_core::syntax::decorate_diff_files(&mut diff_files, highlighter);
let diff_files = Self::filter_ignored_diff_files(repo_root, diff_files);
let diff_files = if let Some(path) = path_filter {
Self::filter_by_path(diff_files, path)
} else {
diff_files
};
Self::require_non_empty_diff_files(diff_files)
}
pub(super) fn load_staged_and_unstaged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TrvError::NoChanges) => {
self.set_message("No staged or unstaged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.engine.reset_with_diff(
Self::load_or_create_session(&self.vcs_info, SessionDiffSource::StagedAndUnstaged),
&diff_files,
);
self.invalidate_tour_score_cache();
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedAndUnstaged;
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub(super) fn load_staged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TrvError::NoChanges) => {
self.set_message("No staged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.engine.reset_with_diff(
Self::load_or_create_session(&self.vcs_info, SessionDiffSource::Staged),
&diff_files,
);
self.invalidate_tour_score_cache();
self.diff_files = diff_files;
self.diff_source = DiffSource::Staged;
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub(super) fn load_unstaged_selection(&mut self) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TrvError::NoChanges) => {
self.set_message("No unstaged changes");
return Ok(());
}
Err(e) => return Err(e),
};
self.engine.reset_with_diff(
Self::load_or_create_session(&self.vcs_info, SessionDiffSource::Unstaged),
&diff_files,
);
self.invalidate_tour_score_cache();
self.diff_files = diff_files;
self.diff_source = DiffSource::Unstaged;
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.clear_expanded_gaps();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub fn reload_diff_files(&mut self) -> Result<usize> {
let current_path = self.current_file_path().cloned();
let prev_file_idx = self.diff_state.current_file_idx;
let prev_cursor_line = self.diff_state.cursor_line;
let prev_viewport_offset = self
.diff_state
.cursor_line
.saturating_sub(self.diff_state.scroll_offset);
let prev_relative_line = if self.diff_files.is_empty() {
0
} else {
let start = self.calculate_file_scroll_offset(self.diff_state.current_file_idx);
prev_cursor_line.saturating_sub(start)
};
let prev_expanded_dirs = self.ui_layout.expanded_dirs.clone();
let prev_cursor_source = self.get_line_at_cursor();
let mut old_new_content: HashMap<PathBuf, String> = self
.diff_files
.iter()
.map(|file| {
let path = file.display_path_lossy().clone();
let content = self
.live
.cached_contents(&path)
.cloned()
.unwrap_or_default();
(path, content)
})
.collect();
let highlighter = self.theme.syntax_highlighter();
let diff_files = match &self.diff_source {
DiffSource::CommitRange(commit_ids) => Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
commit_ids,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::StagedUnstagedAndCommits(commit_ids) => {
let ids = commit_ids.clone();
Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&ids,
highlighter,
self.path_filter.as_deref(),
)?
}
DiffSource::Staged => Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::Unstaged => Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?,
DiffSource::StagedAndUnstaged | DiffSource::WorkingTree => {
Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
)?
}
DiffSource::Remote { .. } => {
return Ok(self.diff_files.len());
}
};
travelagent_core::reanchor::remap_rename_keys(&mut old_new_content, &diff_files);
let current_path = current_path
.map(|p| travelagent_core::reanchor::remap_path(&p, &diff_files).unwrap_or(p));
self.engine.apply_diff_files(&diff_files);
let new_paths: std::collections::HashSet<PathBuf> = diff_files
.iter()
.map(|f| f.display_path_lossy().clone())
.collect();
let path_filter_active = self.path_filter.is_some();
let preload = Self::load_new_contents(&self.vcs_info.root_path, &new_paths);
let (new_content_map, preload_errors) = Self::collect_preload_successes(&preload);
for err in preload_errors {
self.set_error(err);
}
self.engine.reanchor_comments(
&old_new_content,
&new_content_map,
&new_paths,
path_filter_active,
);
self.diff_files = diff_files;
self.clear_expanded_gaps();
self.sort_files_by_directory(false);
self.expand_all_dirs();
for dir in prev_expanded_dirs {
self.ui_layout.expanded_dirs.insert(dir);
}
if self.diff_files.is_empty() {
self.diff_state.current_file_idx = 0;
self.diff_state.cursor_line = 0;
self.diff_state.scroll_offset = 0;
self.file_list_state.select(0);
} else {
let target_idx = if let Some(ref path) = current_path {
self.diff_files
.iter()
.position(|file| file.display_path_lossy() == path)
.unwrap_or_else(|| prev_file_idx.min(self.diff_files.len().saturating_sub(1)))
} else {
prev_file_idx.min(self.diff_files.len().saturating_sub(1))
};
self.jump_to_file(target_idx);
let anchor_hit =
current_path
.as_ref()
.zip(prev_cursor_source)
.and_then(|(path, (line, side))| {
let old_content = old_new_content.get(path)?;
let new_content = new_content_map.get(path)?;
let map = AnchorMap::from_content(old_content, new_content);
if matches!(side, LineSide::New) {
map.lookup(line)
} else {
None
}
});
let placed_by_anchor = if let Some(new_ln) = anchor_hit {
self.rebuild_annotations();
match super::find_source_line(&self.line_annotations, target_idx, new_ln) {
super::FindSourceLineResult::Exact(idx)
| super::FindSourceLineResult::Nearest(idx) => {
self.diff_state.cursor_line = idx;
true
}
super::FindSourceLineResult::NotFound => false,
}
} else {
false
};
if !placed_by_anchor {
let file_start = self.calculate_file_scroll_offset(target_idx);
let file_height = self.file_render_height(target_idx, &self.diff_files[target_idx]);
let relative_line = prev_relative_line.min(file_height.saturating_sub(1));
self.diff_state.cursor_line = file_start.saturating_add(relative_line);
}
let viewport = self.diff_state.viewport_height.max(1);
let max_relative = viewport.saturating_sub(1);
let relative_offset = prev_viewport_offset.min(max_relative);
if self.total_lines() == 0 {
self.diff_state.scroll_offset = 0;
} else {
let max_scroll = self.max_scroll_offset();
let desired = self
.diff_state
.cursor_line
.saturating_sub(relative_offset)
.min(max_scroll);
self.diff_state.scroll_offset = desired;
}
self.ensure_cursor_visible();
self.update_current_file_from_cursor();
}
self.rebuild_annotations();
let paths: Vec<PathBuf> = self
.diff_files
.iter()
.map(|f| f.display_path_lossy().clone())
.collect();
let next_cache =
Self::refresh_cache_with_preload_fallback(&paths, &preload, &old_new_content);
self.live.replace_cached_contents(next_cache);
self.reconcile_agent_ghost();
self.apply_blind_filter();
Ok(self.diff_files.len())
}
pub(crate) fn reconcile_agent_ghost(&mut self) {
let Some(ghost) = self.agent_ghost.as_ref() else {
return;
};
let matches_cached = self
.diff_files
.get(ghost.file_idx)
.map(|f| f.display_path_lossy().to_string_lossy().to_string())
== Some(ghost.path.clone());
if matches_cached {
return;
}
let new_idx = self
.diff_files
.iter()
.position(|f| f.display_path_lossy().to_string_lossy() == ghost.path);
match new_idx {
Some(i) => {
let path = ghost.path.clone();
self.agent_ghost = Some(crate::app::AgentGhost { file_idx: i, path });
}
None => {
self.agent_ghost = None;
}
}
}
pub(crate) fn load_new_contents(
repo_root: &Path,
new_paths: &std::collections::HashSet<PathBuf>,
) -> HashMap<PathBuf, io::Result<String>> {
new_paths
.iter()
.map(|path| {
let abs = repo_root.join(path);
(path.clone(), std::fs::read_to_string(&abs))
})
.collect()
}
#[cfg(test)]
pub(crate) fn reanchor_comments_against_new_content(
session: &mut ReviewSession,
repo_root: &Path,
old_new_content: &HashMap<PathBuf, String>,
new_paths: &std::collections::HashSet<PathBuf>,
path_filter_active: bool,
) {
let preload = Self::load_new_contents(repo_root, new_paths);
let (new_content_map, _errors) = Self::collect_preload_successes(&preload);
travelagent_core::reanchor::reanchor_comments(
session,
old_new_content,
&new_content_map,
new_paths,
path_filter_active,
);
}
pub(crate) fn refresh_cache_with_preload_fallback(
paths: &[PathBuf],
preload: &HashMap<PathBuf, io::Result<String>>,
old_new_content: &HashMap<PathBuf, String>,
) -> HashMap<PathBuf, String> {
let mut next_cache = HashMap::with_capacity(paths.len());
for path in paths {
let content = match preload.get(path) {
Some(Ok(s)) => s.clone(),
_ => old_new_content.get(path).cloned().unwrap_or_default(),
};
next_cache.insert(path.clone(), content);
}
next_cache
}
pub(crate) fn collect_preload_successes(
preload: &HashMap<PathBuf, io::Result<String>>,
) -> (HashMap<PathBuf, String>, Vec<String>) {
let mut out: HashMap<PathBuf, String> = HashMap::with_capacity(preload.len());
let mut errors: Vec<String> = Vec::new();
for (path, result) in preload {
match result {
Ok(content) => {
out.insert(path.clone(), content.clone());
}
Err(e) => {
errors.push(format!(
"preload read failed for {}: {} \
(preserving existing anchors; skipping re-anchor for this file)",
path.display(),
e
));
}
}
}
(out, errors)
}
pub fn reanchor_orphan(
&mut self,
file_path: &Path,
orphan_idx: usize,
dest_line: u32,
dest_side: LineSide,
) -> bool {
if !self
.engine
.reanchor_orphan(file_path, orphan_idx, dest_line, dest_side)
{
return false;
}
self.dirty = true;
self.rebuild_annotations();
true
}
pub fn selected_orphan_at_cursor(&self) -> Option<(std::path::PathBuf, usize)> {
match self.line_annotations.get(self.diff_state.cursor_line)? {
crate::app::AnnotatedLine::OrphanedComment {
orphan_idx,
file_path,
..
} => Some((file_path.clone(), *orphan_idx)),
_ => None,
}
}
pub fn apply_blind_filter(&mut self) -> usize {
if !self.blind_mode || self.blind_patterns.is_empty() {
return 0;
}
let matcher = travelagent_core::trvignore::matcher_from_patterns(
&self.vcs_info.root_path,
&self.blind_patterns,
);
let before = self.diff_files.len();
let filtered = travelagent_core::trvignore::filter_diff_files_with_matcher(
matcher.as_ref(),
std::mem::take(&mut self.diff_files),
);
self.diff_files = filtered;
if self.diff_state.current_file_idx >= self.diff_files.len() {
self.diff_state.current_file_idx = self.diff_files.len().saturating_sub(1);
}
before.saturating_sub(self.diff_files.len())
}
}
#[cfg(test)]
mod reanchor_tests {
use super::*;
use std::collections::HashSet;
use std::fs;
use tempfile::TempDir;
use travelagent_core::model::{
AnchorState, Comment, CommentType, FileStatus, LineSide, ReviewSession, SessionDiffSource,
};
fn make_session(repo: &Path) -> ReviewSession {
ReviewSession::new(
repo.to_path_buf(),
"head".to_string(),
Some("main".to_string()),
SessionDiffSource::WorkingTree,
)
}
fn add_commented_file(
session: &mut ReviewSession,
path: &str,
comment_line: u32,
comment_body: &str,
) -> String {
let p = PathBuf::from(path);
session.add_file(p.clone(), FileStatus::Modified);
let comment = Comment::new(
comment_body.to_string(),
CommentType::Note,
Some(LineSide::New),
);
let cid = comment.id.clone();
let review = session.get_file_mut(&p).expect("file present");
review.add_line_comment(comment_line, comment);
cid
}
fn write_file(repo: &Path, rel: &str, content: &str) {
let abs = repo.join(rel);
if let Some(parent) = abs.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&abs, content).unwrap();
}
#[test]
fn rescan_preserves_comment_on_unchanged_line() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/a.rs";
let old_content = "l1\nl2\nl3\nl4\ntarget\nl6\n";
let new_content = "l1\nl2\nl3\nl4\ntarget\nl6\n";
write_file(repo, rel, new_content);
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, rel, 5, "still here");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), old_content.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(review.orphaned_comments.len(), 0);
let comments = review.line_comments.get(&5).unwrap();
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].id, cid);
}
#[test]
fn rescan_shifts_comment_when_lines_inserted_above() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/b.rs";
let old_content = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\ntarget\nafter\n";
let new_content =
"new-a\nnew-b\nnew-c\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\ntarget\nafter\n";
write_file(repo, rel, new_content);
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, rel, 10, "targeted");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), old_content.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(review.orphaned_comments.len(), 0);
assert!(
!review.line_comments.contains_key(&10),
"old line 10 must be vacated"
);
let comments = review
.line_comments
.get(&13)
.expect("comment moved to line 13");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].id, cid);
match comments[0].anchor.as_ref().unwrap() {
AnchorState::Anchored { line, side, .. } => {
assert_eq!(*line, 13);
assert_eq!(*side, LineSide::New);
}
_ => panic!("expected Anchored"),
}
}
#[test]
fn rescan_shift_preserves_existing_reanchored_at() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/c.rs";
let old_content = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\ntarget\nafter\n";
let new_content =
"new-a\nnew-b\nnew-c\nl1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\ntarget\nafter\n";
write_file(repo, rel, new_content);
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, rel, 10, "recovered then shifted");
let frozen_reanchored_at = chrono::Utc::now() - chrono::Duration::minutes(5);
{
let review = session.files.get_mut(&PathBuf::from(rel)).unwrap();
let comments = review.line_comments.get_mut(&10).unwrap();
comments[0].anchor = Some(AnchorState::Anchored {
line: 10,
side: LineSide::New,
reanchored_at: Some(frozen_reanchored_at),
});
}
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), old_content.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
let shifted = review
.line_comments
.get(&13)
.expect("comment moved to line 13");
assert_eq!(shifted[0].id, cid);
match shifted[0].anchor.as_ref().unwrap() {
AnchorState::Anchored {
line,
reanchored_at,
..
} => {
assert_eq!(*line, 13);
assert_eq!(
*reanchored_at,
Some(frozen_reanchored_at),
"a plain shift must preserve the prior reanchored_at \
(not clear it, not re-stamp it)"
);
}
_ => panic!("expected Anchored"),
}
}
#[test]
fn rescan_orphans_comment_when_line_deleted() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/c.rs";
let old_content = "keep1\nremoved-line\nkeep2\n";
let new_content = "keep1\nkeep2\n";
write_file(repo, rel, new_content);
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, rel, 2, "about to be orphaned");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), old_content.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(review.line_comments.len(), 0);
assert_eq!(review.orphaned_comments.len(), 1);
let orphan = &review.orphaned_comments[0];
assert_eq!(orphan.id, cid);
match orphan.anchor.as_ref().unwrap() {
AnchorState::Orphaned {
was_line,
was_side,
last_seen_content,
orphaned_at,
} => {
assert_eq!(*was_line, 2);
assert_eq!(*was_side, LineSide::New);
assert_eq!(last_seen_content, "removed-line");
assert!(
orphaned_at.is_some(),
"orphan_comment should stamp orphaned_at"
);
}
_ => panic!("expected Orphaned"),
}
}
#[test]
fn rescan_keeps_orphaned_across_multiple_rescans() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/d.rs";
let content_v1 = "alpha\nbeta\ngamma\n";
let content_v2 = "alpha\ngamma\n"; write_file(repo, rel, content_v2);
let mut session = make_session(repo);
let _ = add_commented_file(&mut session, rel, 2, "orphan-me");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), content_v1.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(review.orphaned_comments.len(), 1);
assert_eq!(review.line_comments.len(), 0);
let content_v3 = "alpha\ngamma\ndelta\n";
write_file(repo, rel, content_v3);
let mut old_map2 = HashMap::new();
old_map2.insert(PathBuf::from(rel), content_v2.to_string());
App::reanchor_comments_against_new_content(
&mut session,
repo,
&old_map2,
&new_paths,
false,
);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(review.orphaned_comments.len(), 1);
assert_eq!(review.line_comments.len(), 0);
}
#[test]
fn rescan_keeps_orphaned_when_line_reappears() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/e.rs";
let content_v1 = "alpha\nbeta\ngamma\n";
let content_v2 = "alpha\ngamma\n"; write_file(repo, rel, content_v2);
let mut session = make_session(repo);
let _ = add_commented_file(&mut session, rel, 2, "comes-back");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), content_v1.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
assert_eq!(
session
.files
.get(&PathBuf::from(rel))
.unwrap()
.orphaned_comments
.len(),
1
);
let content_v3 = "alpha\nbeta\ngamma\n";
write_file(repo, rel, content_v3);
let mut old_map2 = HashMap::new();
old_map2.insert(PathBuf::from(rel), content_v2.to_string());
App::reanchor_comments_against_new_content(
&mut session,
repo,
&old_map2,
&new_paths,
false,
);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert_eq!(
review.orphaned_comments.len(),
1,
"orphan stays orphaned until user presses R"
);
assert_eq!(review.line_comments.len(), 0);
}
#[test]
fn rescan_orphans_everything_when_file_disappears() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/gone.rs";
let old_content = "keep1\nkeep2\nkeep3\n";
let mut session = make_session(repo);
let _ = add_commented_file(&mut session, rel, 1, "top");
let review = session
.get_file_mut(&PathBuf::from(rel))
.expect("file present");
review.add_line_comment(
3,
Comment::new("bottom".into(), CommentType::Note, Some(LineSide::New)),
);
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), old_content.to_string());
let new_paths = HashSet::new();
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert!(review.line_comments.is_empty());
assert_eq!(review.orphaned_comments.len(), 2);
let lines_seen: Vec<&str> = review
.orphaned_comments
.iter()
.filter_map(|c| match c.anchor.as_ref()? {
AnchorState::Orphaned {
last_seen_content, ..
} => Some(last_seen_content.as_str()),
_ => None,
})
.collect();
assert!(lines_seen.contains(&"keep1"));
assert!(lines_seen.contains(&"keep3"));
}
#[test]
fn rescan_preserves_line_side_old_comments_without_moving_or_orphaning() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/deletion.rs";
let old_content = "l1\nl2\nl3\nl4\nl5\n";
let new_content = "new_top_a\nnew_top_b\nl1\nl2\nl3\nl4\nl5\n";
write_file(repo, rel, new_content);
let mut session = make_session(repo);
let p = PathBuf::from(rel);
session.add_file(p.clone(), FileStatus::Modified);
let comment = Comment::new(
"deletion comment".into(),
CommentType::Note,
Some(LineSide::Old),
);
let cid = comment.id.clone();
let review = session.get_file_mut(&p).expect("file present");
review.add_line_comment(3, comment);
let mut old_map = HashMap::new();
old_map.insert(p.clone(), old_content.to_string());
let mut new_paths = HashSet::new();
new_paths.insert(p.clone());
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&p).unwrap();
assert_eq!(review.orphaned_comments.len(), 0, "must not orphan");
let at_3 = review.line_comments.get(&3).expect("still at line 3");
assert_eq!(at_3.len(), 1);
assert_eq!(at_3[0].id, cid);
assert_eq!(at_3[0].side, Some(LineSide::Old));
assert!(!review.line_comments.contains_key(&5));
}
#[test]
fn rescan_with_path_filter_does_not_orphan_filtered_files() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let visible_rel = "src/visible.rs";
let hidden_rel = "src/hidden.rs";
write_file(repo, visible_rel, "a\nb\nc\n");
write_file(repo, hidden_rel, "x\ny\nz\n");
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, hidden_rel, 2, "hidden comment");
session.add_file(PathBuf::from(visible_rel), FileStatus::Modified);
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(hidden_rel), "x\ny\nz\n".to_string());
old_map.insert(PathBuf::from(visible_rel), "a\nb\nc\n".to_string());
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(visible_rel));
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, true);
let review = session.files.get(&PathBuf::from(hidden_rel)).unwrap();
assert_eq!(
review.orphaned_comments.len(),
0,
"filtered file must not be orphaned"
);
let at_2 = review.line_comments.get(&2).expect("still at line 2");
assert_eq!(at_2.len(), 1);
assert_eq!(at_2[0].id, cid);
}
#[test]
fn rescan_without_path_filter_orphans_disappeared_files() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/gone.rs";
let mut session = make_session(repo);
let _cid = add_commented_file(&mut session, rel, 2, "was here");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), "x\ny\nz\n".to_string());
let new_paths = HashSet::new();
App::reanchor_comments_against_new_content(&mut session, repo, &old_map, &new_paths, false);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert!(review.line_comments.is_empty());
assert_eq!(review.orphaned_comments.len(), 1);
}
#[test]
fn rename_file_migrates_comments_to_new_key() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let old_rel = "src/before.rs";
let new_rel = "src/after.rs";
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, old_rel, 2, "rename me");
assert!(session.files.contains_key(&PathBuf::from(old_rel)));
let moved = session.rename_file(&PathBuf::from(old_rel), PathBuf::from(new_rel));
assert!(moved);
assert!(!session.files.contains_key(&PathBuf::from(old_rel)));
let review = session.files.get(&PathBuf::from(new_rel)).expect("new key");
assert_eq!(review.path, PathBuf::from(new_rel));
let at_2 = review.line_comments.get(&2).expect("still at line 2");
assert_eq!(at_2.len(), 1);
assert_eq!(at_2[0].id, cid);
}
#[test]
fn rename_file_merges_when_new_key_already_exists() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let old_rel = "src/before.rs";
let new_rel = "src/after.rs";
let mut session = make_session(repo);
let cid_old = add_commented_file(&mut session, old_rel, 2, "from old");
let cid_new = add_commented_file(&mut session, new_rel, 2, "already at new");
let moved = session.rename_file(&PathBuf::from(old_rel), PathBuf::from(new_rel));
assert!(moved);
let review = session.files.get(&PathBuf::from(new_rel)).unwrap();
let at_2 = review.line_comments.get(&2).expect("dst still at line 2");
assert_eq!(at_2.len(), 1, "dst's line comment preserved in place");
assert_eq!(at_2[0].id, cid_new);
assert_eq!(review.orphaned_comments.len(), 1, "src's comment orphaned");
assert_eq!(review.orphaned_comments[0].id, cid_old);
}
#[test]
fn preload_error_preserves_anchors_instead_of_orphaning() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "missing/bogus.rs";
let mut session = make_session(repo);
let cid = add_commented_file(&mut session, rel, 5, "anchor must survive preload failure");
let mut old_map = HashMap::new();
old_map.insert(
PathBuf::from(rel),
"l1\nl2\nl3\nl4\ntarget\nl6\n".to_string(),
);
let mut new_paths = HashSet::new();
new_paths.insert(PathBuf::from(rel));
let preload = App::load_new_contents(repo, &new_paths);
let entry = preload
.get(&PathBuf::from(rel))
.expect("preload entry present");
assert!(
entry.is_err(),
"expected preload to return Err for a nonexistent file"
);
let (new_content_map, preload_errors) = App::collect_preload_successes(&preload);
assert!(
!new_content_map.contains_key(&PathBuf::from(rel)),
"failed preload must be dropped from the success map"
);
assert_eq!(
preload_errors.len(),
1,
"the failing read must surface as exactly one error message"
);
assert!(
preload_errors[0].contains(rel),
"error message should name the failing path: {:?}",
preload_errors[0]
);
travelagent_core::reanchor::reanchor_comments(
&mut session,
&old_map,
&new_content_map,
&new_paths,
false,
);
let review = session
.files
.get(&PathBuf::from(rel))
.expect("file still tracked");
assert!(
review.orphaned_comments.is_empty(),
"preload failure must NOT orphan the comment"
);
let at_5 = review
.line_comments
.get(&5)
.expect("comment still anchored at line 5");
assert_eq!(at_5.len(), 1);
assert_eq!(at_5[0].id, cid);
match at_5[0].anchor.as_ref().expect("anchor stamped") {
AnchorState::Anchored { line, side, .. } => {
assert_eq!(*line, 5);
assert_eq!(*side, LineSide::New);
}
other => panic!("expected Anchored, got {other:?}"),
}
}
#[test]
fn vanished_file_still_orphans_even_after_split() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/truly-gone.rs";
let mut session = make_session(repo);
let _cid = add_commented_file(&mut session, rel, 2, "genuinely gone");
let mut old_map = HashMap::new();
old_map.insert(PathBuf::from(rel), "a\nb\nc\n".to_string());
let new_paths = HashSet::new();
let preload = App::load_new_contents(repo, &new_paths);
let (new_content_map, _errors) = App::collect_preload_successes(&preload);
travelagent_core::reanchor::reanchor_comments(
&mut session,
&old_map,
&new_content_map,
&new_paths,
false,
);
let review = session.files.get(&PathBuf::from(rel)).unwrap();
assert!(
review.line_comments.is_empty(),
"vanished file should orphan, not preserve"
);
assert_eq!(
review.orphaned_comments.len(),
1,
"comment from vanished file lands in the orphan bucket"
);
}
#[test]
fn preload_failure_preserves_previous_cache_for_next_rescan() {
use std::io::{Error, ErrorKind};
let rel = PathBuf::from("src/flaky.rs");
let paths = vec![rel.clone()];
let mut old_new_content = HashMap::new();
old_new_content.insert(rel.clone(), "pre\ncontent\nsnapshot\n".to_string());
let mut preload: HashMap<PathBuf, io::Result<String>> = HashMap::new();
preload.insert(
rel.clone(),
Err(Error::new(
ErrorKind::PermissionDenied,
"simulated I/O flap",
)),
);
let next_cache =
App::refresh_cache_with_preload_fallback(&paths, &preload, &old_new_content);
assert_eq!(
next_cache.get(&rel).map(String::as_str),
Some("pre\ncontent\nsnapshot\n"),
"failed preload must fall back to the previous snapshot, not the empty string"
);
}
#[test]
fn successful_preload_overwrites_previous_cache() {
let rel = PathBuf::from("src/ok.rs");
let paths = vec![rel.clone()];
let mut old_new_content = HashMap::new();
old_new_content.insert(rel.clone(), "stale\n".to_string());
let mut preload: HashMap<PathBuf, io::Result<String>> = HashMap::new();
preload.insert(rel.clone(), Ok("fresh\n".to_string()));
let next_cache =
App::refresh_cache_with_preload_fallback(&paths, &preload, &old_new_content);
assert_eq!(
next_cache.get(&rel).map(String::as_str),
Some("fresh\n"),
"successful preload must overwrite the pre-rescan snapshot"
);
}
}
#[cfg(test)]
mod l3_tests {
use super::super::{DiffSource, InputMode};
use super::*;
use std::collections::HashSet;
use std::path::Path;
use std::sync::Mutex;
use tempfile::TempDir;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{
Comment, CommentType, DiffHunk, DiffLine, FileStatus, LineOrigin, LineSide,
SessionDiffSource,
};
use travelagent_core::vcs::{VcsBackend, VcsInfo, VcsType};
struct ScriptedVcs {
info: VcsInfo,
next_diff: Mutex<Vec<DiffFile>>,
}
impl VcsBackend for ScriptedVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
let diff = self.next_diff.lock().unwrap().clone();
if diff.is_empty() {
Err(TrvError::NoChanges)
} else {
Ok(diff)
}
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn make_file(path: &str, new_lines: &[&str]) -> DiffFile {
let lines: Vec<DiffLine> = new_lines
.iter()
.enumerate()
.map(|(i, s)| DiffLine {
origin: LineOrigin::Context,
content: (*s).to_string(),
old_lineno: Some(i as u32 + 1),
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let count = lines.len() as u32;
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(path)),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: format!("@@ -1,{count} +1,{count} @@"),
lines,
old_start: 1,
old_count: count,
new_start: 1,
new_count: count,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn build_app(tmp: &Path, initial: Vec<DiffFile>) -> super::super::App {
let vcs_info = VcsInfo {
root_path: tmp.to_path_buf(),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
let vcs = ScriptedVcs {
info: vcs_info.clone(),
next_diff: Mutex::new(initial.clone()),
};
super::super::App::build(
Box::new(vcs),
vcs_info,
crate::theme::Theme::dark(),
None,
false,
initial,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
super::super::AppMode::Local(super::super::LocalState::default()),
)
.expect("app")
}
fn write(repo: &Path, rel: &str, content: &str) {
let abs = repo.join(rel);
if let Some(p) = abs.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(&abs, content).unwrap();
}
#[test]
fn pending_rescan_deferred_in_non_normal_modes() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
app.nav.input_mode = InputMode::Comment;
assert!(app.nav.input_mode != InputMode::Normal);
app.live.pending_rescan = true;
assert!(app.live.pending_rescan);
assert_eq!(app.diff_files.len(), 1);
}
#[test]
fn pending_rescan_drains_on_return_to_normal() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
app.nav.input_mode = InputMode::Normal;
app.live.pending_rescan = true;
let count = app
.reload_diff_files()
.expect("reload succeeds after return to Normal");
app.live.pending_rescan = false;
assert!(!app.live.pending_rescan);
assert_eq!(count, 1);
}
#[test]
fn rescan_keeps_cursor_on_same_content_line_after_insert_above() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "a\nb\nc\nd\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["a", "b", "c", "d"])]);
match super::super::find_source_line(&app.line_annotations, 0, 3) {
super::super::FindSourceLineResult::Exact(idx)
| super::super::FindSourceLineResult::Nearest(idx) => {
app.diff_state.cursor_line = idx;
}
_ => panic!("could not find source line 3"),
}
let (line, side) = app.get_line_at_cursor().expect("cursor on diff row");
assert_eq!(line, 3);
assert_eq!(side, LineSide::New);
write(tmp.path(), "a.rs", "x\ny\na\nb\nc\nd\n");
let scripted = app.vcs.info(); let _ = scripted;
{
}
let mut app2 = build_app(
tmp.path(),
vec![make_file("a.rs", &["x", "y", "a", "b", "c", "d"])],
);
match super::super::find_source_line(&app2.line_annotations, 0, 5) {
super::super::FindSourceLineResult::Exact(idx)
| super::super::FindSourceLineResult::Nearest(idx) => {
app2.diff_state.cursor_line = idx;
}
_ => panic!("cannot locate new-line 5"),
}
let (line5, _) = app2.get_line_at_cursor().expect("new line 5");
assert_eq!(
line5, 5,
"content line 'c' moved from 3 to 5 after 2 inserts above"
);
}
#[test]
fn rescan_cursor_falls_back_to_clamped_when_line_deleted() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "a\nb\nc\nd\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["a", "b", "c", "d"])]);
if let super::super::FindSourceLineResult::Exact(idx) =
super::super::find_source_line(&app.line_annotations, 0, 3)
{
app.diff_state.cursor_line = idx;
}
write(tmp.path(), "a.rs", "a\nb\nd\n");
let _ = app.reload_diff_files();
assert!(app.diff_state.cursor_line < app.total_lines().max(1));
}
#[test]
fn rescan_preserves_expanded_dirs() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "src/a.rs", "alpha\n");
write(tmp.path(), "src/b.rs", "beta\n");
let mut app = build_app(
tmp.path(),
vec![
make_file("src/a.rs", &["alpha"]),
make_file("src/b.rs", &["beta"]),
],
);
app.ui_layout.expanded_dirs.insert("src".to_string());
app.ui_layout.expanded_dirs.insert("docs".to_string()); let before: HashSet<_> = app.ui_layout.expanded_dirs.clone();
let _ = app.reload_diff_files();
for dir in before {
assert!(
app.ui_layout.expanded_dirs.contains(&dir),
"expanded dir '{dir}' preserved across rescan"
);
}
}
#[test]
fn rescan_does_not_reset_user_collapsed_override() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
let path = PathBuf::from("a.rs");
app.engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
app.engine
.session_mut()
.get_file_mut(&path)
.unwrap()
.collapsed = Some(true);
let _ = app.reload_diff_files();
let review = app.engine.session().files.get(&path).unwrap();
assert_eq!(review.collapsed, Some(true), "user override preserved");
}
#[test]
fn reanchor_moves_orphan_to_line_comments_with_anchored_state() {
use travelagent_core::model::AnchorState;
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
let path = PathBuf::from("a.rs");
app.engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
let review = app.engine.session_mut().get_file_mut(&path).unwrap();
let c = Comment::new("stale".into(), CommentType::Note, Some(LineSide::New));
let cid = c.id.clone();
review.orphan_comment(5, LineSide::New, "gone".into(), c);
assert_eq!(review.orphaned_comments.len(), 1);
assert!(review.line_comments.is_empty());
assert!(app.reanchor_orphan(&path, 0, 2, LineSide::New));
let review = app.engine.session().files.get(&path).unwrap();
assert!(
review.orphaned_comments.is_empty(),
"orphan removed from orphaned_comments"
);
let comments = review
.line_comments
.get(&2)
.expect("comment lives at new line 2 now");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].id, cid);
match comments[0].anchor.as_ref().unwrap() {
AnchorState::Anchored {
line,
side,
reanchored_at,
} => {
assert_eq!(*line, 2);
assert_eq!(*side, LineSide::New);
assert!(
reanchored_at.is_some(),
"re-anchored comment should stamp reanchored_at"
);
}
_ => panic!("re-anchored comment must be Anchored"),
}
}
#[test]
fn reanchor_returns_false_for_missing_file_or_bad_index() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "x\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["x"])]);
assert!(!app.reanchor_orphan(&PathBuf::from("missing.rs"), 0, 1, LineSide::New));
let path = PathBuf::from("a.rs");
app.engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
assert!(!app.reanchor_orphan(&path, 99, 1, LineSide::New));
}
fn seed_two_orphans(app: &mut super::super::App, path: &Path) -> (String, String) {
app.engine
.session_mut()
.add_file(path.to_path_buf(), FileStatus::Modified);
let review = app.engine.session_mut().get_file_mut(path).unwrap();
let c0 = Comment::new("first".into(), CommentType::Note, Some(LineSide::New));
let c1 = Comment::new("second".into(), CommentType::Note, Some(LineSide::New));
let id0 = c0.id.clone();
let id1 = c1.id.clone();
review.orphan_comment(5, LineSide::New, "gone-a".into(), c0);
review.orphan_comment(7, LineSide::New, "gone-b".into(), c1);
(id0, id1)
}
#[test]
fn reanchor_selected_orphan_uses_last_selection_for_multi_orphan_file() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\nthree\n");
let mut app = build_app(
tmp.path(),
vec![make_file("a.rs", &["one", "two", "three"])],
);
let path = PathBuf::from("a.rs");
let (id0, id1) = seed_two_orphans(&mut app, &path);
app.live.last_selected_orphan = Some((path.clone(), 1));
let (line, side) = seek_new_line(&app, 2);
app.diff_state.cursor_line = line;
crate::handler::reanchor_selected_orphan(&mut app);
let review = app.engine.session().files.get(&path).unwrap();
assert_eq!(
review.orphaned_comments.len(),
1,
"only the selected orphan should have moved out"
);
assert_eq!(
review.orphaned_comments[0].id, id0,
"the *other* orphan (id0) should remain orphaned"
);
let anchored = review.line_comments.get(&2).expect("line 2 has a comment");
assert_eq!(anchored[0].id, id1, "id1 got anchored at line 2");
assert_eq!(
app.live.last_selected_orphan, None,
"selection cleared after consumption"
);
let _ = side;
}
#[test]
fn reanchor_selected_orphan_rejects_without_selection_when_multiple_orphans() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
let path = PathBuf::from("a.rs");
seed_two_orphans(&mut app, &path);
let (line, _side) = seek_new_line(&app, 1);
app.diff_state.cursor_line = line;
crate::handler::reanchor_selected_orphan(&mut app);
let review = app.engine.session().files.get(&path).unwrap();
assert_eq!(
review.orphaned_comments.len(),
2,
"nothing should have been re-anchored"
);
assert!(
app.message.is_some(),
"user was prompted to select an orphan"
);
}
#[test]
fn reanchor_selected_orphan_falls_back_to_zero_for_single_orphan() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
let path = PathBuf::from("a.rs");
app.engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
let c = Comment::new("only".into(), CommentType::Note, Some(LineSide::New));
let cid = c.id.clone();
app.engine
.session_mut()
.get_file_mut(&path)
.unwrap()
.orphan_comment(9, LineSide::New, "gone".into(), c);
let (line, _side) = seek_new_line(&app, 2);
app.diff_state.cursor_line = line;
crate::handler::reanchor_selected_orphan(&mut app);
let review = app.engine.session().files.get(&path).unwrap();
assert!(review.orphaned_comments.is_empty());
let anchored = review.line_comments.get(&2).unwrap();
assert_eq!(anchored[0].id, cid);
}
fn seek_new_line(app: &super::super::App, target_new: u32) -> (usize, LineSide) {
use crate::app::AnnotatedLine;
for (idx, ann) in app.line_annotations.iter().enumerate() {
if let AnnotatedLine::DiffLine { new_lineno, .. } = ann
&& new_lineno == &Some(target_new)
{
return (idx, LineSide::New);
}
}
panic!("no annotated row for new line {target_new}");
}
#[test]
fn orphan_section_renders_in_annotations() {
let tmp = TempDir::new().unwrap();
write(tmp.path(), "a.rs", "one\ntwo\n");
let mut app = build_app(tmp.path(), vec![make_file("a.rs", &["one", "two"])]);
let path = PathBuf::from("a.rs");
app.engine
.session_mut()
.add_file(path.clone(), FileStatus::Modified);
let review = app.engine.session_mut().get_file_mut(&path).unwrap();
review.orphan_comment(
4,
LineSide::New,
"old".into(),
Comment::new("orph".into(), CommentType::Note, Some(LineSide::New)),
);
app.rebuild_annotations();
let has_header = app.line_annotations.iter().any(|a| {
matches!(
a,
crate::app::AnnotatedLine::OrphanedCommentsHeader {
file_idx: Some(_),
..
}
)
});
assert!(has_header, "orphan header present");
let orphan_rows = app
.line_annotations
.iter()
.filter(|a| matches!(a, crate::app::AnnotatedLine::OrphanedComment { .. }))
.count();
assert!(orphan_rows >= 1, "at least one orphan row present");
}
}
#[cfg(test)]
mod rescan_race_tests {
use super::super::{DiffSource, InputMode};
use super::*;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{
AnchorState, Comment, CommentType, DiffHunk, DiffLine, FileStatus, LineOrigin, LineSide,
SessionDiffSource,
};
use travelagent_core::vcs::{VcsBackend, VcsInfo, VcsType};
struct LiveLikeVcs {
info: VcsInfo,
files: Arc<Mutex<Vec<PathBuf>>>,
}
impl VcsBackend for LiveLikeVcs {
fn info(&self) -> &VcsInfo {
&self.info
}
fn get_working_tree_diff(&self) -> Result<Vec<DiffFile>> {
let files = self.files.lock().unwrap().clone();
let mut out = Vec::new();
for rel in files {
let abs = self.info.root_path.join(&rel);
let content = std::fs::read_to_string(&abs).unwrap_or_default();
let new_lines: Vec<&str> = content.lines().collect();
let lines: Vec<DiffLine> = new_lines
.iter()
.enumerate()
.map(|(i, s)| DiffLine {
origin: LineOrigin::Context,
content: (*s).to_string(),
old_lineno: Some(i as u32 + 1),
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let count = lines.len() as u32;
out.push(DiffFile {
old_path: None,
new_path: Some(rel),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: format!("@@ -1,{count} +1,{count} @@"),
lines,
old_start: 1,
old_count: count,
new_start: 1,
new_count: count,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
});
}
if out.is_empty() {
Err(TrvError::NoChanges)
} else {
Ok(out)
}
}
fn fetch_context_lines(
&self,
_file_path: &Path,
_file_status: FileStatus,
_start_line: u32,
_end_line: u32,
) -> Result<Vec<DiffLine>> {
Ok(Vec::new())
}
}
fn make_diff_file_from_disk(repo: &Path, rel: &str) -> DiffFile {
let content = std::fs::read_to_string(repo.join(rel)).unwrap_or_default();
let new_lines: Vec<&str> = content.lines().collect();
let lines: Vec<DiffLine> = new_lines
.iter()
.enumerate()
.map(|(i, s)| DiffLine {
origin: LineOrigin::Context,
content: (*s).to_string(),
old_lineno: Some(i as u32 + 1),
new_lineno: Some(i as u32 + 1),
highlighted_spans: None,
})
.collect();
let count = lines.len() as u32;
DiffFile {
old_path: None,
new_path: Some(PathBuf::from(rel)),
status: FileStatus::Modified,
hunks: vec![DiffHunk {
header: format!("@@ -1,{count} +1,{count} @@"),
lines,
old_start: 1,
old_count: count,
new_start: 1,
new_count: count,
}],
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
fn write_file(repo: &Path, rel: &str, content: &str) {
let abs = repo.join(rel);
if let Some(p) = abs.parent() {
std::fs::create_dir_all(p).unwrap();
}
std::fs::write(&abs, content).unwrap();
}
#[test]
fn rescan_moves_comment_when_file_rewritten_above() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path();
let rel = "src/a.rs";
let rel_path = PathBuf::from(rel);
let target_line: u32 = 5;
let inserted_lines: u32 = 3;
let v1 = "l1\nl2\nl3\nl4\ntarget\nl6\nl7\n";
write_file(repo, rel, v1);
let initial_diff = vec![make_diff_file_from_disk(repo, rel)];
let files_handle: Arc<Mutex<Vec<PathBuf>>> = Arc::new(Mutex::new(vec![rel_path.clone()]));
let vcs_info = VcsInfo {
root_path: repo.to_path_buf(),
head_commit: "head".to_string(),
branch_name: Some("main".to_string()),
vcs_type: VcsType::Git,
};
let session = ReviewSession::new(
vcs_info.root_path.clone(),
vcs_info.head_commit.clone(),
vcs_info.branch_name.clone(),
SessionDiffSource::WorkingTree,
);
let vcs = LiveLikeVcs {
info: vcs_info.clone(),
files: Arc::clone(&files_handle),
};
let mut app = super::super::App::build(
Box::new(vcs),
vcs_info,
crate::theme::Theme::dark(),
None,
false,
initial_diff,
session,
DiffSource::WorkingTree,
InputMode::Normal,
Vec::new(),
None,
crate::test_support::runtime_handle(),
super::super::AppMode::Local(super::super::LocalState::default()),
)
.expect("app");
let comment = Comment::new(
"keep me pointed at 'target'".to_string(),
CommentType::Note,
Some(LineSide::New),
);
let cid = comment.id.clone();
app.engine
.session_mut()
.add_file(rel_path.clone(), FileStatus::Modified);
let review = app
.engine
.session_mut()
.get_file_mut(&rel_path)
.expect("file present");
review.add_line_comment(target_line, comment);
assert_eq!(
app.live
.cached_file_contents
.get(&rel_path)
.map(String::as_str),
Some(v1),
"cache seeded with v1 at App construction"
);
let v2 = "new-a\nnew-b\nnew-c\nl1\nl2\nl3\nl4\ntarget\nl6\nl7\n";
write_file(repo, rel, v2);
let count = app.reload_diff_files().expect("reload succeeds");
assert_eq!(count, 1);
let review = app.engine.session().files.get(&rel_path).unwrap();
assert!(
review.orphaned_comments.is_empty(),
"comment should be re-anchored, not orphaned"
);
assert!(
!review.line_comments.contains_key(&target_line),
"comment must vacate the old line ({target_line}); if it is still here, \
the pre-change snapshot was not captured and AnchorMap degenerated \
to identity"
);
let moved_to = target_line + inserted_lines;
let comments = review
.line_comments
.get(&moved_to)
.unwrap_or_else(|| panic!("comment should have moved to line {moved_to}"));
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].id, cid);
match comments[0].anchor.as_ref().unwrap() {
AnchorState::Anchored { line, side, .. } => {
assert_eq!(*line, moved_to);
assert_eq!(*side, LineSide::New);
}
other => panic!("expected Anchored, got {other:?}"),
}
assert_eq!(
app.live
.cached_file_contents
.get(&rel_path)
.map(String::as_str),
Some(v2),
"cache refreshed with v2 at end of reload"
);
}
}