use std::collections::BTreeMap;
use std::path::Path;
use travelagent_core::error::{Result, TrvError};
use travelagent_core::model::{DiffFile, ReviewSession, SessionDiffSource};
use travelagent_core::persistence::load_latest_session_for_context;
use travelagent_core::vcs::CommitInfo;
use super::{
App, DiffSource, DiffState, DiffViewMode, FileListState, FocusedPanel, InputMode,
VISIBLE_COMMIT_COUNT,
};
impl App {
pub fn enter_commit_select_mode(&mut self) -> Result<()> {
if !self.inline_selector.commits.is_empty() {
self.inline_selector.saved_selection = self.commit_select.selection_range;
}
let highlighter = self.theme.syntax_highlighter();
let has_staged_changes = match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(_) => true,
Err(TrvError::NoChanges) => false,
Err(TrvError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let has_unstaged_changes = match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(_) => true,
Err(TrvError::NoChanges) => false,
Err(TrvError::UnsupportedOperation(_)) => false,
Err(e) => return Err(e),
};
let commits = self.vcs.get_recent_commits(0, VISIBLE_COMMIT_COUNT)?;
if commits.is_empty() && !has_staged_changes && !has_unstaged_changes {
self.set_message("No commits or staged/unstaged changes found");
return Ok(());
}
self.commit_select.has_more = commits.len() >= VISIBLE_COMMIT_COUNT;
self.commit_select.list = commits;
if has_staged_changes {
self.commit_select
.list
.insert(0, Self::staged_commit_entry());
}
if has_unstaged_changes {
self.commit_select
.list
.insert(0, Self::unstaged_commit_entry());
}
self.commit_select.cursor = 0;
self.commit_select.scroll_offset = 0;
self.commit_select.selection_range = None;
self.commit_select.visible_count = self.commit_select.list.len();
self.nav.input_mode = InputMode::CommitSelect;
Ok(())
}
pub fn exit_commit_select_mode(&mut self) -> Result<()> {
self.nav.input_mode = InputMode::Normal;
if !self.inline_selector.commits.is_empty() {
self.commit_select.list = self.inline_selector.commits.clone();
self.commit_select.selection_range = self.inline_selector.saved_selection;
self.commit_select.cursor = 0;
self.commit_select.scroll_offset = 0;
self.commit_select.visible_count = self.inline_selector.commits.len();
self.commit_select.has_more = false;
self.inline_selector.saved_selection = None;
if self.commit_select.selection_range.is_some() {
self.reload_inline_selection()?;
}
return Ok(());
}
let needs_reload = self.diff_files.is_empty()
|| matches!(
self.diff_source,
DiffSource::CommitRange(_) | DiffSource::StagedUnstagedAndCommits(_)
);
if needs_reload {
let highlighter = self.theme.syntax_highlighter();
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) => {
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedAndUnstaged;
self.engine.apply_diff_files(&self.diff_files);
self.sort_files_by_directory(true);
self.expand_all_dirs();
}
Err(_) => {
self.set_message("No staged or unstaged changes");
}
}
}
Ok(())
}
pub fn toggle_diff_view_mode(&mut self) {
self.nav.diff_view_mode = match self.nav.diff_view_mode {
DiffViewMode::Unified => DiffViewMode::SideBySide,
DiffViewMode::SideBySide => DiffViewMode::Unified,
};
let mode_name = match self.nav.diff_view_mode {
DiffViewMode::Unified => "unified",
DiffViewMode::SideBySide => "side-by-side",
};
self.set_message(format!("Diff view mode: {mode_name}"));
self.rebuild_annotations();
}
pub fn toggle_file_list(&mut self) {
self.ui_layout.show_file_list = !self.ui_layout.show_file_list;
if !self.ui_layout.show_file_list && self.nav.focused_panel == FocusedPanel::FileList {
self.nav.focused_panel = FocusedPanel::Diff;
}
let status = if self.ui_layout.show_file_list {
"visible"
} else {
"hidden"
};
self.set_message(format!("File list: {status}"));
}
pub fn shrink_file_list(&mut self) {
if !self.ui_layout.show_file_list {
self.set_message("File list is hidden (\\ to toggle)");
return;
}
match self.ui_layout.shrink_file_list_width() {
Some(pct) => self.set_message(format!("File list width: {pct}%")),
None => self.set_message(format!(
"File list at min width ({}%)",
self.ui_layout.file_list_width_pct()
)),
}
}
pub fn grow_file_list(&mut self) {
if !self.ui_layout.show_file_list {
self.set_message("File list is hidden (\\ to toggle)");
return;
}
match self.ui_layout.grow_file_list_width() {
Some(pct) => self.set_message(format!("File list width: {pct}%")),
None => self.set_message(format!(
"File list at max width ({}%)",
self.ui_layout.file_list_width_pct()
)),
}
}
pub fn has_inline_commit_selector(&self) -> bool {
self.inline_selector.visible
&& self.inline_selector.commits.len() > 1
&& !matches!(&self.diff_source, DiffSource::WorkingTree)
}
pub fn commit_select_up(&mut self) {
if self.commit_select.cursor > 0 {
self.commit_select.cursor -= 1;
if self.commit_select.cursor < self.commit_select.scroll_offset {
self.commit_select.scroll_offset = self.commit_select.cursor;
}
}
}
pub fn commit_select_down(&mut self) {
let max_cursor = if self.can_show_more_commits() {
self.commit_select.visible_count
} else {
self.commit_select.visible_count.saturating_sub(1)
};
if self.commit_select.cursor < max_cursor {
self.commit_select.cursor += 1;
if self.commit_select.viewport_height > 0
&& self.commit_select.cursor
>= self.commit_select.scroll_offset + self.commit_select.viewport_height
{
self.commit_select.scroll_offset =
self.commit_select.cursor - self.commit_select.viewport_height + 1;
}
}
}
pub fn is_on_expand_row(&self) -> bool {
self.can_show_more_commits()
&& self.commit_select.cursor == self.commit_select.visible_count
}
pub fn can_show_more_commits(&self) -> bool {
self.commit_select.visible_count < self.commit_select.list.len()
|| self.commit_select.has_more
}
pub fn expand_commit(&mut self) -> Result<()> {
if self.commit_select.visible_count < self.commit_select.list.len() {
self.commit_select.visible_count = (self.commit_select.visible_count
+ self.commit_select.page_size)
.min(self.commit_select.list.len());
return Ok(());
}
if !self.commit_select.has_more {
self.set_message("No more commits");
return Ok(());
}
let offset = self.loaded_history_commit_count();
let limit = self.commit_select.page_size;
let new_commits = self.vcs.get_recent_commits(offset, limit)?;
if new_commits.is_empty() {
self.commit_select.has_more = false;
self.set_message("No more commits");
return Ok(());
}
if new_commits.len() < limit {
self.commit_select.has_more = false;
self.set_message("No more commits");
}
self.commit_select.list.extend(new_commits);
self.commit_select.visible_count = self.commit_select.list.len();
Ok(())
}
pub fn toggle_commit_selection(&mut self) {
let cursor = self.commit_select.cursor;
if cursor >= self.commit_select.list.len() {
return;
}
match self.commit_select.selection_range {
None => {
self.commit_select.selection_range = Some((cursor, cursor));
}
Some((start, end)) => {
if cursor >= start && cursor <= end {
if start == end {
self.commit_select.selection_range = None;
} else if cursor == start {
self.commit_select.selection_range = Some((start + 1, end));
} else if cursor == end {
self.commit_select.selection_range = Some((start, end - 1));
} else {
self.commit_select.selection_range = Some((start, cursor));
}
} else {
let new_start = start.min(cursor);
let new_end = end.max(cursor);
self.commit_select.selection_range = Some((new_start, new_end));
}
}
}
}
pub fn is_commit_selected(&self, index: usize) -> bool {
match self.commit_select.selection_range {
Some((start, end)) => index >= start && index <= end,
None => false,
}
}
pub fn cycle_commit_next(&mut self) {
if self.inline_selector.commits.is_empty() {
return;
}
let n = self.inline_selector.commits.len();
let all_selected = Some((0, n - 1));
if self.commit_select.selection_range == all_selected {
self.commit_select.selection_range = Some((n - 1, n - 1));
self.commit_select.cursor = n - 1;
} else if let Some((i, j)) = self.commit_select.selection_range {
if i == j {
if i == n - 1 {
self.commit_select.selection_range = all_selected;
} else {
self.commit_select.selection_range = Some((i + 1, i + 1));
self.commit_select.cursor = i + 1;
}
} else {
self.commit_select.selection_range = Some((j, j));
self.commit_select.cursor = j;
}
} else {
self.commit_select.selection_range = all_selected;
}
}
pub fn cycle_commit_prev(&mut self) {
if self.inline_selector.commits.is_empty() {
return;
}
let n = self.inline_selector.commits.len();
let all_selected = Some((0, n - 1));
if self.commit_select.selection_range == all_selected {
self.commit_select.selection_range = Some((0, 0));
self.commit_select.cursor = 0;
} else if let Some((i, j)) = self.commit_select.selection_range {
if i == j {
if i == 0 {
self.commit_select.selection_range = all_selected;
} else {
self.commit_select.selection_range = Some((i - 1, i - 1));
self.commit_select.cursor = i - 1;
}
} else {
self.commit_select.selection_range = Some((i, i));
self.commit_select.cursor = i;
}
} else {
self.commit_select.selection_range = all_selected;
}
}
pub fn confirm_commit_selection(&mut self) -> Result<()> {
let Some((start, end)) = self.commit_select.selection_range else {
self.set_message("Select at least one commit");
return Ok(());
};
let selected_commits: Vec<&CommitInfo> = (start..=end)
.rev()
.filter_map(|i| self.commit_select.list.get(i))
.collect();
if selected_commits.is_empty() {
self.set_message("Select at least one commit");
return Ok(());
}
let selected_staged = selected_commits.iter().any(|c| Self::is_staged_commit(c));
let selected_unstaged = selected_commits.iter().any(|c| Self::is_unstaged_commit(c));
let selected_ids: Vec<String> = selected_commits
.iter()
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
.collect();
if (selected_staged || selected_unstaged) && !selected_ids.is_empty() {
let all_selected: Vec<CommitInfo> = selected_commits.into_iter().cloned().collect();
return self.load_staged_unstaged_and_commits_selection(selected_ids, all_selected);
}
if selected_staged && selected_unstaged {
return self.load_staged_and_unstaged_selection();
}
if selected_staged {
return self.load_staged_selection();
}
if selected_unstaged {
return self.load_unstaged_selection();
}
let highlighter = self.theme.syntax_highlighter();
let diff_files = Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
)?;
if diff_files.is_empty() {
self.set_message("No changes in selected commits");
return Ok(());
}
let newest_commit_id = selected_ids.last().ok_or(TrvError::NoChanges)?.clone();
let loaded_session = load_latest_session_for_context(
&self.vcs_info.root_path,
self.vcs_info.branch_name.as_deref(),
&newest_commit_id,
SessionDiffSource::CommitRange,
Some(selected_ids.as_slice()),
)
.ok()
.and_then(|found| found.map(|(_path, session)| session));
let mut session = loaded_session.unwrap_or_else(|| {
let mut session = ReviewSession::new(
self.vcs_info.root_path.clone(),
newest_commit_id,
self.vcs_info.branch_name.clone(),
SessionDiffSource::CommitRange,
);
session.commit_range = Some(selected_ids.clone());
session
});
if session.commit_range.is_none() {
session.commit_range = Some(selected_ids.clone());
session.updated_at = chrono::Utc::now();
}
self.engine.reset_with_diff(session, &diff_files);
self.diff_files = diff_files;
self.diff_source = DiffSource::CommitRange(selected_ids);
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.inline_selector.commits = selected_commits
.iter()
.rev()
.map(|c| (*c).clone())
.collect();
self.invalidate_tour_score_cache();
self.inline_selector.range_diff_files = Some(self.diff_files.clone());
self.commit_select.list = self.inline_selector.commits.clone();
self.commit_select.cursor = 0;
self.commit_select.selection_range = if self.inline_selector.commits.is_empty() {
None
} else {
Some((0, self.inline_selector.commits.len() - 1))
};
self.commit_select.scroll_offset = 0;
self.commit_select.visible_count = self.inline_selector.commits.len();
self.commit_select.has_more = false;
self.inline_selector.visible = self.inline_selector.commits.len() > 1;
self.inline_selector.diff_cache.clear();
self.inline_selector.saved_selection = None;
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub fn reload_inline_selection(&mut self) -> Result<()> {
let Some((start, end)) = self.commit_select.selection_range else {
self.set_message("Select at least one commit");
return Ok(());
};
if start == 0
&& end == self.inline_selector.commits.len() - 1
&& let Some(ref files) = self.inline_selector.range_diff_files
{
self.diff_files = files.clone();
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.gaps.expanded_top.clear();
self.gaps.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
return Ok(());
}
if let Some(files) = self.inline_selector.diff_cache.get(&(start, end)) {
self.diff_files = files.clone();
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.gaps.expanded_top.clear();
self.gaps.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
return Ok(());
}
let has_staged = (start..=end).any(|i| {
self.inline_selector
.commits
.get(i)
.is_some_and(Self::is_staged_commit)
});
let has_unstaged = (start..=end).any(|i| {
self.inline_selector
.commits
.get(i)
.is_some_and(Self::is_unstaged_commit)
});
let selected_ids: Vec<String> = (start..=end)
.rev() .filter_map(|i| self.inline_selector.commits.get(i))
.filter(|c| !Self::is_special_commit(c))
.map(|c| c.id.clone())
.collect();
let highlighter = self.theme.syntax_highlighter();
let diff_files = if (has_staged || has_unstaged) && !selected_ids.is_empty() {
match Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TrvError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_staged && has_unstaged {
match Self::get_working_tree_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TrvError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_staged {
match Self::get_staged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TrvError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else if has_unstaged {
match Self::get_unstaged_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TrvError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
} else {
match Self::get_commit_range_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(files) => files,
Err(TrvError::NoChanges) => Vec::new(),
Err(e) => return Err(e),
}
};
self.inline_selector
.diff_cache
.insert((start, end), diff_files.clone());
self.diff_files = diff_files;
let wrap = self.diff_state.wrap_lines;
self.diff_state = DiffState::default();
self.diff_state.wrap_lines = wrap;
self.file_list_state = FileListState::default();
self.gaps.expanded_top.clear();
self.gaps.expanded_bottom.clear();
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
fn load_staged_unstaged_and_commits_selection(
&mut self,
selected_ids: Vec<String>,
selected_commits: Vec<CommitInfo>,
) -> Result<()> {
let highlighter = self.theme.syntax_highlighter();
let diff_files = match Self::get_working_tree_with_commits_diff_with_ignore(
self.vcs.as_ref(),
&self.vcs_info.root_path,
&selected_ids,
highlighter,
self.path_filter.as_deref(),
) {
Ok(diff_files) => diff_files,
Err(TrvError::NoChanges) => {
self.set_message("No changes in selected commits + staged/unstaged");
return Ok(());
}
Err(e) => return Err(e),
};
let session = Self::load_or_create_staged_unstaged_and_commits_session(
&self.vcs_info,
&selected_ids,
)?;
self.engine.reset_with_diff(session, &diff_files);
self.invalidate_tour_score_cache();
self.diff_files = diff_files;
self.diff_source = DiffSource::StagedUnstagedAndCommits(selected_ids);
self.nav.input_mode = InputMode::Normal;
self.diff_state = DiffState::default();
self.file_list_state = FileListState::default();
self.inline_selector.commits = selected_commits.into_iter().rev().collect();
self.inline_selector.range_diff_files = Some(self.diff_files.clone());
self.commit_select.list = self.inline_selector.commits.clone();
self.commit_select.cursor = 0;
self.commit_select.selection_range = if self.inline_selector.commits.is_empty() {
None
} else {
Some((0, self.inline_selector.commits.len() - 1))
};
self.commit_select.scroll_offset = 0;
self.commit_select.visible_count = self.inline_selector.commits.len();
self.commit_select.has_more = false;
self.inline_selector.visible = self.inline_selector.commits.len() > 1;
self.inline_selector.diff_cache.clear();
self.inline_selector.saved_selection = None;
self.insert_commit_message_if_single();
self.sort_files_by_directory(true);
self.expand_all_dirs();
self.rebuild_annotations();
Ok(())
}
pub(super) fn sort_files_by_directory(&mut self, reset_position: bool) {
let current_path = if reset_position {
None
} else {
self.current_file_path().cloned()
};
let mut dir_map: BTreeMap<String, Vec<DiffFile>> = BTreeMap::new();
let mut commit_msg_files: Vec<DiffFile> = Vec::new();
for file in self.diff_files.drain(..) {
if file.is_commit_message {
commit_msg_files.push(file);
continue;
}
let path = file.display_path_lossy();
let dir = if let Some(parent) = path.parent() {
if parent == Path::new("") {
".".to_string()
} else {
parent.to_string_lossy().to_string()
}
} else {
".".to_string()
};
dir_map.entry(dir).or_default().push(file);
}
self.diff_files.extend(commit_msg_files);
for (_dir, files) in dir_map {
self.diff_files.extend(files);
}
if let Some(path) = current_path
&& let Some(idx) = self
.diff_files
.iter()
.position(|f| f.display_path_lossy() == &path)
{
self.jump_to_file(idx);
return;
}
self.diff_state.cursor_line = 0;
self.diff_state.scroll_offset = 0;
self.diff_state.current_file_idx = 0;
}
}
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::test_support::cwd_lock;
use crate::theme::Theme;
use chrono::Utc;
use tempfile::TempDir;
use travelagent_core::model::{DiffFile, FileStatus};
use travelagent_core::vcs::CommitInfo;
fn build_test_app() -> (App, TempDir) {
let _lock = cwd_lock();
let dir = TempDir::new().unwrap();
let repo = git2::Repository::init(dir.path()).unwrap();
let sig = git2::Signature::now("test", "test@test.com").unwrap();
let tree_id = repo.index().unwrap().write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
std::fs::write(dir.path().join("test.txt"), "hello\n").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let theme = Theme::dark();
let app = App::new(
theme,
None,
false,
None,
true,
None,
None,
crate::test_support::runtime_handle(),
)
.unwrap();
std::env::set_current_dir(original_dir).unwrap();
(app, dir)
}
fn fake_commit(id: &str) -> CommitInfo {
CommitInfo {
id: id.to_string(),
short_id: id[..7.min(id.len())].to_string(),
branch_name: None,
summary: format!("commit {id}"),
body: None,
author: "test".to_string(),
time: Utc::now(),
}
}
fn fake_diff_file(path: &str) -> DiffFile {
DiffFile {
old_path: None,
new_path: Some(std::path::PathBuf::from(path)),
status: FileStatus::Modified,
hunks: Vec::new(),
is_binary: false,
is_too_large: false,
is_commit_message: false,
}
}
#[test]
fn reload_inline_selection_with_no_selection_sets_message_and_returns_ok() {
let (mut app, _dir) = build_test_app();
app.commit_select.selection_range = None;
let result = app.reload_inline_selection();
assert!(result.is_ok(), "no-selection branch must Ok-return");
let msg = app.message.as_ref().expect("status set");
assert!(
msg.content.contains("Select at least one commit"),
"expected friendly nudge, got: {}",
msg.content
);
}
#[test]
fn reload_inline_selection_full_range_uses_cached_range_diff_files() {
let (mut app, _dir) = build_test_app();
app.inline_selector.commits = vec![fake_commit("aaa1111"), fake_commit("bbb2222")];
app.commit_select.selection_range = Some((0, 1));
let cached = vec![fake_diff_file("src/cached.rs")];
app.inline_selector.range_diff_files = Some(cached.clone());
let result = app.reload_inline_selection();
assert!(result.is_ok());
assert_eq!(app.diff_files.len(), 1);
let path = app.diff_files[0]
.display_path_lossy()
.to_string_lossy()
.to_string();
assert_eq!(path, "src/cached.rs");
assert_eq!(app.diff_state.cursor_line, 0);
assert_eq!(app.diff_state.scroll_offset, 0);
}
#[test]
fn reload_inline_selection_subrange_uses_diff_cache() {
let (mut app, _dir) = build_test_app();
app.inline_selector.commits = vec![
fake_commit("aaa1111"),
fake_commit("bbb2222"),
fake_commit("ccc3333"),
];
app.commit_select.selection_range = Some((0, 1));
let cached_files = vec![fake_diff_file("src/sub.rs")];
app.inline_selector
.diff_cache
.insert((0, 1), cached_files.clone());
let result = app.reload_inline_selection();
assert!(result.is_ok());
assert_eq!(app.diff_files.len(), 1);
let path = app.diff_files[0]
.display_path_lossy()
.to_string_lossy()
.to_string();
assert_eq!(path, "src/sub.rs");
}
}