mod types;
mod views;
pub use types::*;
use std::collections::HashMap;
use std::time::Instant;
use git2::Repository;
use crate::config::{FilterPresets, LanguageConfig};
use crate::event::GitEvent;
use crate::filter::FilterQuery;
use crate::git::{CommitDiff, FilePatch, FileStatus, RepoInfo};
use crate::i18n::Language;
use crate::navigation::ListNavigation;
use crate::risk::RiskLevel;
use crate::session::AiSession;
use crate::staging::StagingGroup;
use crate::stats::ActivityTimeline;
use crate::suggestion::CommitSuggestion;
use crate::topology::{BranchRecommendations, BranchTopology};
pub struct App {
pub language: Language,
pub should_quit: bool,
pub repo_info: Option<RepoInfo>,
pub(crate) repo: Option<Repository>,
pub(crate) all_events: Vec<GitEvent>,
pub(crate) filtered_indices: Vec<usize>,
pub selected_index: usize,
pub show_detail: bool,
pub input_mode: InputMode,
pub filter_text: String,
pub filter_query: FilterQuery,
pub(crate) file_cache: HashMap<String, Vec<String>>,
pub branches: Vec<crate::git::BranchInfo>,
pub branch_selected_index: usize,
pub branch_create_input: String,
pub file_statuses: Vec<FileStatus>,
pub status_selected_index: usize,
pub commit_message: String,
pub commit_type: Option<CommitType>,
pub status_message: Option<String>,
pub status_message_level: StatusMessageLevel,
pub status_message_at: Option<Instant>,
pub is_loading: bool,
pub show_help: bool,
pub(crate) head_hash: Option<String>,
pub topology_cache: Option<BranchTopology>,
pub topology_nav: ListNavigation,
pub branch_recommendations_cache: Option<BranchRecommendations>,
pub commit_suggestions: Vec<CommitSuggestion>,
pub suggestion_selected_index: usize,
pub detail_nav: ListNavigation,
pub detail_diff_cache: Option<CommitDiff>,
pub stats_view: StatsViewState,
pub heatmap_view: HeatmapViewState,
pub file_history_view: FileHistoryViewState,
pub timeline_cache: Option<ActivityTimeline>,
pub blame_view: BlameViewState,
pub ownership_view: OwnershipViewState,
pub stash_view: StashViewState,
pub patch_view: PatchViewState,
pub branch_compare_view: BranchCompareViewState,
pub related_files_view: RelatedFilesViewState,
pub impact_score_view: ImpactScoreViewState,
pub change_coupling_view: ChangeCouplingViewState,
pub quality_score_view: QualityScoreViewState,
pub health_view: HealthViewState,
pub file_diff: FileDiffState,
pub commit_detail: CommitDetailState,
pub filter_presets: FilterPresets,
pub preset_save_slot: usize,
pub preset_save_name: String,
pub relevance_mode: bool,
pub working_files: Vec<String>,
pub(crate) relevance_scores: HashMap<String, f32>,
pub quick_action_selected_index: usize,
pub active_sidebar_panel: SidebarPanel,
pub sidebar_focused: bool,
pub sidebar_width_ratio: u16,
pub watch_mode: bool,
pub session_cache: Option<Vec<AiSession>>,
pub staged_risk_score: Option<f64>,
pub staged_risk_level: Option<RiskLevel>,
pub staging_groups: Vec<StagingGroup>,
pub review_queue_view: ReviewQueueViewState,
pub pr_create_state: crate::pr::PrCreateState,
pub review_pack_view: ReviewPackViewState,
pub next_actions_view: NextActionsViewState,
pub handoff_view: HandoffViewState,
}
impl App {
pub fn new() -> Self {
Self {
language: LanguageConfig::load().language,
should_quit: false,
repo_info: None,
repo: None,
all_events: Vec::new(),
filtered_indices: Vec::new(),
selected_index: 0,
show_detail: false,
input_mode: InputMode::Normal,
filter_text: String::new(),
filter_query: FilterQuery::default(),
file_cache: HashMap::new(),
branches: Vec::new(),
branch_selected_index: 0,
branch_create_input: String::new(),
file_statuses: Vec::new(),
status_selected_index: 0,
commit_message: String::new(),
commit_type: None,
status_message: None,
status_message_level: StatusMessageLevel::Info,
status_message_at: None,
is_loading: false,
show_help: false,
head_hash: None,
topology_cache: None,
topology_nav: ListNavigation::new(),
branch_recommendations_cache: None,
commit_suggestions: Vec::new(),
suggestion_selected_index: 0,
detail_nav: ListNavigation::new(),
detail_diff_cache: None,
stats_view: StatsViewState::default(),
heatmap_view: HeatmapViewState::default(),
file_history_view: FileHistoryViewState::default(),
timeline_cache: None,
blame_view: BlameViewState::default(),
ownership_view: OwnershipViewState::default(),
stash_view: StashViewState::default(),
patch_view: PatchViewState::default(),
branch_compare_view: BranchCompareViewState::default(),
related_files_view: RelatedFilesViewState::default(),
impact_score_view: ImpactScoreViewState::default(),
change_coupling_view: ChangeCouplingViewState::default(),
quality_score_view: QualityScoreViewState::default(),
health_view: HealthViewState::default(),
file_diff: FileDiffState::default(),
commit_detail: CommitDetailState::default(),
filter_presets: FilterPresets::load(),
preset_save_slot: 1,
preset_save_name: String::new(),
relevance_mode: false,
working_files: Vec::new(),
relevance_scores: HashMap::new(),
quick_action_selected_index: 0,
active_sidebar_panel: SidebarPanel::default(),
sidebar_focused: true,
sidebar_width_ratio: 33,
watch_mode: false,
session_cache: None,
staged_risk_score: None,
staged_risk_level: None,
staging_groups: Vec::new(),
review_queue_view: ReviewQueueViewState::default(),
pr_create_state: crate::pr::PrCreateState::default(),
review_pack_view: ReviewPackViewState::default(),
next_actions_view: NextActionsViewState::default(),
handoff_view: HandoffViewState::default(),
}
}
pub fn load(&mut self, repo_info: RepoInfo, events: Vec<GitEvent>) {
self.repo_info = Some(repo_info);
self.filtered_indices = (0..events.len()).collect();
self.all_events = events;
self.selected_index = 0;
self.filter_text.clear();
}
pub fn set_repo(&mut self, repo: Repository) {
self.repo = Some(repo);
}
pub fn get_repo(&self) -> Option<&Repository> {
self.repo.as_ref()
}
pub fn set_head_hash(&mut self, hash: String) {
self.head_hash = Some(if hash.len() > 7 {
hash[..7].to_string()
} else {
hash
});
}
pub fn head_hash(&self) -> Option<&str> {
self.head_hash.as_deref()
}
pub fn events(&self) -> impl Iterator<Item = &GitEvent> {
let all = &self.all_events;
self.filtered_indices
.iter()
.filter_map(move |&i| all.get(i))
}
pub fn event_at(&self, index: usize) -> Option<&GitEvent> {
self.filtered_indices
.get(index)
.and_then(|&i| self.all_events.get(i))
}
pub fn all_events(&self) -> &[GitEvent] {
&self.all_events
}
pub fn event_count(&self) -> usize {
self.filtered_indices.len()
}
pub fn quit(&mut self) {
self.should_quit = true;
}
pub fn move_up(&mut self) {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
pub fn move_down(&mut self) {
if !self.filtered_indices.is_empty()
&& self.selected_index < self.filtered_indices.len() - 1
{
self.selected_index += 1;
}
}
pub fn move_to_top(&mut self) {
self.selected_index = 0;
}
pub fn jump_to_head(&mut self) {
let Some(head_hash) = &self.head_hash else {
self.selected_index = 0;
return;
};
let head_index = self
.all_events
.iter()
.position(|e| e.short_hash == *head_hash);
let Some(head_idx) = head_index else {
self.selected_index = 0;
return;
};
if let Some(pos) = self.filtered_indices.iter().position(|&i| i == head_idx) {
self.selected_index = pos;
}
}
pub fn move_to_bottom(&mut self) {
if !self.filtered_indices.is_empty() {
self.selected_index = self.filtered_indices.len() - 1;
}
}
pub fn page_down(&mut self, page_size: usize) {
if !self.filtered_indices.is_empty() {
let max_index = self.filtered_indices.len() - 1;
self.selected_index = (self.selected_index + page_size).min(max_index);
}
}
pub fn page_up(&mut self, page_size: usize) {
self.selected_index = self.selected_index.saturating_sub(page_size);
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn close_help(&mut self) {
self.show_help = false;
}
pub fn start_quick_action_view(&mut self) {
self.input_mode = InputMode::QuickActionView;
self.quick_action_selected_index = 0;
}
pub fn end_quick_action_view(&mut self) {
self.input_mode = InputMode::Normal;
}
pub fn quick_action_move_down(&mut self) {
let max = QuickAction::all().len().saturating_sub(1);
self.quick_action_selected_index = (self.quick_action_selected_index + 1).min(max);
}
pub fn quick_action_move_up(&mut self) {
self.quick_action_selected_index = self.quick_action_selected_index.saturating_sub(1);
}
pub fn selected_quick_action(&self) -> Option<QuickAction> {
QuickAction::all()
.get(self.quick_action_selected_index)
.copied()
}
pub fn jump_to_next_label(&mut self) {
for i in (self.selected_index + 1)..self.filtered_indices.len() {
if let Some(&event_idx) = self.filtered_indices.get(i) {
if let Some(event) = self.all_events.get(event_idx) {
if event.has_labels() {
self.selected_index = i;
return;
}
}
}
}
}
pub fn jump_to_prev_label(&mut self) {
if self.selected_index == 0 {
return;
}
for i in (0..self.selected_index).rev() {
if let Some(&event_idx) = self.filtered_indices.get(i) {
if let Some(event) = self.all_events.get(event_idx) {
if event.has_labels() {
self.selected_index = i;
return;
}
}
}
}
}
pub fn selected_event(&self) -> Option<&GitEvent> {
self.filtered_indices
.get(self.selected_index)
.and_then(|&i| self.all_events.get(i))
}
pub fn open_detail(&mut self) {
if self.selected_event().is_some() {
self.show_detail = true;
self.detail_nav.reset();
self.detail_diff_cache = None;
}
}
pub fn close_detail(&mut self) {
self.show_detail = false;
self.detail_diff_cache = None;
}
pub fn set_detail_diff(&mut self, diff: CommitDiff) {
self.detail_diff_cache = Some(diff);
}
pub fn set_file_diff(&mut self, patch: FilePatch, path: String) {
self.file_diff.cache = Some(patch);
self.file_diff.cache_path = Some(path);
self.file_diff.scroll = 0;
}
pub fn clear_file_diff(&mut self) {
self.file_diff.cache = None;
self.file_diff.cache_path = None;
self.file_diff.scroll = 0;
}
pub fn file_diff_cache_path(&self) -> Option<&str> {
self.file_diff.cache_path.as_deref()
}
pub fn detail_move_up(&mut self) {
self.detail_nav.move_up();
}
pub fn detail_move_down(&mut self) {
if let Some(ref diff) = self.detail_diff_cache {
self.detail_nav.move_down(diff.files.len());
}
}
pub fn detail_adjust_scroll(&mut self, visible_lines: usize) {
self.detail_nav.adjust_scroll(visible_lines);
}
pub fn start_filter(&mut self) {
self.input_mode = InputMode::Filter;
}
pub fn end_filter(&mut self) {
self.input_mode = InputMode::Normal;
}
pub fn filter_push(&mut self, c: char) {
self.filter_text.push(c);
self.apply_filter();
self.update_filter_status();
}
pub fn filter_pop(&mut self) {
self.filter_text.pop();
self.apply_filter();
self.update_filter_status();
}
pub fn filter_clear(&mut self) {
self.filter_text.clear();
self.apply_filter();
self.status_message = None;
self.status_message_at = None;
}
fn update_filter_status(&mut self) {
let total = self.all_events.len();
let filtered = self.filtered_indices.len();
if !self.filter_text.is_empty() {
self.status_message = Some(format!("{}/{} commits matched", filtered, total));
}
}
pub(crate) fn apply_filter(&mut self) {
self.filter_query = FilterQuery::parse(&self.filter_text);
if self.filter_query.is_empty() {
self.filtered_indices = (0..self.all_events.len()).collect();
} else {
let hash_range_indices = self.calculate_hash_range_indices();
self.filtered_indices = self
.all_events
.iter()
.enumerate()
.filter(|(i, e)| {
if let Some((start_idx, end_idx)) = hash_range_indices {
if *i < start_idx || *i > end_idx {
return false;
}
}
let files = self.file_cache.get(&e.short_hash).map(|v| v.as_slice());
self.filter_query.matches(e, files)
})
.map(|(i, _)| i)
.collect();
}
if self.selected_index >= self.filtered_indices.len() {
self.selected_index = self.filtered_indices.len().saturating_sub(1);
}
}
fn calculate_hash_range_indices(&self) -> Option<(usize, usize)> {
let (start_hash, end_hash) = self.filter_query.hash_range.as_ref()?;
let start_idx = self
.all_events
.iter()
.position(|e| e.short_hash.starts_with(start_hash))?;
let end_idx = self
.all_events
.iter()
.position(|e| e.short_hash.starts_with(end_hash))?;
if start_idx <= end_idx {
Some((start_idx, end_idx))
} else {
Some((end_idx, start_idx))
}
}
pub fn preload_file_cache(&mut self, get_files: impl Fn(&str) -> Option<Vec<String>>) {
if !self.filter_query.has_file_filter() {
return;
}
const MAX_PRELOAD_COMMITS: usize = 200;
const FILE_CACHE_MAX_SIZE: usize = 500;
for event in self.all_events.iter().take(MAX_PRELOAD_COMMITS) {
if self.file_cache.len() >= FILE_CACHE_MAX_SIZE {
break;
}
if !self.file_cache.contains_key(&event.short_hash) {
if let Some(files) = get_files(&event.short_hash) {
self.file_cache.insert(event.short_hash.clone(), files);
}
}
}
}
pub fn clear_file_cache(&mut self) {
self.file_cache.clear();
}
pub fn file_cache_is_empty(&self) -> bool {
self.file_cache.is_empty()
}
pub fn reapply_filter(&mut self) {
self.apply_filter();
}
pub fn repo_name(&self) -> &str {
self.repo_info
.as_ref()
.map(|r| r.name.as_str())
.unwrap_or("unknown")
}
pub fn branch_name(&self) -> &str {
self.repo_info
.as_ref()
.map(|r| r.branch.as_str())
.unwrap_or("unknown")
}
pub fn start_branch_select(&mut self, branches: Vec<crate::git::BranchInfo>) {
self.branches = branches;
let current = self.branch_name().to_string();
self.branch_selected_index = self
.branches
.iter()
.position(|b| b.name == current)
.unwrap_or(0);
self.input_mode = InputMode::BranchSelect;
}
pub fn end_branch_select(&mut self) {
self.input_mode = InputMode::Normal;
}
pub fn branch_move_up(&mut self) {
if self.branch_selected_index > 0 {
self.branch_selected_index -= 1;
}
}
pub fn branch_move_down(&mut self) {
if !self.branches.is_empty() && self.branch_selected_index < self.branches.len() - 1 {
self.branch_selected_index += 1;
}
}
pub fn selected_branch(&self) -> Option<&str> {
self.branches
.get(self.branch_selected_index)
.map(|b| b.name.as_str())
}
pub fn update_branch(&mut self, branch: String) {
if let Some(ref mut info) = self.repo_info {
info.branch = branch;
}
}
pub fn start_branch_create(&mut self) {
self.branch_create_input.clear();
self.input_mode = InputMode::BranchCreate;
}
pub fn end_branch_create(&mut self) {
self.input_mode = InputMode::BranchSelect;
self.branch_create_input.clear();
}
pub fn branch_create_push(&mut self, c: char) {
self.branch_create_input.push(c);
}
pub fn branch_create_pop(&mut self) {
self.branch_create_input.pop();
}
pub fn branch_create_name(&self) -> &str {
&self.branch_create_input
}
pub fn all_events_replace(&mut self, events: Vec<GitEvent>) {
self.all_events = events;
self.selected_index = 0;
}
pub fn annotate_events<F: FnMut(&mut GitEvent)>(&mut self, mut f: F) {
for event in &mut self.all_events {
f(event);
}
}
pub fn filtered_indices_reset(&mut self, count: usize) {
self.filtered_indices = (0..count).collect();
self.selected_index = 0;
}
pub fn start_status_view(&mut self, statuses: Vec<FileStatus>) {
self.file_statuses = statuses;
self.status_selected_index = 0;
self.status_message = None;
self.status_message_at = None;
self.input_mode = InputMode::StatusView;
}
pub fn end_status_view(&mut self) {
self.input_mode = InputMode::Normal;
self.status_message = None;
self.status_message_at = None;
}
pub fn status_move_up(&mut self) {
if self.status_selected_index > 0 {
self.status_selected_index -= 1;
}
}
pub fn status_move_down(&mut self) {
if !self.file_statuses.is_empty()
&& self.status_selected_index < self.file_statuses.len() - 1
{
self.status_selected_index += 1;
}
}
pub fn selected_file_status(&self) -> Option<&FileStatus> {
self.file_statuses.get(self.status_selected_index)
}
pub fn update_branches(&mut self, branches: Vec<crate::git::BranchInfo>) {
self.branches = branches;
if self.branch_selected_index >= self.branches.len() {
self.branch_selected_index = self.branches.len().saturating_sub(1);
}
}
pub fn update_file_statuses(&mut self, statuses: Vec<FileStatus>) {
self.file_statuses = statuses;
if self.status_selected_index >= self.file_statuses.len() {
self.status_selected_index = self.file_statuses.len().saturating_sub(1);
}
}
pub fn set_status_message(&mut self, msg: String) {
let level = if msg.contains("Failed")
|| msg.contains("failed")
|| msg.contains("Error")
|| msg.contains("error")
{
StatusMessageLevel::Error
} else if msg.contains("Success")
|| msg.contains("Committed")
|| msg.contains("Pushed")
|| msg.contains("Pulled")
|| msg.contains("Saved")
|| msg.contains("Deleted")
|| msg.contains("Stashed")
|| msg.contains("Fetched")
|| msg.contains("staged")
|| msg.contains("unstaged")
{
StatusMessageLevel::Success
} else {
StatusMessageLevel::Info
};
self.status_message_level = level;
self.status_message_at = Some(Instant::now());
self.status_message = Some(msg);
}
pub fn clear_expired_status_message(&mut self) {
if let Some(at) = self.status_message_at {
if at.elapsed().as_secs() >= 5 {
self.status_message = None;
self.status_message_at = None;
}
}
}
pub fn start_commit_input(&mut self) {
self.commit_message.clear();
self.commit_type = None;
self.input_mode = InputMode::CommitInput;
}
pub fn end_commit_input(&mut self) {
self.commit_type = None;
self.input_mode = InputMode::StatusView;
}
pub fn select_commit_type(&mut self, commit_type: CommitType) {
self.commit_type = Some(commit_type);
self.commit_message = commit_type.prefix().to_string();
}
pub fn commit_message_push(&mut self, c: char) {
self.commit_message.push(c);
}
pub fn commit_message_pop(&mut self) {
self.commit_message.pop();
if let Some(commit_type) = self.commit_type {
if !self
.commit_message
.starts_with(commit_type.prefix().trim_end())
{
self.commit_type = None;
}
}
}
pub fn commit_message_clear(&mut self) {
self.commit_message.clear();
self.commit_type = None;
}
pub fn start_loading(&mut self) {
self.is_loading = true;
}
pub fn finish_loading(&mut self) {
self.is_loading = false;
}
pub fn append_events(&mut self, mut events: Vec<GitEvent>) {
let current_len = self.all_events.len();
self.all_events.append(&mut events);
if self.filter_text.is_empty() {
let new_indices: Vec<usize> = (current_len..self.all_events.len()).collect();
self.filtered_indices.extend(new_indices);
} else {
self.apply_filter();
}
}
pub fn filter_description(&self) -> String {
self.filter_query.description()
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Local;
fn create_test_event(message: &str) -> GitEvent {
GitEvent::commit(
"abc1234".to_string(),
message.to_string(),
"author".to_string(),
Local::now(),
0,
0,
)
}
fn create_test_repo_info() -> RepoInfo {
RepoInfo {
name: "test-repo".to_string(),
branch: "main".to_string(),
}
}
#[test]
fn test_app_new_initializes_with_should_quit_false() {
let app = App::new();
assert!(!app.should_quit);
}
#[test]
fn test_app_new_initializes_with_empty_events() {
let app = App::new();
assert_eq!(app.event_count(), 0);
}
#[test]
fn test_app_new_initializes_with_no_repo_info() {
let app = App::new();
assert!(app.repo_info.is_none());
}
#[test]
fn test_app_quit_sets_should_quit_to_true() {
let mut app = App::new();
app.quit();
assert!(app.should_quit);
}
#[test]
fn test_app_default_is_same_as_new() {
let app1 = App::new();
let app2 = App::default();
assert_eq!(app1.should_quit, app2.should_quit);
}
#[test]
fn test_app_load_sets_repo_info() {
let mut app = App::new();
let repo_info = create_test_repo_info();
app.load(repo_info, vec![]);
assert!(app.repo_info.is_some());
assert_eq!(app.repo_name(), "test-repo");
}
#[test]
fn test_app_load_sets_events() {
let mut app = App::new();
let events = vec![create_test_event("commit 1"), create_test_event("commit 2")];
app.load(create_test_repo_info(), events);
assert_eq!(app.event_count(), 2);
}
#[test]
fn test_app_load_resets_selected_index() {
let mut app = App::new();
app.selected_index = 5;
app.load(create_test_repo_info(), vec![create_test_event("commit")]);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_move_down_increments_selected_index() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![create_test_event("1"), create_test_event("2")],
);
app.move_down();
assert_eq!(app.selected_index, 1);
}
#[test]
fn test_app_move_down_does_not_exceed_events_length() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![create_test_event("1"), create_test_event("2")],
);
app.move_down();
app.move_down();
app.move_down();
assert_eq!(app.selected_index, 1);
}
#[test]
fn test_app_move_up_decrements_selected_index() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![create_test_event("1"), create_test_event("2")],
);
app.selected_index = 1;
app.move_up();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_move_up_does_not_go_below_zero() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("1")]);
app.move_up();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_selected_event_returns_correct_event() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![create_test_event("first"), create_test_event("second")],
);
app.selected_index = 1;
let event = app.selected_event().unwrap();
assert_eq!(event.message, "second");
}
#[test]
fn test_app_selected_event_returns_none_when_empty() {
let app = App::new();
assert!(app.selected_event().is_none());
}
#[test]
fn test_app_repo_name_returns_unknown_when_no_repo() {
let app = App::new();
assert_eq!(app.repo_name(), "unknown");
}
#[test]
fn test_app_branch_name_returns_unknown_when_no_repo() {
let app = App::new();
assert_eq!(app.branch_name(), "unknown");
}
#[test]
fn test_app_new_initializes_with_show_detail_false() {
let app = App::new();
assert!(!app.show_detail);
}
#[test]
fn test_app_open_detail_sets_show_detail_true() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("commit")]);
app.open_detail();
assert!(app.show_detail);
}
#[test]
fn test_app_open_detail_does_nothing_when_no_events() {
let mut app = App::new();
app.open_detail();
assert!(!app.show_detail);
}
#[test]
fn test_app_close_detail_sets_show_detail_false() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("commit")]);
app.open_detail();
app.close_detail();
assert!(!app.show_detail);
}
#[test]
fn test_app_new_initializes_with_normal_mode() {
let app = App::new();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_app_start_filter_sets_filter_mode() {
let mut app = App::new();
app.start_filter();
assert_eq!(app.input_mode, InputMode::Filter);
}
#[test]
fn test_app_end_filter_sets_normal_mode() {
let mut app = App::new();
app.start_filter();
app.end_filter();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_app_filter_push_adds_character() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("test")]);
app.filter_push('a');
assert_eq!(app.filter_text, "a");
}
#[test]
fn test_app_filter_pop_removes_character() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("test")]);
app.filter_push('a');
app.filter_push('b');
app.filter_pop();
assert_eq!(app.filter_text, "a");
}
#[test]
fn test_app_filter_clear_removes_all() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("test")]);
app.filter_push('a');
app.filter_push('b');
app.filter_clear();
assert_eq!(app.filter_text, "");
}
#[test]
fn test_app_filter_filters_by_message() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("feat: add feature"),
create_test_event("fix: bug fix"),
create_test_event("feat: another feature"),
],
);
app.filter_push('f');
app.filter_push('i');
app.filter_push('x');
assert_eq!(app.event_count(), 1);
}
#[test]
fn test_app_filter_is_case_insensitive() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![create_test_event("FEAT: Add Feature")],
);
app.filter_push('f');
app.filter_push('e');
app.filter_push('a');
app.filter_push('t');
assert_eq!(app.event_count(), 1);
}
#[test]
fn test_app_filter_resets_selected_index() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("first"),
create_test_event("second"),
create_test_event("third"),
],
);
app.selected_index = 2;
app.filter_push('f');
app.filter_push('i');
app.filter_push('r');
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_start_branch_select_sets_mode() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![
BranchInfo::new("main".to_string(), false),
BranchInfo::new("develop".to_string(), false),
]);
assert_eq!(app.input_mode, InputMode::BranchSelect);
}
#[test]
fn test_app_start_branch_select_sets_branches() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![
BranchInfo::new("main".to_string(), false),
BranchInfo::new("develop".to_string(), false),
]);
assert_eq!(app.branches.len(), 2);
}
#[test]
fn test_app_start_branch_select_selects_current_branch() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]); app.start_branch_select(vec![
BranchInfo::new("develop".to_string(), false),
BranchInfo::new("main".to_string(), false),
]);
assert_eq!(app.branch_selected_index, 1);
}
#[test]
fn test_app_end_branch_select_keeps_branches() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![BranchInfo::new("main".to_string(), false)]);
app.end_branch_select();
assert!(!app.branches.is_empty());
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_app_branch_move_down_increments_index() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![
BranchInfo::new("a".to_string(), false),
BranchInfo::new("b".to_string(), false),
BranchInfo::new("c".to_string(), false),
]);
app.branch_selected_index = 0;
app.branch_move_down();
assert_eq!(app.branch_selected_index, 1);
}
#[test]
fn test_app_branch_move_up_decrements_index() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![
BranchInfo::new("a".to_string(), false),
BranchInfo::new("b".to_string(), false),
BranchInfo::new("c".to_string(), false),
]);
app.branch_selected_index = 2;
app.branch_move_up();
assert_eq!(app.branch_selected_index, 1);
}
#[test]
fn test_app_selected_branch_returns_correct_branch() {
use crate::git::BranchInfo;
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.start_branch_select(vec![
BranchInfo::new("a".to_string(), false),
BranchInfo::new("b".to_string(), false),
BranchInfo::new("c".to_string(), false),
]);
app.branch_selected_index = 1;
assert_eq!(app.selected_branch(), Some("b"));
}
#[test]
fn test_app_update_branch_changes_repo_info() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![]);
app.update_branch("new-branch".to_string());
assert_eq!(app.branch_name(), "new-branch");
}
fn create_test_file_status() -> FileStatus {
use crate::git::FileStatusKind;
FileStatus {
path: "test.txt".to_string(),
kind: FileStatusKind::Modified,
}
}
#[test]
fn test_app_start_status_view_sets_mode() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status()]);
assert_eq!(app.input_mode, InputMode::StatusView);
}
#[test]
fn test_app_start_status_view_sets_statuses() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
assert_eq!(app.file_statuses.len(), 2);
}
#[test]
fn test_app_end_status_view_keeps_statuses() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status()]);
app.end_status_view();
assert!(!app.file_statuses.is_empty());
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_app_status_move_down_increments_index() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
app.status_move_down();
assert_eq!(app.status_selected_index, 1);
}
#[test]
fn test_app_status_move_up_decrements_index() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status(), create_test_file_status()]);
app.status_selected_index = 1;
app.status_move_up();
assert_eq!(app.status_selected_index, 0);
}
#[test]
fn test_app_selected_file_status_returns_correct_status() {
let mut app = App::new();
app.start_status_view(vec![create_test_file_status()]);
let status = app.selected_file_status().unwrap();
assert_eq!(status.path, "test.txt");
}
#[test]
fn test_app_start_commit_input_sets_mode() {
let mut app = App::new();
app.start_commit_input();
assert_eq!(app.input_mode, InputMode::CommitInput);
}
#[test]
fn test_app_commit_message_push_adds_character() {
let mut app = App::new();
app.commit_message_push('t');
app.commit_message_push('e');
app.commit_message_push('s');
app.commit_message_push('t');
assert_eq!(app.commit_message, "test");
}
#[test]
fn test_app_commit_message_pop_removes_character() {
let mut app = App::new();
app.commit_message_push('a');
app.commit_message_push('b');
app.commit_message_pop();
assert_eq!(app.commit_message, "a");
}
#[test]
fn test_app_set_status_message_sets_message() {
let mut app = App::new();
app.set_status_message("Success".to_string());
assert_eq!(app.status_message, Some("Success".to_string()));
}
#[test]
fn test_app_new_initializes_with_is_loading_false() {
let app = App::new();
assert!(!app.is_loading);
}
#[test]
fn test_app_start_loading_sets_is_loading_true() {
let mut app = App::new();
app.start_loading();
assert!(app.is_loading);
}
#[test]
fn test_app_finish_loading_sets_is_loading_false() {
let mut app = App::new();
app.start_loading();
app.finish_loading();
assert!(!app.is_loading);
}
#[test]
fn test_app_append_events_adds_to_existing() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("first")]);
assert_eq!(app.event_count(), 1);
app.append_events(vec![
create_test_event("second"),
create_test_event("third"),
]);
assert_eq!(app.event_count(), 3);
}
#[test]
fn test_app_append_events_updates_filtered_indices() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("first")]);
app.append_events(vec![create_test_event("second")]);
assert_eq!(app.event_count(), 2);
}
#[test]
fn test_app_move_to_top_sets_selected_index_to_zero() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("1"),
create_test_event("2"),
create_test_event("3"),
],
);
app.selected_index = 2;
app.move_to_top();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_move_to_top_works_when_already_at_top() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("1")]);
app.selected_index = 0;
app.move_to_top();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_move_to_bottom_sets_selected_index_to_last() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("1"),
create_test_event("2"),
create_test_event("3"),
],
);
app.selected_index = 0;
app.move_to_bottom();
assert_eq!(app.selected_index, 2);
}
#[test]
fn test_app_move_to_bottom_does_nothing_when_empty() {
let mut app = App::new();
app.move_to_bottom();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_page_down_moves_by_page_size() {
let mut app = App::new();
let events: Vec<_> = (0..20)
.map(|i| create_test_event(&format!("{}", i)))
.collect();
app.load(create_test_repo_info(), events);
app.selected_index = 0;
app.page_down(10);
assert_eq!(app.selected_index, 10);
}
#[test]
fn test_app_page_down_stops_at_last_index() {
let mut app = App::new();
let events: Vec<_> = (0..5)
.map(|i| create_test_event(&format!("{}", i)))
.collect();
app.load(create_test_repo_info(), events);
app.selected_index = 0;
app.page_down(10);
assert_eq!(app.selected_index, 4);
}
#[test]
fn test_app_page_down_does_nothing_when_empty() {
let mut app = App::new();
app.page_down(10);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_page_up_moves_by_page_size() {
let mut app = App::new();
let events: Vec<_> = (0..20)
.map(|i| create_test_event(&format!("{}", i)))
.collect();
app.load(create_test_repo_info(), events);
app.selected_index = 15;
app.page_up(10);
assert_eq!(app.selected_index, 5);
}
#[test]
fn test_app_page_up_stops_at_zero() {
let mut app = App::new();
let events: Vec<_> = (0..5)
.map(|i| create_test_event(&format!("{}", i)))
.collect();
app.load(create_test_repo_info(), events);
app.selected_index = 3;
app.page_up(10);
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_new_initializes_with_show_help_false() {
let app = App::new();
assert!(!app.show_help);
}
#[test]
fn test_app_toggle_help_sets_show_help_true() {
let mut app = App::new();
app.toggle_help();
assert!(app.show_help);
}
#[test]
fn test_app_toggle_help_toggles_show_help() {
let mut app = App::new();
app.toggle_help();
assert!(app.show_help);
app.toggle_help();
assert!(!app.show_help);
}
#[test]
fn test_app_close_help_sets_show_help_false() {
let mut app = App::new();
app.show_help = true;
app.close_help();
assert!(!app.show_help);
}
#[test]
fn test_app_close_help_does_nothing_when_already_closed() {
let mut app = App::new();
app.close_help();
assert!(!app.show_help);
}
#[test]
fn test_app_jump_to_head_moves_to_head_event() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("1"),
create_test_event("2"),
create_test_event("3"),
],
);
app.set_head_hash("abc1234".to_string());
app.selected_index = 2;
app.jump_to_head();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_jump_to_head_works_when_already_at_head() {
let mut app = App::new();
app.load(create_test_repo_info(), vec![create_test_event("1")]);
app.set_head_hash("abc1234".to_string());
app.selected_index = 0;
app.jump_to_head();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_jump_to_head_finds_head_in_filtered_results() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("HEAD commit"),
create_test_event("other"),
create_test_event("HEAD related"),
],
);
app.set_head_hash("abc1234".to_string());
app.filter_push('H');
app.filter_push('E');
app.filter_push('A');
app.filter_push('D');
app.selected_index = 1;
app.jump_to_head();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_jump_to_head_does_nothing_when_head_not_in_filter() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("first"),
create_test_event("match me"),
create_test_event("another match me"),
],
);
app.set_head_hash("abc1234".to_string());
app.filter_push('m');
app.filter_push('a');
app.filter_push('t');
app.filter_push('c');
app.filter_push('h');
app.selected_index = 1;
app.jump_to_head();
assert_eq!(app.selected_index, 1);
}
#[test]
fn test_app_jump_to_head_without_head_hash_goes_to_first() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("1"),
create_test_event("2"),
create_test_event("3"),
],
);
app.selected_index = 2;
app.jump_to_head();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_set_head_hash_truncates_long_hash() {
let mut app = App::new();
app.set_head_hash("abcdef1234567890".to_string());
assert_eq!(app.head_hash, Some("abcdef1".to_string()));
}
#[test]
fn test_app_set_head_hash_keeps_short_hash() {
let mut app = App::new();
app.set_head_hash("abc1234".to_string());
assert_eq!(app.head_hash, Some("abc1234".to_string()));
}
fn create_labeled_event(message: &str, labels: Vec<&str>) -> GitEvent {
let mut event = GitEvent::commit(
"abc1234".to_string(),
message.to_string(),
"author".to_string(),
Local::now(),
0,
0,
);
event.branch_labels = labels.into_iter().map(|s| s.to_string()).collect();
event
}
#[test]
fn test_app_jump_to_next_label_finds_label() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("no label 1"),
create_test_event("no label 2"),
create_labeled_event("with label", vec!["main"]),
create_test_event("no label 3"),
],
);
app.selected_index = 0;
app.jump_to_next_label();
assert_eq!(app.selected_index, 2);
}
#[test]
fn test_app_jump_to_next_label_no_label_found() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("no label 1"),
create_test_event("no label 2"),
create_test_event("no label 3"),
],
);
app.selected_index = 0;
app.jump_to_next_label();
assert_eq!(app.selected_index, 0); }
#[test]
fn test_app_jump_to_prev_label_finds_label() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_labeled_event("with label", vec!["main"]),
create_test_event("no label 1"),
create_test_event("no label 2"),
create_test_event("no label 3"),
],
);
app.selected_index = 3;
app.jump_to_prev_label();
assert_eq!(app.selected_index, 0);
}
#[test]
fn test_app_jump_to_prev_label_no_label_found() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_test_event("no label 1"),
create_test_event("no label 2"),
create_test_event("no label 3"),
],
);
app.selected_index = 2;
app.jump_to_prev_label();
assert_eq!(app.selected_index, 2); }
#[test]
fn test_app_jump_to_prev_label_at_start() {
let mut app = App::new();
app.load(
create_test_repo_info(),
vec![
create_labeled_event("with label", vec!["main"]),
create_test_event("no label"),
],
);
app.selected_index = 0;
app.jump_to_prev_label();
assert_eq!(app.selected_index, 0); }
#[test]
fn test_commit_type_prefix_feat() {
assert_eq!(CommitType::Feat.prefix(), "feat: ");
}
#[test]
fn test_commit_type_prefix_fix() {
assert_eq!(CommitType::Fix.prefix(), "fix: ");
}
#[test]
fn test_commit_type_prefix_all_types() {
assert_eq!(CommitType::Docs.prefix(), "docs: ");
assert_eq!(CommitType::Style.prefix(), "style: ");
assert_eq!(CommitType::Refactor.prefix(), "refactor: ");
assert_eq!(CommitType::Test.prefix(), "test: ");
assert_eq!(CommitType::Chore.prefix(), "chore: ");
assert_eq!(CommitType::Perf.prefix(), "perf: ");
}
#[test]
fn test_commit_type_key_feat() {
assert_eq!(CommitType::Feat.key(), 'f');
}
#[test]
fn test_commit_type_key_fix() {
assert_eq!(CommitType::Fix.key(), 'x');
}
#[test]
fn test_commit_type_key_all_types() {
assert_eq!(CommitType::Docs.key(), 'd');
assert_eq!(CommitType::Style.key(), 's');
assert_eq!(CommitType::Refactor.key(), 'r');
assert_eq!(CommitType::Test.key(), 't');
assert_eq!(CommitType::Chore.key(), 'c');
assert_eq!(CommitType::Perf.key(), 'p');
}
#[test]
fn test_commit_type_all_returns_8_types() {
assert_eq!(CommitType::all().len(), 8);
}
#[test]
fn test_select_commit_type_sets_prefix() {
let mut app = App::new();
app.select_commit_type(CommitType::Feat);
assert_eq!(app.commit_message, "feat: ");
assert_eq!(app.commit_type, Some(CommitType::Feat));
}
#[test]
fn test_select_commit_type_fix() {
let mut app = App::new();
app.select_commit_type(CommitType::Fix);
assert_eq!(app.commit_message, "fix: ");
assert_eq!(app.commit_type, Some(CommitType::Fix));
}
#[test]
fn test_commit_message_pop_clears_type_when_prefix_removed() {
let mut app = App::new();
app.select_commit_type(CommitType::Feat);
for _ in 0..6 {
app.commit_message_pop();
}
assert_eq!(app.commit_type, None);
}
#[test]
fn test_commit_message_clear_clears_type() {
let mut app = App::new();
app.select_commit_type(CommitType::Feat);
app.commit_message_clear();
assert_eq!(app.commit_type, None);
assert_eq!(app.commit_message, "");
}
#[test]
fn test_start_commit_input_clears_type() {
let mut app = App::new();
app.commit_type = Some(CommitType::Feat);
app.start_commit_input();
assert_eq!(app.commit_type, None);
}
#[test]
fn test_end_commit_input_clears_type() {
let mut app = App::new();
app.start_commit_input();
app.select_commit_type(CommitType::Feat);
app.end_commit_input();
assert_eq!(app.commit_type, None);
}
#[test]
fn test_app_new_initializes_with_empty_suggestions() {
let app = App::new();
assert!(app.commit_suggestions.is_empty());
assert_eq!(app.suggestion_selected_index, 0);
}
#[test]
fn test_suggestion_move_down_increments_index() {
let mut app = App::new();
app.commit_suggestions = vec![
crate::suggestion::CommitSuggestion {
commit_type: CommitType::Feat,
scope: None,
message: "test1".to_string(),
confidence: 0.8,
},
crate::suggestion::CommitSuggestion {
commit_type: CommitType::Fix,
scope: None,
message: "test2".to_string(),
confidence: 0.6,
},
];
app.suggestion_move_down();
assert_eq!(app.suggestion_selected_index, 1);
}
#[test]
fn test_suggestion_move_up_decrements_index() {
let mut app = App::new();
app.commit_suggestions = vec![
crate::suggestion::CommitSuggestion {
commit_type: CommitType::Feat,
scope: None,
message: "test1".to_string(),
confidence: 0.8,
},
crate::suggestion::CommitSuggestion {
commit_type: CommitType::Fix,
scope: None,
message: "test2".to_string(),
confidence: 0.6,
},
];
app.suggestion_selected_index = 1;
app.suggestion_move_up();
assert_eq!(app.suggestion_selected_index, 0);
}
#[test]
fn test_apply_suggestion_sets_message_and_type() {
let mut app = App::new();
app.commit_suggestions = vec![crate::suggestion::CommitSuggestion {
commit_type: CommitType::Feat,
scope: Some("auth".to_string()),
message: "add login".to_string(),
confidence: 0.8,
}];
app.apply_suggestion(0);
assert_eq!(app.commit_type, Some(CommitType::Feat));
assert_eq!(app.commit_message, "feat(auth): add login");
}
#[test]
fn test_apply_suggestion_invalid_index_does_nothing() {
let mut app = App::new();
app.apply_suggestion(0);
assert_eq!(app.commit_type, None);
assert!(app.commit_message.is_empty());
}
#[test]
fn test_quick_action_view_lifecycle() {
let mut app = App::new();
app.start_quick_action_view();
assert_eq!(app.input_mode, InputMode::QuickActionView);
app.end_quick_action_view();
assert_eq!(app.input_mode, InputMode::Normal);
}
#[test]
fn test_quick_action_author_stats_opens_stats_view() {
let mut app = App::new();
let stats = crate::stats::RepoStats {
authors: vec![],
total_commits: 0,
total_insertions: 0,
total_deletions: 0,
};
app.start_stats_view(stats);
assert_eq!(app.input_mode, InputMode::StatsView);
}
#[test]
fn test_quick_action_heatmap_opens_heatmap_view() {
let mut app = App::new();
let heatmap = crate::stats::FileHeatmap {
files: vec![],
total_files: 0,
aggregation_level: crate::stats::AggregationLevel::default(),
};
app.start_heatmap_view(heatmap);
assert_eq!(app.input_mode, InputMode::HeatmapView);
}
}