diff --git a/Cargo.toml b/Cargo.toml index 4257159..f8c6efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,10 @@ tokio = { version = "1.47.1", features = ["full"] } once_cell = "1.21.3" uuid = {version = "1.18.0", features = ["v4"]} async-trait = "0.1.83" +# TUI dependencies +ratatui = "0.29.0" +crossterm = "0.28.1" +tui-textarea = "0.6.1" [dev-dependencies] assert_cmd = "2.0.17" diff --git a/src/cli/commands/mod.rs b/src/cli/commands/mod.rs index cb21cbf..c5f5f5d 100644 --- a/src/cli/commands/mod.rs +++ b/src/cli/commands/mod.rs @@ -5,3 +5,4 @@ pub mod group_commit; pub mod lint; pub mod lint_message; pub mod tag; +pub mod tui; diff --git a/src/cli/commands/tui.rs b/src/cli/commands/tui.rs new file mode 100644 index 0000000..ed58e9d --- /dev/null +++ b/src/cli/commands/tui.rs @@ -0,0 +1,42 @@ +use crate::cli::Command; +use crate::error::CliError; +use crate::tui::{self, App}; +use log::info; +use structopt::StructOpt; + +#[derive(Debug, StructOpt)] +pub struct TuiCommand { + #[structopt(long = "ai", help = "Enable AI assistance for commit messages")] + ai: bool, + + #[structopt(long = "ai-provider", default_value = "openrouter", possible_values = &["openrouter", "ollama"])] + ai_provider: String, + + #[structopt(long = "ai-model", help = "AI model to use")] + ai_model: Option, +} + +impl Command for TuiCommand { + fn execute(&self, _non_interactive: bool) -> Result<(), CliError> { + info!("🚀 Starting Committy TUI"); + + // Initialize terminal + let mut terminal = tui::init()?; + + // Create app + let mut app = App::new()?; + + // Set AI options if enabled + if self.ai { + app.state.ai_enabled = true; + } + + // Run the TUI + let result = app.run(&mut terminal); + + // Restore terminal + tui::restore()?; + + result + } +} \ No newline at end of file diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2e7e889..421a0b0 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,6 +1,6 @@ pub mod commands; -use self::commands::{amend, branch, commit, group_commit, lint, lint_message, tag}; +use self::commands::{amend, branch, commit, group_commit, lint, lint_message, tag, tui}; use crate::error::CliError; use structopt::StructOpt; @@ -24,6 +24,8 @@ pub enum CliCommand { Branch(branch::BranchCommand), #[structopt(about = "Group changes and optionally commit/apply them (with optional AI)")] GroupCommit(group_commit::GroupCommitCommand), + #[structopt(about = "Interactive TUI for staging and committing changes")] + Tui(tui::TuiCommand), } impl CliCommand { @@ -36,6 +38,7 @@ impl CliCommand { CliCommand::LintMessage(cmd) => cmd.execute(non_interactive), CliCommand::Branch(cmd) => cmd.execute(non_interactive), CliCommand::GroupCommit(cmd) => cmd.execute(non_interactive), + CliCommand::Tui(cmd) => cmd.execute(non_interactive), } } } diff --git a/src/lib.rs b/src/lib.rs index a50bb4f..29e89d6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,5 +8,6 @@ pub mod linter; pub mod logger; pub mod release; pub mod telemetry; +pub mod tui; pub mod update; pub mod version; diff --git a/src/main.rs b/src/main.rs index c01b168..1b273eb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod linter; mod logger; mod release; mod telemetry; +mod tui; mod update; mod version; diff --git a/src/tui/app.rs b/src/tui/app.rs new file mode 100644 index 0000000..dcfed04 --- /dev/null +++ b/src/tui/app.rs @@ -0,0 +1,338 @@ +use super::{event::Event, state::AppState, ui, EventHandler, Tui}; +use crate::error::CliError; +use crossterm::event::{KeyCode, KeyModifiers}; + +pub struct App { + pub state: AppState, + pub running: bool, +} + +impl App { + pub fn new() -> Result { + let state = AppState::new().map_err(|e| CliError::Generic(e))?; + Ok(Self { + state, + running: true, + }) + } + + pub fn run(&mut self, terminal: &mut Tui) -> Result<(), CliError> { + let events = EventHandler::new(250); // 250ms tick rate + + while self.running { + terminal + .draw(|f| ui::render(f, &mut self.state)) + .map_err(|e| CliError::Generic(format!("Failed to draw: {}", e)))?; + + match events + .next() + .map_err(|e| CliError::Generic(format!("Failed to get event: {}", e)))? + { + Event::Key(key) => self.handle_key_event(key)?, + Event::Mouse(_) => {} + Event::Resize(_, _) => {} + Event::Tick => {} + } + } + + Ok(()) + } + + fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + use super::state::AppMode; + + // Global keybindings + match (key.code, key.modifiers) { + // Quit with Ctrl+C or ESC (depending on mode) + (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + self.running = false; + return Ok(()); + } + (KeyCode::Esc, _) if self.state.mode != AppMode::CommitMessage => { + self.running = false; + return Ok(()); + } + // Help + (KeyCode::Char('?'), _) | (KeyCode::F(1), _) => { + self.state.mode = AppMode::Help; + return Ok(()); + } + _ => {} + } + + // Mode-specific keybindings + match self.state.mode { + AppMode::FileSelection => self.handle_file_selection_keys(key)?, + AppMode::CommitMessage => self.handle_commit_message_keys(key)?, + AppMode::GroupView => self.handle_group_view_keys(key)?, + AppMode::DiffView => self.handle_diff_view_keys(key)?, + AppMode::Help => self.handle_help_keys(key)?, + } + + Ok(()) + } + + fn handle_file_selection_keys(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + self.state.move_selection_up(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.move_selection_down(); + } + KeyCode::Char(' ') => { + self.state.toggle_selected(); + } + KeyCode::Char('s') => { + match self.state.stage_selected() { + Ok(_) => { + self.state.success_message = Some("Files staged successfully".to_string()); + } + Err(e) => { + self.state.error_message = Some(format!("Failed to stage files: {}", e)); + } + } + } + KeyCode::Char('u') => { + match self.state.unstage_selected() { + Ok(_) => { + self.state.success_message = Some("Files unstaged successfully".to_string()); + } + Err(e) => { + self.state.error_message = Some(format!("Failed to unstage files: {}", e)); + } + } + } + KeyCode::Char('a') => { + // Select all + for file in &mut self.state.files { + file.selected = true; + } + } + KeyCode::Char('d') => { + // Deselect all + for file in &mut self.state.files { + file.selected = false; + } + } + KeyCode::Char('c') => { + // Go to commit message mode + if self.state.has_staged_files() { + self.state.mode = super::state::AppMode::CommitMessage; + self.state.current_field = super::state::CommitFormField::Type; + } else { + self.state.error_message = Some("No staged files to commit".to_string()); + } + } + KeyCode::Char('g') => { + // Auto-group and go to group view + if self.state.has_staged_files() { + self.state.create_auto_groups(); + if !self.state.groups.is_empty() { + self.state.mode = super::state::AppMode::GroupView; + } else { + self.state.error_message = Some("No groups created".to_string()); + } + } else { + self.state.error_message = Some("No staged files to group".to_string()); + } + } + KeyCode::Char('v') => { + // View diff + self.state.mode = super::state::AppMode::DiffView; + } + KeyCode::Char('f') => { + // Cycle file filter + self.state.cycle_filter(); + } + _ => {} + } + Ok(()) + } + + fn handle_commit_message_keys(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + use super::state::CommitFormField; + + // Debug logging + eprintln!("Key pressed: {:?}, Current field: {:?}", key.code, self.state.current_field); + + match key.code { + KeyCode::Esc => { + self.state.mode = super::state::AppMode::FileSelection; + } + KeyCode::Tab => { + self.state.next_field(); + } + KeyCode::BackTab => { + self.state.prev_field(); + } + KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Commit with Ctrl+Enter + if !self.state.commit_message.is_empty() { + self.perform_commit()?; + } else { + self.state.error_message = Some("Commit message cannot be empty".to_string()); + } + } + // Handle Space key for Type and BreakingChange fields BEFORE general Char handling + KeyCode::Char(' ') if self.state.current_field == CommitFormField::Type => { + eprintln!("Cycling commit type from: {}", self.state.commit_type); + self.state.cycle_commit_type(); + eprintln!("Cycled to: {}", self.state.commit_type); + } + KeyCode::Char(' ') if self.state.current_field == CommitFormField::BreakingChange => { + self.state.breaking_change = !self.state.breaking_change; + } + KeyCode::Char(' ') => { + // Space in text fields - only for Scope, ShortMessage, LongMessage + match self.state.current_field { + CommitFormField::Scope => { + self.state.commit_scope.push(' '); + } + CommitFormField::ShortMessage => { + self.state.commit_message.push(' '); + } + CommitFormField::LongMessage => { + self.state.commit_body.push(' '); + } + _ => {} + } + } + KeyCode::Char(c) => { + // Text input for other characters + match self.state.current_field { + CommitFormField::Scope => { + self.state.commit_scope.push(c); + } + CommitFormField::ShortMessage => { + self.state.commit_message.push(c); + } + CommitFormField::LongMessage => { + self.state.commit_body.push(c); + } + _ => {} + } + } + KeyCode::Backspace => { + // Delete last character + match self.state.current_field { + CommitFormField::Scope => { + self.state.commit_scope.pop(); + } + CommitFormField::ShortMessage => { + self.state.commit_message.pop(); + } + CommitFormField::LongMessage => { + self.state.commit_body.pop(); + } + _ => {} + } + } + KeyCode::Enter => { + // Newline in long message only + if self.state.current_field == CommitFormField::LongMessage { + self.state.commit_body.push('\n'); + } + } + _ => {} + } + Ok(()) + } + + fn handle_group_view_keys(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + match key.code { + KeyCode::Esc => { + self.state.mode = super::state::AppMode::FileSelection; + } + KeyCode::Up | KeyCode::Char('k') => { + if self.state.selected_group > 0 { + self.state.selected_group -= 1; + } + } + KeyCode::Down | KeyCode::Char('j') => { + if self.state.selected_group < self.state.groups.len().saturating_sub(1) { + self.state.selected_group += 1; + } + } + KeyCode::Char('c') => { + // Commit all groups + self.commit_all_groups()?; + } + _ => {} + } + Ok(()) + } + + fn handle_diff_view_keys(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.state.mode = super::state::AppMode::FileSelection; + } + _ => {} + } + Ok(()) + } + + fn handle_help_keys(&mut self, key: crossterm::event::KeyEvent) -> Result<(), CliError> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { + self.state.mode = super::state::AppMode::FileSelection; + } + _ => {} + } + Ok(()) + } + + fn perform_commit(&mut self) -> Result<(), CliError> { + use crate::git; + + let full_message = git::format_commit_message( + &self.state.commit_type, + self.state.breaking_change, + &self.state.commit_scope, + &self.state.commit_message, + &self.state.commit_body, + ); + + git::commit_changes(&full_message, false)?; + + self.state.success_message = Some("Commit created successfully!".to_string()); + self.running = false; // Exit after commit + Ok(()) + } + + fn commit_all_groups(&mut self) -> Result<(), CliError> { + use crate::git; + + for group in &self.state.groups { + // Message should be just the description, not include the type prefix + let message = group.suggested_message.as_ref() + .map(|m| m.clone()) + .unwrap_or_else(|| format!("update {} files", group.name)); + + let full_message = git::format_commit_message( + &group.commit_type, + false, + &group.name, + &message, + "", + ); + + // Stage only the files in this group + let repo = git2::Repository::open(".").map_err(CliError::from)?; + let mut index = repo.index().map_err(CliError::from)?; + + for file_path in &group.files { + index.add_path(file_path).map_err(CliError::from)?; + } + index.write().map_err(CliError::from)?; + + // Commit + git::commit_changes(&full_message, false)?; + } + + self.state.success_message = Some(format!("{} commits created successfully!", self.state.groups.len())); + self.running = false; // Exit after commits + Ok(()) + } +} \ No newline at end of file diff --git a/src/tui/event.rs b/src/tui/event.rs new file mode 100644 index 0000000..2c7b809 --- /dev/null +++ b/src/tui/event.rs @@ -0,0 +1,36 @@ +use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; +use std::time::Duration; + +#[derive(Clone, Copy, Debug)] +pub enum Event { + Tick, + Key(KeyEvent), + Mouse(MouseEvent), + Resize(u16, u16), +} + +pub struct EventHandler { + tick_rate: Duration, +} + +impl EventHandler { + pub fn new(tick_rate_ms: u64) -> Self { + Self { + tick_rate: Duration::from_millis(tick_rate_ms), + } + } + + /// Poll for the next event + pub fn next(&self) -> std::io::Result { + if event::poll(self.tick_rate)? { + match event::read()? { + CrosstermEvent::Key(key) => Ok(Event::Key(key)), + CrosstermEvent::Mouse(mouse) => Ok(Event::Mouse(mouse)), + CrosstermEvent::Resize(w, h) => Ok(Event::Resize(w, h)), + _ => Ok(Event::Tick), + } + } else { + Ok(Event::Tick) + } + } +} \ No newline at end of file diff --git a/src/tui/mod.rs b/src/tui/mod.rs new file mode 100644 index 0000000..21725cd --- /dev/null +++ b/src/tui/mod.rs @@ -0,0 +1,42 @@ +pub mod app; +pub mod event; +pub mod state; +pub mod ui; + +pub use app::App; +pub use event::{Event, EventHandler}; +pub use state::AppState; + +use crate::error::CliError; +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; + +pub type Tui = Terminal>; + +/// Initialize the terminal +pub fn init() -> Result { + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture) + .map_err(|e| CliError::Generic(format!("Failed to enter alternate screen: {}", e)))?; + enable_raw_mode() + .map_err(|e| CliError::Generic(format!("Failed to enable raw mode: {}", e)))?; + + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend) + .map_err(|e| CliError::Generic(format!("Failed to create terminal: {}", e)))?; + + Ok(terminal) +} + +/// Restore the terminal to its original state +pub fn restore() -> Result<(), CliError> { + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture) + .map_err(|e| CliError::Generic(format!("Failed to leave alternate screen: {}", e)))?; + disable_raw_mode() + .map_err(|e| CliError::Generic(format!("Failed to disable raw mode: {}", e)))?; + Ok(()) +} \ No newline at end of file diff --git a/src/tui/state.rs b/src/tui/state.rs new file mode 100644 index 0000000..dfd2b59 --- /dev/null +++ b/src/tui/state.rs @@ -0,0 +1,452 @@ +use crate::config::COMMIT_TYPES; +use git2::{Delta, DiffOptions, Repository}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub enum FileStatus { + Modified, + Added, + Deleted, + Renamed, + Typechange, +} + +#[derive(Debug, Clone)] +pub struct FileEntry { + pub path: PathBuf, + pub status: FileStatus, + pub staged: bool, + pub selected: bool, + pub suggested_group: Option, // For auto-grouping +} + +#[derive(Debug, Clone)] +pub struct CommitGroup { + pub name: String, + pub commit_type: String, + pub files: Vec, + pub suggested_message: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum AppMode { + FileSelection, // Select files to stage/unstage + CommitMessage, // Write commit message + GroupView, // View/edit auto-grouped commits + DiffView, // View diff for selected file + Help, // Show help +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum CommitFormField { + Type, + Scope, + ShortMessage, + LongMessage, + BreakingChange, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FileFilter { + All, + StagedOnly, + UnstagedOnly, +} + +pub struct AppState { + pub mode: AppMode, + pub files: Vec, + pub selected_index: usize, + pub scroll_offset: usize, + + // Commit form state + pub commit_type: String, + pub commit_type_index: usize, + pub commit_scope: String, + pub commit_message: String, + pub commit_body: String, + pub breaking_change: bool, + pub current_field: CommitFormField, + + // Groups for multi-commit feature + pub groups: Vec, + pub selected_group: usize, + + // AI integration + pub ai_enabled: bool, + pub ai_suggestions: HashMap, + + // UI state + pub file_filter: FileFilter, + pub collapsed_folders: std::collections::HashSet, + pub error_message: Option, + pub success_message: Option, +} + +impl AppState { + pub fn new() -> Result { + let files = Self::load_files()?; + + Ok(Self { + mode: AppMode::FileSelection, + files, + selected_index: 0, + scroll_offset: 0, + commit_type: COMMIT_TYPES[0].to_string(), + commit_type_index: 0, + commit_scope: String::new(), + commit_message: String::new(), + commit_body: String::new(), + breaking_change: false, + current_field: CommitFormField::Type, + groups: Vec::new(), + selected_group: 0, + ai_enabled: false, + ai_suggestions: HashMap::new(), + file_filter: FileFilter::All, + collapsed_folders: std::collections::HashSet::new(), + error_message: None, + success_message: None, + }) + } + + fn load_files() -> Result, String> { + let repo = Repository::open(".").map_err(|e| format!("Failed to open repository: {}", e))?; + + let mut files = Vec::new(); + + // Get HEAD tree + let head = repo.head().map_err(|e| format!("Failed to get HEAD: {}", e))?; + let head_tree = head.peel_to_tree().map_err(|e| format!("Failed to get HEAD tree: {}", e))?; + + // Get staged files (index vs HEAD) + let mut staged_opts = DiffOptions::new(); + let staged_diff = repo + .diff_tree_to_index(Some(&head_tree), None, Some(&mut staged_opts)) + .map_err(|e| format!("Failed to get staged diff: {}", e))?; + + let mut staged_files = std::collections::HashSet::new(); + staged_diff.foreach( + &mut |delta, _| { + let path = delta.new_file().path().unwrap_or_else(|| delta.old_file().path().unwrap()); + + // Skip directories + if let Ok(cwd) = std::env::current_dir() { + let full_path = cwd.join(path); + if full_path.is_dir() { + return true; + } + } + + let status = match delta.status() { + Delta::Added | Delta::Untracked => FileStatus::Added, + Delta::Deleted => FileStatus::Deleted, + Delta::Modified => FileStatus::Modified, + Delta::Renamed => FileStatus::Renamed, + Delta::Typechange => FileStatus::Typechange, + _ => FileStatus::Modified, + }; + + let suggested_group = Self::suggest_file_group(path); + + // Add staged files to the list + files.push(FileEntry { + path: path.to_path_buf(), + status, + staged: true, + selected: false, + suggested_group, + }); + + staged_files.insert(path.to_path_buf()); + true + }, + None, + None, + None, + ).ok(); + + // Get unstaged files (working dir vs index) + let mut unstaged_opts = DiffOptions::new(); + unstaged_opts.include_untracked(true); + unstaged_opts.recurse_untracked_dirs(true); // Recurse into untracked directories + let unstaged_diff = repo + .diff_index_to_workdir(None, Some(&mut unstaged_opts)) + .map_err(|e| format!("Failed to get unstaged diff: {}", e))?; + + unstaged_diff.foreach( + &mut |delta, _| { + let path = delta.new_file().path().unwrap_or_else(|| delta.old_file().path().unwrap()); + + // Skip if this is a directory (should not happen with recurse enabled, but be safe) + if let Ok(cwd) = std::env::current_dir() { + let full_path = cwd.join(path); + if full_path.is_dir() { + return true; // Skip directories + } + } + + let status = match delta.status() { + Delta::Added | Delta::Untracked => FileStatus::Added, + Delta::Deleted => FileStatus::Deleted, + Delta::Modified => FileStatus::Modified, + Delta::Renamed => FileStatus::Renamed, + Delta::Typechange => FileStatus::Typechange, + _ => FileStatus::Modified, + }; + + let is_staged = staged_files.contains(path); + let suggested_group = Self::suggest_file_group(path); + + files.push(FileEntry { + path: path.to_path_buf(), + status, + staged: is_staged, + selected: false, + suggested_group, + }); + true + }, + None, + None, + None, + ).ok(); + + Ok(files) + } + + fn suggest_file_group(path: &std::path::Path) -> Option { + let path_str = path.to_str()?; + + // Docs + if path_str.contains("README") || path_str.ends_with(".md") || path_str.contains("/docs/") { + return Some("docs".to_string()); + } + + // Tests + if path_str.contains("test") || path_str.contains("spec") || path_str.ends_with("_test.rs") { + return Some("tests".to_string()); + } + + // CI/CD + if path_str.contains(".github") || path_str.contains(".gitlab") || path_str.contains("ci") { + return Some("ci".to_string()); + } + + // Dependencies + if path_str.contains("Cargo.toml") || path_str.contains("package.json") || path_str.contains("requirements.txt") { + return Some("deps".to_string()); + } + + // Build + if path_str.contains("Makefile") || path_str.contains("build.rs") || path_str.contains("webpack") { + return Some("build".to_string()); + } + + None + } + + pub fn toggle_selected(&mut self) { + if let Some(file) = self.files.get_mut(self.selected_index) { + file.selected = !file.selected; + } + } + + pub fn stage_selected(&mut self) -> Result<(), String> { + let repo = Repository::open(".").map_err(|e| format!("Failed to open repository: {}", e))?; + let mut index = repo.index().map_err(|e| format!("Failed to get index: {}", e))?; + + for file in &mut self.files { + if file.selected { + // Validate the path + if !file.path.is_file() && file.status != FileStatus::Deleted { + return Err(format!("Invalid file path (might be a directory): {}", file.path.display())); + } + + match file.status { + FileStatus::Deleted => { + index.remove_path(&file.path).map_err(|e| { + format!("Failed to remove file '{}': {}", file.path.display(), e) + })?; + } + _ => { + // For untracked files, we need to add them to the working directory first + if file.status == FileStatus::Added && !file.path.exists() { + return Err(format!("File does not exist: {}", file.path.display())); + } + + index.add_path(&file.path).map_err(|e| { + format!("Failed to add file '{}': {} (path: {:?})", + file.path.display(), e, file.path) + })?; + } + } + file.staged = true; + file.selected = false; + } + } + + index.write().map_err(|e| format!("Failed to write index: {}", e))?; + Ok(()) + } + + pub fn unstage_selected(&mut self) -> Result<(), String> { + let repo = Repository::open(".").map_err(|e| format!("Failed to open repository: {}", e))?; + let mut index = repo.index().map_err(|e| format!("Failed to get index: {}", e))?; + + // Get HEAD commit once if needed for non-added files + let head_commit = if self.files.iter().any(|f| f.selected && f.staged && f.status != FileStatus::Added) { + let head = repo.head().map_err(|e| format!("Failed to get HEAD: {}", e))?; + Some(head.peel_to_commit().map_err(|e| format!("Failed to peel HEAD to commit: {}", e))?) + } else { + None + }; + + for file in &mut self.files { + if file.selected && file.staged { + // For newly added files (not in HEAD), we need to remove them from index + if file.status == FileStatus::Added { + index.remove_path(&file.path) + .map_err(|e| format!("Failed to unstage new file '{}': {}", file.path.display(), e))?; + } else if let Some(ref commit) = head_commit { + // For modified/deleted files, use reset_default with HEAD commit + repo.reset_default(Some(commit.as_object()), &[&file.path]) + .map_err(|e| format!("Failed to unstage file '{}': {}", file.path.display(), e))?; + } + + file.staged = false; + file.selected = false; + } + } + + index.write().map_err(|e| format!("Failed to write index: {}", e))?; + Ok(()) + } + + pub fn move_selection_up(&mut self) { + if self.selected_index > 0 { + self.selected_index -= 1; + + // Update scroll offset if selection moves above visible area + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } + } + } + + pub fn move_selection_down(&mut self) { + let visible = self.visible_files(); + let max_index = visible.len().saturating_sub(1); + + if self.selected_index < max_index { + self.selected_index += 1; + } + } + + pub fn update_scroll(&mut self, viewport_height: usize) { + // Ensure selected item is visible + if self.selected_index < self.scroll_offset { + self.scroll_offset = self.selected_index; + } else if self.selected_index >= self.scroll_offset + viewport_height { + self.scroll_offset = self.selected_index.saturating_sub(viewport_height - 1); + } + } + + pub fn create_auto_groups(&mut self) { + let mut groups_map: HashMap> = HashMap::new(); + + for file in &self.files { + if file.staged { + let group_name = file.suggested_group.clone().unwrap_or_else(|| "code".to_string()); + groups_map.entry(group_name).or_insert_with(Vec::new).push(file.path.clone()); + } + } + + self.groups = groups_map.into_iter() + .map(|(name, files)| { + let commit_type = match name.as_str() { + "docs" => "docs", + "tests" => "test", + "ci" => "ci", + "deps" => "build", + "build" => "build", + _ => "feat", + }.to_string(); + + CommitGroup { + name, + commit_type, + files, + suggested_message: None, + } + }) + .collect(); + } + + pub fn has_staged_files(&self) -> bool { + self.files.iter().any(|f| f.staged) + } + + pub fn visible_files(&self) -> Vec<&FileEntry> { + self.files.iter() + .filter(|f| { + match self.file_filter { + FileFilter::StagedOnly => f.staged, + FileFilter::UnstagedOnly => !f.staged, + FileFilter::All => true, + } + }) + .collect() + } + + pub fn cycle_filter(&mut self) { + self.file_filter = match self.file_filter { + FileFilter::All => FileFilter::StagedOnly, + FileFilter::StagedOnly => FileFilter::UnstagedOnly, + FileFilter::UnstagedOnly => FileFilter::All, + }; + + // Reset selection if it's out of bounds for the new filter + let visible_count = self.visible_files().len(); + if visible_count > 0 && self.selected_index >= visible_count { + self.selected_index = visible_count.saturating_sub(1); + } + if visible_count == 0 { + self.selected_index = 0; + } + } + + pub fn cycle_commit_type(&mut self) { + self.commit_type_index = (self.commit_type_index + 1) % COMMIT_TYPES.len(); + self.commit_type = COMMIT_TYPES[self.commit_type_index].to_string(); + } + + pub fn next_field(&mut self) { + self.current_field = match self.current_field { + CommitFormField::Type => CommitFormField::Scope, + CommitFormField::Scope => CommitFormField::ShortMessage, + CommitFormField::ShortMessage => CommitFormField::LongMessage, + CommitFormField::LongMessage => CommitFormField::BreakingChange, + CommitFormField::BreakingChange => CommitFormField::Type, + }; + } + + pub fn prev_field(&mut self) { + self.current_field = match self.current_field { + CommitFormField::Type => CommitFormField::BreakingChange, + CommitFormField::Scope => CommitFormField::Type, + CommitFormField::ShortMessage => CommitFormField::Scope, + CommitFormField::LongMessage => CommitFormField::ShortMessage, + CommitFormField::BreakingChange => CommitFormField::LongMessage, + }; + } + + pub fn toggle_folder(&mut self, folder: PathBuf) { + if self.collapsed_folders.contains(&folder) { + self.collapsed_folders.remove(&folder); + } else { + self.collapsed_folders.insert(folder); + } + } +} \ No newline at end of file diff --git a/src/tui/ui/commit_form.rs b/src/tui/ui/commit_form.rs new file mode 100644 index 0000000..9b635af --- /dev/null +++ b/src/tui/ui/commit_form.rs @@ -0,0 +1,125 @@ +use crate::tui::state::{AppState, CommitFormField}; +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Type + Constraint::Length(3), // Scope + Constraint::Length(5), // Short message + Constraint::Min(5), // Long message + Constraint::Length(3), // Breaking change toggle + ]) + .split(area); + + // Commit Type + let type_active = state.current_field == CommitFormField::Type; + let type_text = format!("{} (Space to cycle)", state.commit_type); + + let type_widget = Paragraph::new(type_text) + .block(Block::default() + .borders(Borders::ALL) + .title(if type_active { "► Commit Type [ACTIVE]" } else { "Commit Type" }) + .border_style(if type_active { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + })) + .style(Style::default().fg(Color::Cyan)); + + frame.render_widget(type_widget, chunks[0]); + + // Scope + let scope_active = state.current_field == CommitFormField::Scope; + let scope_text = if state.commit_scope.is_empty() { + "(type here...)".to_string() + } else { + state.commit_scope.clone() + }; + + let scope_widget = Paragraph::new(scope_text) + .block(Block::default() + .borders(Borders::ALL) + .title(if scope_active { "► Scope (optional) [ACTIVE]" } else { "Scope (optional)" }) + .border_style(if scope_active { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + })) + .style(Style::default().fg(if state.commit_scope.is_empty() { Color::DarkGray } else { Color::Yellow })); + + frame.render_widget(scope_widget, chunks[1]); + + // Short Message + let message_active = state.current_field == CommitFormField::ShortMessage; + let message_text = if state.commit_message.is_empty() { + "(type here...)".to_string() + } else { + state.commit_message.clone() + }; + + let message_widget = Paragraph::new(message_text) + .block(Block::default() + .borders(Borders::ALL) + .title(if message_active { "► Short Message [ACTIVE]" } else { "Short Message *REQUIRED*" }) + .border_style(if message_active { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + })) + .style(Style::default().fg(if state.commit_message.is_empty() { Color::DarkGray } else { Color::White })) + .wrap(Wrap { trim: false }); + + frame.render_widget(message_widget, chunks[2]); + + // Long Message + let body_active = state.current_field == CommitFormField::LongMessage; + let body_text = if state.commit_body.is_empty() { + "(type here... Enter for newline)".to_string() + } else { + state.commit_body.clone() + }; + + let body_widget = Paragraph::new(body_text) + .block(Block::default() + .borders(Borders::ALL) + .title(if body_active { "► Long Message (optional) [ACTIVE]" } else { "Long Message (optional)" }) + .border_style(if body_active { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + })) + .style(Style::default().fg(if state.commit_body.is_empty() { Color::DarkGray } else { Color::Gray })) + .wrap(Wrap { trim: false }); + + frame.render_widget(body_widget, chunks[3]); + + // Breaking Change Toggle + let breaking_active = state.current_field == CommitFormField::BreakingChange; + let breaking_text = if state.breaking_change { + "[x] Breaking Change (Space to toggle)" + } else { + "[ ] Breaking Change (Space to toggle)" + }; + + let breaking_widget = Paragraph::new(Line::from(vec![ + Span::styled(breaking_text, Style::default().fg(if state.breaking_change { Color::Red } else { Color::Gray })), + ])) + .block(Block::default() + .borders(Borders::ALL) + .border_style(if breaking_active { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default() + })) + .style(Style::default()); + + frame.render_widget(breaking_widget, chunks[4]); +} \ No newline at end of file diff --git a/src/tui/ui/file_list.rs b/src/tui/ui/file_list.rs new file mode 100644 index 0000000..e464bfc --- /dev/null +++ b/src/tui/ui/file_list.rs @@ -0,0 +1,89 @@ +use crate::tui::state::{AppState, FileStatus}; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, + Frame, +}; + +pub fn render(frame: &mut Frame, area: Rect, state: &mut AppState) { + // Update scroll based on viewport height (subtract borders and title) + let viewport_height = area.height.saturating_sub(2) as usize; + state.update_scroll(viewport_height); + + let visible_files = state.visible_files(); + + let items: Vec = visible_files + .iter() + .enumerate() + .map(|(idx, file)| { + let status_icon = match file.status { + FileStatus::Modified => "M", + FileStatus::Added => "A", + FileStatus::Deleted => "D", + FileStatus::Renamed => "R", + FileStatus::Typechange => "T", + }; + + let status_color = match file.status { + FileStatus::Modified => Color::Yellow, + FileStatus::Added => Color::Green, + FileStatus::Deleted => Color::Red, + FileStatus::Renamed => Color::Cyan, + FileStatus::Typechange => Color::Magenta, + }; + + let checkbox = if file.selected { + "[x]" + } else { + "[ ]" + }; + + let staged_marker = if file.staged { "●" } else { "○" }; + + let group_hint = if let Some(group) = &file.suggested_group { + format!(" ({})", group) + } else { + String::new() + }; + + let line = Line::from(vec![ + Span::styled(checkbox, Style::default().fg(Color::Cyan)), + Span::raw(" "), + Span::styled(staged_marker, Style::default().fg(if file.staged { Color::Green } else { Color::Gray })), + Span::raw(" "), + Span::styled(status_icon, Style::default().fg(status_color).add_modifier(Modifier::BOLD)), + Span::raw(" "), + Span::raw(file.path.display().to_string()), + Span::styled(group_hint, Style::default().fg(Color::DarkGray)), + ]); + + // Highlight selected item + let mut style = Style::default(); + if idx == state.selected_index { + style = style.bg(Color::DarkGray).add_modifier(Modifier::BOLD); + } + + ListItem::new(line).style(style) + }) + .collect(); + + use crate::tui::state::FileFilter; + + let title = match state.file_filter { + FileFilter::StagedOnly => "Files (Staged Only)", + FileFilter::UnstagedOnly => "Files (Unstaged Only)", + FileFilter::All => "Files (All)", + }; + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)); + + // Create list state with proper scrolling + let mut list_state = ListState::default(); + list_state.select(Some(state.selected_index)); + + frame.render_stateful_widget(list, area, &mut list_state); +} \ No newline at end of file diff --git a/src/tui/ui/group_view.rs b/src/tui/ui/group_view.rs new file mode 100644 index 0000000..5ce545a --- /dev/null +++ b/src/tui/ui/group_view.rs @@ -0,0 +1,70 @@ +use crate::tui::state::AppState; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +pub fn render(frame: &mut Frame, area: Rect, state: &AppState) { + if state.groups.is_empty() { + let empty = Paragraph::new("No groups available.\n\nPress 'g' in file selection mode to auto-group staged files.") + .block(Block::default().borders(Borders::ALL).title("Auto-grouped Commits")) + .style(Style::default().fg(Color::Gray)); + frame.render_widget(empty, area); + return; + } + + let items: Vec = state.groups.iter().enumerate().map(|(idx, group)| { + let count = group.files.len(); + let type_colored = Span::styled( + &group.commit_type, + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ); + + let file_list = group.files + .iter() + .map(|p| format!(" • {}", p.display())) + .collect::>() + .join("\n"); + + let suggested = group.suggested_message + .as_ref() + .map(|m| format!("\n Message: {}", m)) + .unwrap_or_default(); + + let lines = vec![ + Line::from(vec![ + Span::raw("Group: "), + Span::styled(&group.name, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw(" ("), + type_colored, + Span::raw(")"), + Span::styled(format!(" - {} files", count), Style::default().fg(Color::DarkGray)), + ]), + Line::from(file_list), + Line::from(suggested), + Line::from(""), + ]; + + let mut style = Style::default(); + if idx == state.selected_group { + style = style.bg(Color::DarkGray); + } + + ListItem::new(lines).style(style) + }).collect(); + + let list = List::new(items) + .block(Block::default() + .borders(Borders::ALL) + .title("Auto-grouped Commits - Review and Commit")) + .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD)); + + // Create list state for scrolling + let mut list_state = ListState::default(); + list_state.select(Some(state.selected_group)); + + frame.render_stateful_widget(list, area, &mut list_state); +} \ No newline at end of file diff --git a/src/tui/ui/help.rs b/src/tui/ui/help.rs new file mode 100644 index 0000000..d6f6a4c --- /dev/null +++ b/src/tui/ui/help.rs @@ -0,0 +1,108 @@ +use crate::tui::state::AppState; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph, Wrap}, + Frame, +}; + +pub fn render(frame: &mut Frame, area: Rect, _state: &AppState) { + let help_text = vec![ + Line::from(vec![ + Span::styled("Committy TUI Help", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("File Selection Mode:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(" ↑/k - Move up"), + Line::from(" ↓/j - Move down"), + Line::from(" Space - Toggle file selection"), + Line::from(" s - Stage selected files"), + Line::from(" u - Unstage selected files"), + Line::from(" a - Select all files"), + Line::from(" d - Deselect all files"), + Line::from(" c - Go to commit message (if staged files exist)"), + Line::from(" g - Auto-group staged files and go to group view"), + Line::from(" v - View diff for selected file"), + Line::from(" f - Cycle file filter (All → Staged → Unstaged)"), + Line::from(""), + Line::from(vec![ + Span::styled("Commit Message Mode:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(" Tab - Next field"), + Line::from(" Shift+Tab - Previous field"), + Line::from(" Space - Cycle commit type / Toggle breaking change"), + Line::from(" Type text - Input for scope, short message, long message"), + Line::from(" Backspace - Delete character"), + Line::from(" Enter - Newline in long message field"), + Line::from(" Ctrl+Enter - Create commit"), + Line::from(" Esc - Back to file selection"), + Line::from(""), + Line::from(vec![ + Span::styled("Group View Mode:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(" ↑/k - Move up"), + Line::from(" ↓/j - Move down"), + Line::from(" c - Commit all groups as separate commits"), + Line::from(" Esc - Back to file selection"), + Line::from(""), + Line::from(vec![ + Span::styled("Global Keys:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(" ?/F1 - Show this help"), + Line::from(" Ctrl+C/Esc - Quit"), + Line::from(""), + Line::from(vec![ + Span::styled("File Status Icons:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" M", Style::default().fg(Color::Yellow)), + Span::raw(" - Modified"), + ]), + Line::from(vec![ + Span::styled(" A", Style::default().fg(Color::Green)), + Span::raw(" - Added"), + ]), + Line::from(vec![ + Span::styled(" D", Style::default().fg(Color::Red)), + Span::raw(" - Deleted"), + ]), + Line::from(vec![ + Span::styled(" R", Style::default().fg(Color::Cyan)), + Span::raw(" - Renamed"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Staged Marker:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(vec![ + Span::styled(" ●", Style::default().fg(Color::Green)), + Span::raw(" - Staged"), + ]), + Line::from(vec![ + Span::styled(" ○", Style::default().fg(Color::Gray)), + Span::raw(" - Unstaged"), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Auto-grouping:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ]), + Line::from(" Files are automatically grouped by type:"), + Line::from(" - docs: README, .md files, /docs/ directories"), + Line::from(" - tests: test files, spec files"), + Line::from(" - ci: .github, .gitlab, CI configs"), + Line::from(" - deps: Cargo.toml, package.json, requirements.txt"), + Line::from(" - build: Makefile, build.rs, webpack configs"), + Line::from(""), + Line::from(Span::styled("Press ? or Esc to close this help", Style::default().fg(Color::Gray))), + ]; + + let help = Paragraph::new(help_text) + .block(Block::default().borders(Borders::ALL).title("Help")) + .style(Style::default().fg(Color::White)) + .wrap(Wrap { trim: false }); + + frame.render_widget(help, area); +} \ No newline at end of file diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs new file mode 100644 index 0000000..8d6642f --- /dev/null +++ b/src/tui/ui/mod.rs @@ -0,0 +1,149 @@ +mod file_list; +mod commit_form; +mod group_view; +mod help; + +use crate::tui::state::{AppMode, AppState}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +pub fn render(frame: &mut Frame, state: &mut AppState) { + let size = frame.area(); + + // Main layout + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Min(0), // Content + Constraint::Length(2), // Status bar + ]) + .split(size); + + // Render title + render_title(frame, chunks[0], state); + + // Render content based on mode + match state.mode { + AppMode::FileSelection => file_list::render(frame, chunks[1], state), + AppMode::CommitMessage => commit_form::render(frame, chunks[1], state), + AppMode::GroupView => group_view::render(frame, chunks[1], state), + AppMode::DiffView => render_diff_view(frame, chunks[1], state), + AppMode::Help => help::render(frame, chunks[1], state), + } + + // Render status bar + render_status_bar(frame, chunks[2], state); + + // Render messages overlay if any + if state.error_message.is_some() || state.success_message.is_some() { + render_message_overlay(frame, size, state); + } +} + +fn render_title(frame: &mut Frame, area: Rect, state: &AppState) { + let mode_text = match state.mode { + AppMode::FileSelection => "File Selection", + AppMode::CommitMessage => "Commit Message", + AppMode::GroupView => "Group View", + AppMode::DiffView => "Diff View", + AppMode::Help => "Help", + }; + + let title = Paragraph::new(vec![ + Line::from(vec![ + Span::styled("Committy TUI", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(" - "), + Span::styled(mode_text, Style::default().fg(Color::Yellow)), + ]), + ]) + .block(Block::default().borders(Borders::ALL)) + .alignment(Alignment::Center); + + frame.render_widget(title, area); +} + +fn render_status_bar(frame: &mut Frame, area: Rect, state: &AppState) { + use crate::tui::state::FileFilter; + + let status_text = match state.mode { + AppMode::FileSelection => { + let staged = state.files.iter().filter(|f| f.staged).count(); + let total = state.files.len(); + let filter_text = match state.file_filter { + FileFilter::All => "All", + FileFilter::StagedOnly => "Staged", + FileFilter::UnstagedOnly => "Unstaged", + }; + format!("Staged: {}/{} | Filter: {} | [f] Filter | [Space] Select | [s] Stage | [u] Unstage | [c] Commit | [g] Group | [?] Help", + staged, total, filter_text) + } + AppMode::CommitMessage => { + "[Tab/Shift+Tab] Navigate | [Space] Cycle/Toggle | [Ctrl+Enter] Commit | [Esc] Back".to_string() + } + AppMode::GroupView => { + format!("Groups: {} | [c] Commit All | [Esc] Back | [?] Help", state.groups.len()) + } + AppMode::DiffView => { + "[q/Esc] Back | [?] Help".to_string() + } + AppMode::Help => { + "[q/Esc/?] Close Help".to_string() + } + }; + + let status = Paragraph::new(status_text) + .style(Style::default().fg(Color::White).bg(Color::DarkGray)); + + frame.render_widget(status, area); +} + +fn render_message_overlay(frame: &mut Frame, area: Rect, state: &mut AppState) { + let (message, style) = if let Some(err) = &state.error_message { + (err.clone(), Style::default().fg(Color::White).bg(Color::Red)) + } else if let Some(success) = &state.success_message { + (success.clone(), Style::default().fg(Color::White).bg(Color::Green)) + } else { + return; + }; + + // Center the message + let message_width = (message.len() + 4).min(area.width as usize) as u16; + let message_area = Rect { + x: (area.width.saturating_sub(message_width)) / 2, + y: area.height / 2, + width: message_width, + height: 3, + }; + + let message_widget = Paragraph::new(message) + .style(style) + .block(Block::default().borders(Borders::ALL)) + .alignment(Alignment::Center); + + frame.render_widget(message_widget, message_area); + + // Clear messages after display (will be shown for one frame) + // In a real app, you'd want a timer +} + +fn render_diff_view(frame: &mut Frame, area: Rect, state: &AppState) { + let selected_file = state.files.get(state.selected_index); + + let text = if let Some(file) = selected_file { + format!("Diff view for: {}\n\n(Diff rendering to be implemented)", file.path.display()) + } else { + "No file selected".to_string() + }; + + let diff = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title("Diff View")) + .style(Style::default().fg(Color::White)); + + frame.render_widget(diff, area); +} \ No newline at end of file diff --git a/tests/tui_commit_form_tests.rs b/tests/tui_commit_form_tests.rs new file mode 100644 index 0000000..b13b86e --- /dev/null +++ b/tests/tui_commit_form_tests.rs @@ -0,0 +1,411 @@ +use serial_test::serial; + +use committy::tui::state::{AppState, CommitFormField}; +use git2::{Repository, Signature}; +use tempfile::TempDir; + +/// Helper to create a test repository with initial commit +fn setup_test_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + // Create initial commit + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + { + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Initial commit", + &tree, + &[], + ) + .unwrap(); + } + + (dir, repo) +} + +#[test] +#[serial] +fn test_commit_form_initial_state() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let state = AppState::new().unwrap(); + + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_scope, ""); + assert_eq!(state.commit_message, ""); + assert_eq!(state.commit_body, ""); + assert!(!state.breaking_change); + assert_eq!(state.current_field, CommitFormField::Type); +} + +#[test] +#[serial] +fn test_commit_type_cycling_all_types() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + let expected_types = vec![ + "feat", "fix", "build", "chore", "ci", "cd", "docs", + "perf", "refactor", "revert", "style", "test", "security", "config" + ]; + + for (i, expected_type) in expected_types.iter().enumerate() { + assert_eq!(state.commit_type, *expected_type); + assert_eq!(state.commit_type_index, i); + state.cycle_commit_type(); + } + + // Should wrap back to first + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_type_index, 0); +} + +#[test] +#[serial] +fn test_field_navigation_forward() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + let fields = vec![ + CommitFormField::Type, + CommitFormField::Scope, + CommitFormField::ShortMessage, + CommitFormField::LongMessage, + CommitFormField::BreakingChange, + ]; + + for (i, expected_field) in fields.iter().enumerate() { + assert_eq!(state.current_field, *expected_field, "Failed at step {}", i); + state.next_field(); + } + + // Should wrap to beginning + assert_eq!(state.current_field, CommitFormField::Type); +} + +#[test] +#[serial] +fn test_field_navigation_backward() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Start at Type, go backwards + assert_eq!(state.current_field, CommitFormField::Type); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::BreakingChange); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::LongMessage); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::ShortMessage); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::Scope); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::Type); +} + +#[test] +#[serial] +fn test_field_navigation_round_trip() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + let start_field = state.current_field; + + // Go forward 5 times (full cycle) + for _ in 0..5 { + state.next_field(); + } + + assert_eq!(state.current_field, start_field); + + // Go backward 5 times (full cycle) + for _ in 0..5 { + state.prev_field(); + } + + assert_eq!(state.current_field, start_field); +} + +#[test] +#[serial] +fn test_text_input_scope() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Navigate to Scope field + state.next_field(); + assert_eq!(state.current_field, CommitFormField::Scope); + + // Simulate typing + state.commit_scope.push_str("api"); + + assert_eq!(state.commit_scope, "api"); +} + +#[test] +#[serial] +fn test_text_input_short_message() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Navigate to ShortMessage field + state.next_field(); // Scope + state.next_field(); // ShortMessage + assert_eq!(state.current_field, CommitFormField::ShortMessage); + + // Simulate typing + state.commit_message.push_str("add user authentication"); + + assert_eq!(state.commit_message, "add user authentication"); +} + +#[test] +#[serial] +fn test_text_input_long_message() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Navigate to LongMessage field + for _ in 0..3 { + state.next_field(); + } + assert_eq!(state.current_field, CommitFormField::LongMessage); + + // Simulate typing with newlines + state.commit_body.push_str("This is a longer description.\n"); + state.commit_body.push_str("It can span multiple lines.\n"); + state.commit_body.push_str("Details about the implementation."); + + assert!(state.commit_body.contains("longer description")); + assert!(state.commit_body.contains("\n")); + assert!(state.commit_body.contains("implementation")); +} + +#[test] +#[serial] +fn test_breaking_change_toggle() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert!(!state.breaking_change); + + // Navigate to BreakingChange field + for _ in 0..4 { + state.next_field(); + } + assert_eq!(state.current_field, CommitFormField::BreakingChange); + + // Toggle it + state.breaking_change = !state.breaking_change; + assert!(state.breaking_change); + + // Toggle again + state.breaking_change = !state.breaking_change; + assert!(!state.breaking_change); +} + +#[test] +#[serial] +fn test_complete_commit_form_filling() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Set commit type + assert_eq!(state.current_field, CommitFormField::Type); + state.cycle_commit_type(); // Change to "fix" + assert_eq!(state.commit_type, "fix"); + + // Set scope + state.next_field(); + assert_eq!(state.current_field, CommitFormField::Scope); + state.commit_scope.push_str("auth"); + + // Set short message + state.next_field(); + assert_eq!(state.current_field, CommitFormField::ShortMessage); + state.commit_message.push_str("resolve token expiration bug"); + + // Set long message + state.next_field(); + assert_eq!(state.current_field, CommitFormField::LongMessage); + state.commit_body.push_str("Fixed an issue where tokens were not being refreshed properly.\n"); + state.commit_body.push_str("Added additional validation for token expiration."); + + // Enable breaking change + state.next_field(); + assert_eq!(state.current_field, CommitFormField::BreakingChange); + state.breaking_change = true; + + // Verify all fields + assert_eq!(state.commit_type, "fix"); + assert_eq!(state.commit_scope, "auth"); + assert_eq!(state.commit_message, "resolve token expiration bug"); + assert!(state.commit_body.contains("tokens were not being refreshed")); + assert!(state.breaking_change); +} + +#[test] +#[serial] +fn test_commit_form_with_empty_optional_fields() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Only set type and short message (minimum required) + assert_eq!(state.commit_type, "feat"); + + // Navigate to short message + state.next_field(); // Scope (skip) + state.next_field(); // ShortMessage + state.commit_message.push_str("add new feature"); + + // Verify optional fields are empty + assert_eq!(state.commit_scope, ""); + assert_eq!(state.commit_body, ""); + assert!(!state.breaking_change); + + // But required fields are filled + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_message, "add new feature"); +} + +#[test] +#[serial] +fn test_commit_message_backspace_simulation() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Navigate to short message + state.next_field(); + state.next_field(); + assert_eq!(state.current_field, CommitFormField::ShortMessage); + + // Type a message + state.commit_message.push_str("add feature"); + + assert_eq!(state.commit_message, "add feature"); + + // Simulate backspace (remove last char) + state.commit_message.pop(); + assert_eq!(state.commit_message, "add featur"); + + state.commit_message.pop(); + assert_eq!(state.commit_message, "add featu"); +} + +#[test] +#[serial] +fn test_scope_backspace_simulation() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Navigate to scope + state.next_field(); + assert_eq!(state.current_field, CommitFormField::Scope); + + // Type scope + state.commit_scope.push_str("api"); + assert_eq!(state.commit_scope, "api"); + + // Simulate backspace + state.commit_scope.pop(); + assert_eq!(state.commit_scope, "ap"); + + state.commit_scope.pop(); + state.commit_scope.pop(); + assert_eq!(state.commit_scope, ""); +} + +#[test] +#[serial] +fn test_multiple_commit_type_cycles() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Cycle many times + for _ in 0..50 { + state.cycle_commit_type(); + } + + // Should still be valid and consistent (14 types total) + assert_eq!(state.commit_type_index, 50 % 14); + + // Verify type matches index + let expected_types = vec![ + "feat", "fix", "build", "chore", "ci", "cd", "docs", + "perf", "refactor", "revert", "style", "test", "security", "config" + ]; + assert_eq!(state.commit_type, expected_types[50 % 14]); +} + +#[test] +#[serial] +fn test_commit_form_reset_simulation() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Fill out the form + state.cycle_commit_type(); + state.commit_scope.push_str("test"); + state.commit_message.push_str("test message"); + state.commit_body.push_str("test body"); + state.breaking_change = true; + + // Verify it's filled + assert_eq!(state.commit_type, "fix"); + assert_eq!(state.commit_scope, "test"); + assert_eq!(state.commit_message, "test message"); + assert_eq!(state.commit_body, "test body"); + assert!(state.breaking_change); + + // Simulate reset by creating new state + state = AppState::new().unwrap(); + + // Verify it's reset + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_scope, ""); + assert_eq!(state.commit_message, ""); + assert_eq!(state.commit_body, ""); + assert!(!state.breaking_change); +} \ No newline at end of file diff --git a/tests/tui_filtering_grouping_tests.rs b/tests/tui_filtering_grouping_tests.rs new file mode 100644 index 0000000..51054cb --- /dev/null +++ b/tests/tui_filtering_grouping_tests.rs @@ -0,0 +1,484 @@ +use committy::tui::state::{AppState, FileFilter}; +use git2::{Repository, Signature}; +use serial_test::serial; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Helper to create a test repository with initial commit +fn setup_test_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + // Create initial commit + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + { + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Initial commit", + &tree, + &[], + ) + .unwrap(); + } + + (dir, repo) +} + +/// Helper to create a file in the repo +fn create_file(_dir: &TempDir, path: &str, content: &str) { + let file_path = _dir.path().join(path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(file_path, content).unwrap(); +} + +#[test] +#[serial] +#[serial] +fn test_file_filter_initial_state() { + let (_dir, _repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + let state = AppState::new().unwrap(); + + assert_eq!(state.file_filter, FileFilter::All); +} + +#[test] +#[serial] +fn test_file_filter_cycling() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.file_filter, FileFilter::All); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::StagedOnly); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::UnstagedOnly); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::All); + + // Continue cycling + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::StagedOnly); +} + +#[test] +#[serial] +fn test_visible_files_with_all_filter() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create staged and unstaged files + create_file(&_dir,"staged1.txt", "staged"); + create_file(&_dir,"staged2.txt", "staged"); + create_file(&_dir,"unstaged1.txt", "unstaged"); + create_file(&_dir,"unstaged2.txt", "unstaged"); + + // Stage some files + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged1.txt")).unwrap(); + index.add_path(&PathBuf::from("staged2.txt")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.file_filter = FileFilter::All; + + let visible = state.visible_files(); + + // Should see all files + assert!(visible.len() >= 4); +} + +#[test] +#[serial] +fn test_visible_files_with_staged_filter() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create staged and unstaged files + create_file(&_dir,"staged.txt", "staged"); + create_file(&_dir,"unstaged.txt", "unstaged"); + + // Stage one file + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged.txt")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.file_filter = FileFilter::StagedOnly; + + let visible = state.visible_files(); + + // Should only see staged files + assert!(visible.iter().all(|f| f.staged)); + assert!(visible.len() >= 1); +} + +#[test] +#[serial] +fn test_visible_files_with_unstaged_filter() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create staged and unstaged files + create_file(&_dir,"staged.txt", "staged"); + create_file(&_dir,"unstaged.txt", "unstaged"); + + // Stage one file + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged.txt")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.file_filter = FileFilter::UnstagedOnly; + + let visible = state.visible_files(); + + // Should only see unstaged files + assert!(visible.iter().all(|f| !f.staged)); + assert!(visible.len() >= 1); +} + +#[test] +#[serial] +fn test_file_grouping_docs() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + create_file(&_dir,"README.md", "# Documentation"); + create_file(&_dir,"docs/guide.md", "# Guide"); + create_file(&_dir,"CHANGELOG.md", "# Changelog"); + + let state = AppState::new().unwrap(); + + for file in &state.files { + let path_str = file.path.to_str().unwrap(); + if path_str.contains("README") || path_str.contains("docs/") || path_str.ends_with(".md") { + assert_eq!(file.suggested_group, Some("docs".to_string())); + } + } +} + +#[test] +#[serial] +fn test_file_grouping_tests() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + create_file(&_dir,"tests/test_main.rs", "#[test] fn test() {}"); + create_file(&_dir,"src/lib_test.rs", "#[test] fn test() {}"); + create_file(&_dir,"spec/test_spec.rb", "describe 'test'"); + + let state = AppState::new().unwrap(); + + for file in &state.files { + let path_str = file.path.to_str().unwrap(); + if path_str.contains("test") || path_str.contains("spec") { + assert_eq!(file.suggested_group, Some("tests".to_string())); + } + } +} + +#[test] +#[serial] +fn test_file_grouping_ci() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + create_file(&_dir,".github/workflows/ci.yml", "name: CI"); + create_file(&_dir,".gitlab-ci.yml", "stages:"); + create_file(&_dir,"ci/build.sh", "#!/bin/bash"); + + let state = AppState::new().unwrap(); + + for file in &state.files { + let path_str = file.path.to_str().unwrap(); + if path_str.contains(".github") || path_str.contains(".gitlab") || path_str.contains("ci") { + assert_eq!(file.suggested_group, Some("ci".to_string())); + } + } +} + +#[test] +#[serial] +fn test_file_grouping_deps() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + create_file(&_dir,"Cargo.toml", "[package]"); + create_file(&_dir,"package.json", "{}"); + create_file(&_dir,"requirements.txt", "requests==2.28.0"); + + let state = AppState::new().unwrap(); + + for file in &state.files { + let path_str = file.path.to_str().unwrap(); + if path_str.contains("Cargo.toml") || path_str.contains("package.json") || path_str.contains("requirements.txt") { + assert_eq!(file.suggested_group, Some("deps".to_string())); + } + } +} + +#[test] +#[serial] +fn test_file_grouping_build() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + create_file(&_dir,"Makefile", "all:"); + create_file(&_dir,"build.rs", "fn main() {}"); + create_file(&_dir,"webpack.config.js", "module.exports = {}"); + + let state = AppState::new().unwrap(); + + for file in &state.files { + let path_str = file.path.to_str().unwrap(); + if path_str.contains("Makefile") || path_str.contains("build.rs") || path_str.contains("webpack") { + assert_eq!(file.suggested_group, Some("build".to_string())); + } + } +} + +#[test] +#[serial] +fn test_auto_grouping_creates_groups() { + let (_dir, repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create and stage various types of files + create_file(&_dir, "README.md", "docs"); + create_file(&_dir, "src/main.rs", "code"); + create_file(&_dir, "tests/test.rs", "tests"); + create_file(&_dir, "Cargo.toml", "deps"); + + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("README.md")).unwrap(); + index.add_path(&PathBuf::from("src/main.rs")).unwrap(); + index.add_path(&PathBuf::from("tests/test.rs")).unwrap(); + index.add_path(&PathBuf::from("Cargo.toml")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.groups.len(), 0); + + state.create_auto_groups(); + + // Should have created multiple groups + assert!(state.groups.len() > 0); + + // Check that each group has files + for group in &state.groups { + assert!(!group.files.is_empty()); + } +} + +#[test] +#[serial] +fn test_auto_grouping_assigns_correct_commit_types() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create and stage files + create_file(&_dir,"README.md", "docs"); + create_file(&_dir,"tests/test.rs", "tests"); + create_file(&_dir,".github/workflows/ci.yml", "ci"); + create_file(&_dir,"Cargo.toml", "deps"); + + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("README.md")).unwrap(); + index.add_path(&PathBuf::from("tests/test.rs")).unwrap(); + index.add_path(&PathBuf::from(".github/workflows/ci.yml")).unwrap(); + index.add_path(&PathBuf::from("Cargo.toml")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.create_auto_groups(); + + // Verify commit types match groups + for group in &state.groups { + match group.name.as_str() { + "docs" => assert_eq!(group.commit_type, "docs"), + "tests" => assert_eq!(group.commit_type, "test"), + "ci" => assert_eq!(group.commit_type, "ci"), + "deps" => assert_eq!(group.commit_type, "build"), + "build" => assert_eq!(group.commit_type, "build"), + _ => assert_eq!(group.commit_type, "feat"), // default + } + } +} + +#[test] +#[serial] +fn test_auto_grouping_only_staged_files() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create staged and unstaged files + create_file(&_dir,"staged.md", "staged docs"); + create_file(&_dir,"unstaged.md", "unstaged docs"); + + // Only stage one file + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged.md")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.create_auto_groups(); + + // Groups should only contain staged files + for group in &state.groups { + for file_path in &group.files { + let file = state.files.iter().find(|f| &f.path == file_path); + if let Some(f) = file { + assert!(f.staged, "Group should only contain staged files"); + } + } + } +} + +#[test] +#[serial] +fn test_auto_grouping_empty_when_no_staged_files() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create files but don't stage them + create_file(&_dir,"unstaged1.txt", "content"); + create_file(&_dir,"unstaged2.txt", "content"); + + let mut state = AppState::new().unwrap(); + state.create_auto_groups(); + + // Should have no groups since no files are staged + assert_eq!(state.groups.len(), 0); +} + +#[test] +#[serial] +fn test_filter_changes_visible_files() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create and stage some files + create_file(&_dir,"staged1.txt", "staged"); + create_file(&_dir,"staged2.txt", "staged"); + create_file(&_dir,"unstaged1.txt", "unstaged"); + create_file(&_dir,"unstaged2.txt", "unstaged"); + + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged1.txt")).unwrap(); + index.add_path(&PathBuf::from("staged2.txt")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + + // Test All filter + state.file_filter = FileFilter::All; + let all_count = state.visible_files().len(); + + // Test StagedOnly filter + state.file_filter = FileFilter::StagedOnly; + let staged_count = state.visible_files().len(); + + // Test UnstagedOnly filter + state.file_filter = FileFilter::UnstagedOnly; + let unstaged_count = state.visible_files().len(); + + // Verify counts make sense + assert!(all_count >= staged_count + unstaged_count); + assert!(staged_count > 0); + assert!(unstaged_count > 0); +} + +#[test] +#[serial] +fn test_grouping_with_mixed_file_types() { + let (_dir,repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + // Create a mix of file types + create_file(&_dir,"README.md", "docs"); + create_file(&_dir,"src/main.rs", "code"); + create_file(&_dir,"src/lib.rs", "code"); + create_file(&_dir,"tests/test1.rs", "test"); + create_file(&_dir,"tests/test2.rs", "test"); + create_file(&_dir,"Cargo.toml", "deps"); + create_file(&_dir,".github/workflows/ci.yml", "ci"); + + // Stage all + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("README.md")).unwrap(); + index.add_path(&PathBuf::from("src/main.rs")).unwrap(); + index.add_path(&PathBuf::from("src/lib.rs")).unwrap(); + index.add_path(&PathBuf::from("tests/test1.rs")).unwrap(); + index.add_path(&PathBuf::from("tests/test2.rs")).unwrap(); + index.add_path(&PathBuf::from("Cargo.toml")).unwrap(); + index.add_path(&PathBuf::from(".github/workflows/ci.yml")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + state.create_auto_groups(); + + // Should have multiple groups + assert!(state.groups.len() >= 4); // docs, tests, deps, ci at minimum + + // Verify each group has the right number of files + for group in &state.groups { + match group.name.as_str() { + "tests" => assert!(group.files.len() >= 2), + _ => {} + } + } +} + +#[test] +#[serial] +fn test_folder_collapse_toggle() { + let (_dir,_repo) = setup_test_repo(); + std::env::set_current_dir(_dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + let folder1 = PathBuf::from("src"); + let folder2 = PathBuf::from("tests"); + + // Initially no folders collapsed + assert!(!state.collapsed_folders.contains(&folder1)); + assert!(!state.collapsed_folders.contains(&folder2)); + + // Collapse folder1 + state.toggle_folder(folder1.clone()); + assert!(state.collapsed_folders.contains(&folder1)); + assert!(!state.collapsed_folders.contains(&folder2)); + + // Collapse folder2 + state.toggle_folder(folder2.clone()); + assert!(state.collapsed_folders.contains(&folder1)); + assert!(state.collapsed_folders.contains(&folder2)); + + // Expand folder1 + state.toggle_folder(folder1.clone()); + assert!(!state.collapsed_folders.contains(&folder1)); + assert!(state.collapsed_folders.contains(&folder2)); + + // Expand folder2 + state.toggle_folder(folder2.clone()); + assert!(!state.collapsed_folders.contains(&folder1)); + assert!(!state.collapsed_folders.contains(&folder2)); +} \ No newline at end of file diff --git a/tests/tui_staging_tests.rs b/tests/tui_staging_tests.rs new file mode 100644 index 0000000..2ed6ccb --- /dev/null +++ b/tests/tui_staging_tests.rs @@ -0,0 +1,373 @@ +use serial_test::serial; + +use committy::tui::state::AppState; +use git2::{Repository, Signature}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Helper to create a test repository with initial commit +fn setup_test_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + // Create initial commit + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + { + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Initial commit", + &tree, + &[], + ) + .unwrap(); + } + + (dir, repo) +} + +/// Helper to create a file in the repo +fn create_file(dir: &TempDir, path: &str, content: &str) { + let file_path = dir.path().join(path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(file_path, content).unwrap(); +} + +#[test] +#[serial] +fn test_stage_selected_files() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create test files + create_file(&dir, "file1.txt", "content1"); + create_file(&dir, "file2.txt", "content2"); + + let mut state = AppState::new().unwrap(); + + // Initially files should be unstaged + assert!(state.files.iter().all(|f| !f.staged)); + + // Select first file + state.selected_index = 0; + state.toggle_selected(); + + // Stage the selected file + state.stage_selected().unwrap(); + + // Verify file is staged in git + let statuses = repo.statuses(None).unwrap(); + let mut has_staged = false; + for entry in statuses.iter() { + if entry.index_to_workdir().is_none() && entry.head_to_index().is_some() { + has_staged = true; + break; + } + } + assert!(has_staged, "Should have at least one staged file"); + + // Reload state and verify + state = AppState::new().unwrap(); + assert!(state.files.iter().any(|f| f.staged)); +} + +#[test] +#[serial] +fn test_stage_multiple_files() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create test files + create_file(&dir, "file1.txt", "content1"); + create_file(&dir, "file2.txt", "content2"); + create_file(&dir, "file3.txt", "content3"); + + let mut state = AppState::new().unwrap(); + + // Select all files + for file in &mut state.files { + file.selected = true; + } + + // Stage all selected files + state.stage_selected().unwrap(); + + // Verify all files are staged + let statuses = repo.statuses(None).unwrap(); + let staged_count = statuses.iter().filter(|e| { + e.index_to_workdir().is_none() && e.head_to_index().is_some() + }).count(); + + assert!(staged_count >= 3, "Should have at least 3 staged files"); +} + +#[test] +#[serial] +fn test_unstage_newly_added_file() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and stage a new file + create_file(&dir, "newfile.txt", "new content"); + + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("newfile.txt")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + + // Find and select the staged file + let file_index = state.files.iter() + .position(|f| f.path.to_str().unwrap().contains("newfile.txt") && f.staged) + .expect("Should find staged file"); + + state.selected_index = file_index; + state.toggle_selected(); + + // Unstage it + state.unstage_selected().unwrap(); + + // Verify file is no longer staged + let statuses = repo.statuses(None).unwrap(); + for entry in statuses.iter() { + if let Some(path) = entry.path() { + if path.contains("newfile.txt") { + // Should be untracked or in workdir, not in index + assert!(entry.head_to_index().is_none() || entry.status().is_wt_new()); + } + } + } +} + +#[test] +#[serial] +fn test_unstage_modified_file() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and commit a file first + create_file(&dir, "existing.txt", "original content"); + { + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("existing.txt")).unwrap(); + index.write().unwrap(); + + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Add existing file", + &tree, + &[&parent], + ) + .unwrap(); + } + + // Modify and stage the file + create_file(&dir, "existing.txt", "modified content"); + { + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("existing.txt")).unwrap(); + index.write().unwrap(); + } + + let mut state = AppState::new().unwrap(); + + // Find and select the staged modified file + let file_index = state.files.iter() + .position(|f| f.path.to_str().unwrap().contains("existing.txt") && f.staged) + .expect("Should find staged file"); + + state.selected_index = file_index; + state.toggle_selected(); + + // Unstage it + state.unstage_selected().unwrap(); + + // Verify file is unstaged (should be in workdir but not in index) + let statuses = repo.statuses(None).unwrap(); + for entry in statuses.iter() { + if let Some(path) = entry.path() { + if path.contains("existing.txt") { + // Should be modified in workdir, not staged + assert!(entry.status().is_wt_modified() || entry.status().is_index_modified()); + } + } + } +} + +#[test] +#[serial] +fn test_stage_deleted_file() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and commit a file + create_file(&dir, "to_delete.txt", "content"); + { + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("to_delete.txt")).unwrap(); + index.write().unwrap(); + + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = index.write_tree().unwrap(); + let tree = repo.find_tree(tree_id).unwrap(); + let parent = repo.head().unwrap().peel_to_commit().unwrap(); + + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Add file to delete", + &tree, + &[&parent], + ) + .unwrap(); + } + + // Delete the file + fs::remove_file(dir.path().join("to_delete.txt")).unwrap(); + + let mut state = AppState::new().unwrap(); + + // Find and select the deleted file + let file_index = state.files.iter() + .position(|f| f.path.to_str().unwrap().contains("to_delete.txt")) + .expect("Should find deleted file"); + + state.selected_index = file_index; + state.toggle_selected(); + + // Stage the deletion + state.stage_selected().unwrap(); + + // Verify deletion is staged + let statuses = repo.statuses(None).unwrap(); + let mut found_deleted = false; + for entry in statuses.iter() { + if let Some(path) = entry.path() { + if path.contains("to_delete.txt") { + assert!(entry.status().is_index_deleted()); + found_deleted = true; + } + } + } + assert!(found_deleted, "Deletion should be staged"); +} + +#[test] +#[serial] +fn test_stage_files_in_subdirectories() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create files in subdirectories + create_file(&dir, "src/main.rs", "fn main() {}"); + create_file(&dir, "src/lib.rs", "pub fn lib() {}"); + create_file(&dir, "tests/test.rs", "#[test] fn test() {}"); + + let mut state = AppState::new().unwrap(); + + // Select all files + for file in &mut state.files { + file.selected = true; + } + + // Stage all + let result = state.stage_selected(); + assert!(result.is_ok(), "Should stage files in subdirectories without error"); + + // Reload and verify + state = AppState::new().unwrap(); + assert!(state.files.iter().filter(|f| f.staged).count() >= 3); +} + +#[test] +#[serial] +fn test_unstage_multiple_files() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and stage multiple files + create_file(&dir, "file1.txt", "content1"); + create_file(&dir, "file2.txt", "content2"); + create_file(&dir, "file3.txt", "content3"); + + { + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("file1.txt")).unwrap(); + index.add_path(&PathBuf::from("file2.txt")).unwrap(); + index.add_path(&PathBuf::from("file3.txt")).unwrap(); + index.write().unwrap(); + } + + let mut state = AppState::new().unwrap(); + + // Select all staged files + for file in &mut state.files { + if file.staged { + file.selected = true; + } + } + + // Unstage all + state.unstage_selected().unwrap(); + + // Verify no files are staged + state = AppState::new().unwrap(); + assert!(state.files.iter().filter(|f| f.staged).count() == 0); +} + +#[test] +#[serial] +fn test_stage_then_unstage_cycle() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create a file + create_file(&dir, "cycle.txt", "content"); + + let mut state = AppState::new().unwrap(); + + // Stage it + state.files[0].selected = true; + state.stage_selected().unwrap(); + + // Reload and verify staged + state = AppState::new().unwrap(); + assert!(state.files.iter().any(|f| f.staged)); + + // Unstage it + for file in &mut state.files { + if file.staged { + file.selected = true; + } + } + state.unstage_selected().unwrap(); + + // Reload and verify unstaged + state = AppState::new().unwrap(); + assert!(state.files.iter().filter(|f| f.staged).count() == 0); + + // Stage again + state.files[0].selected = true; + state.stage_selected().unwrap(); + + // Verify staged again + state = AppState::new().unwrap(); + assert!(state.files.iter().any(|f| f.staged)); +} \ No newline at end of file diff --git a/tests/tui_state_tests.rs b/tests/tui_state_tests.rs new file mode 100644 index 0000000..ffca003 --- /dev/null +++ b/tests/tui_state_tests.rs @@ -0,0 +1,384 @@ +use serial_test::serial; + +use committy::tui::state::{AppState, AppMode, CommitFormField, FileFilter}; +use git2::{Repository, Signature}; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +/// Helper to create a test repository with files +fn setup_test_repo() -> (TempDir, Repository) { + let dir = TempDir::new().unwrap(); + let repo = Repository::init(dir.path()).unwrap(); + + // Create initial commit + let sig = Signature::now("Test User", "test@example.com").unwrap(); + let tree_id = { + let mut index = repo.index().unwrap(); + index.write_tree().unwrap() + }; + { + let tree = repo.find_tree(tree_id).unwrap(); + repo.commit( + Some("HEAD"), + &sig, + &sig, + "Initial commit", + &tree, + &[], + ) + .unwrap(); + } + + (dir, repo) +} + +/// Helper to create a file in the repo +fn create_file(dir: &TempDir, path: &str, content: &str) { + let file_path = dir.path().join(path); + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(file_path, content).unwrap(); +} + +#[test] +#[serial] +fn test_app_state_initialization() { + let (_dir, _repo) = setup_test_repo(); + + // Initialize in the test repo directory + std::env::set_current_dir(_dir.path()).unwrap(); + + let state = AppState::new().unwrap(); + + assert_eq!(state.mode, AppMode::FileSelection); + assert_eq!(state.selected_index, 0); + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_scope, ""); + assert_eq!(state.commit_message, ""); + assert_eq!(state.breaking_change, false); + assert_eq!(state.current_field, CommitFormField::Type); + assert_eq!(state.file_filter, FileFilter::All); +} + +#[test] +#[serial] +fn test_file_selection_navigation() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create some test files + create_file(&dir, "file1.txt", "content1"); + create_file(&dir, "file2.txt", "content2"); + create_file(&dir, "file3.txt", "content3"); + + let mut state = AppState::new().unwrap(); + + // Initially at index 0 + assert_eq!(state.selected_index, 0); + + // Move down + state.move_selection_down(); + assert_eq!(state.selected_index, 1); + + state.move_selection_down(); + assert_eq!(state.selected_index, 2); + + // Move up + state.move_selection_up(); + assert_eq!(state.selected_index, 1); + + state.move_selection_up(); + assert_eq!(state.selected_index, 0); + + // Can't go below 0 + state.move_selection_up(); + assert_eq!(state.selected_index, 0); +} + +#[test] +#[serial] +fn test_file_selection_toggle() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + create_file(&dir, "test.txt", "content"); + + let mut state = AppState::new().unwrap(); + + if let Some(file) = state.files.get(0) { + assert!(!file.selected); + } + + // Toggle selection + state.toggle_selected(); + + if let Some(file) = state.files.get(0) { + assert!(file.selected); + } + + // Toggle again + state.toggle_selected(); + + if let Some(file) = state.files.get(0) { + assert!(!file.selected); + } +} + +#[test] +#[serial] +fn test_commit_type_cycling() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.commit_type, "feat"); + assert_eq!(state.commit_type_index, 0); + + state.cycle_commit_type(); + assert_eq!(state.commit_type, "fix"); + assert_eq!(state.commit_type_index, 1); + + state.cycle_commit_type(); + assert_eq!(state.commit_type, "build"); + assert_eq!(state.commit_type_index, 2); + + // Cycle once more to verify it continues working + state.cycle_commit_type(); + assert_eq!(state.commit_type, "chore"); + assert_eq!(state.commit_type_index, 3); +} + +#[test] +#[serial] +fn test_commit_form_field_navigation() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.current_field, CommitFormField::Type); + + // Navigate forward with Tab + state.next_field(); + assert_eq!(state.current_field, CommitFormField::Scope); + + state.next_field(); + assert_eq!(state.current_field, CommitFormField::ShortMessage); + + state.next_field(); + assert_eq!(state.current_field, CommitFormField::LongMessage); + + state.next_field(); + assert_eq!(state.current_field, CommitFormField::BreakingChange); + + // Should wrap to beginning + state.next_field(); + assert_eq!(state.current_field, CommitFormField::Type); + + // Navigate backward with Shift+Tab + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::BreakingChange); + + state.prev_field(); + assert_eq!(state.current_field, CommitFormField::LongMessage); +} + +#[test] +#[serial] +fn test_file_filter_cycling() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.file_filter, FileFilter::All); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::StagedOnly); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::UnstagedOnly); + + state.cycle_filter(); + assert_eq!(state.file_filter, FileFilter::All); +} + +#[test] +#[serial] +fn test_has_staged_files() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + create_file(&dir, "test.txt", "content"); + + let mut state = AppState::new().unwrap(); + + // Initially no staged files + assert!(!state.has_staged_files()); + + // Stage a file manually + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("test.txt")).unwrap(); + index.write().unwrap(); + + // Reload state + state = AppState::new().unwrap(); + + // Now should have staged files + assert!(state.has_staged_files()); +} + +#[test] +#[serial] +fn test_visible_files_with_filter() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and stage one file + create_file(&dir, "staged.txt", "staged content"); + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("staged.txt")).unwrap(); + index.write().unwrap(); + + // Create unstaged file + create_file(&dir, "unstaged.txt", "unstaged content"); + + let mut state = AppState::new().unwrap(); + + // All files visible with All filter + state.file_filter = FileFilter::All; + let visible = state.visible_files(); + assert!(visible.len() >= 2); + + // Only staged files visible + state.file_filter = FileFilter::StagedOnly; + let visible = state.visible_files(); + assert!(visible.iter().all(|f| f.staged)); + + // Only unstaged files visible + state.file_filter = FileFilter::UnstagedOnly; + let visible = state.visible_files(); + assert!(visible.iter().all(|f| !f.staged)); +} + +#[test] +#[serial] +fn test_folder_toggle() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + let folder = PathBuf::from("src"); + + assert!(!state.collapsed_folders.contains(&folder)); + + state.toggle_folder(folder.clone()); + assert!(state.collapsed_folders.contains(&folder)); + + state.toggle_folder(folder.clone()); + assert!(!state.collapsed_folders.contains(&folder)); +} + +#[test] +#[serial] +fn test_file_group_suggestions() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create files of different types + create_file(&dir, "README.md", "# Documentation"); + create_file(&dir, "src/lib.rs", "// Code"); + create_file(&dir, "tests/test.rs", "// Test"); + create_file(&dir, "Cargo.toml", "[package]"); + create_file(&dir, ".github/workflows/ci.yml", "# CI"); + + let state = AppState::new().unwrap(); + + // Check that files are grouped correctly + for file in &state.files { + match file.path.to_str().unwrap() { + path if path.contains("README") => { + assert_eq!(file.suggested_group, Some("docs".to_string())); + } + path if path.contains("test") => { + assert_eq!(file.suggested_group, Some("tests".to_string())); + } + path if path.contains("Cargo.toml") => { + assert_eq!(file.suggested_group, Some("deps".to_string())); + } + path if path.contains(".github") => { + assert_eq!(file.suggested_group, Some("ci".to_string())); + } + _ => {} + } + } +} + +#[test] +#[serial] +fn test_auto_grouping() { + let (dir, repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + // Create and stage various files + create_file(&dir, "README.md", "docs"); + create_file(&dir, "src/main.rs", "code"); + create_file(&dir, "tests/test.rs", "test"); + + let mut index = repo.index().unwrap(); + index.add_path(&PathBuf::from("README.md")).unwrap(); + index.add_path(&PathBuf::from("src/main.rs")).unwrap(); + index.add_path(&PathBuf::from("tests/test.rs")).unwrap(); + index.write().unwrap(); + + let mut state = AppState::new().unwrap(); + + // Create auto groups + state.create_auto_groups(); + + // Should have created groups for docs, code, and tests + assert!(!state.groups.is_empty()); + + // Check that groups exist + let group_names: Vec<&str> = state.groups.iter().map(|g| g.name.as_str()).collect(); + assert!(group_names.contains(&"docs")); + assert!(group_names.contains(&"tests")); + + // Check commit types are appropriate + for group in &state.groups { + match group.name.as_str() { + "docs" => assert_eq!(group.commit_type, "docs"), + "tests" => assert_eq!(group.commit_type, "test"), + _ => {} + } + } +} + +#[test] +#[serial] +fn test_mode_transitions() { + let (dir, _repo) = setup_test_repo(); + std::env::set_current_dir(dir.path()).unwrap(); + + let mut state = AppState::new().unwrap(); + + assert_eq!(state.mode, AppMode::FileSelection); + + state.mode = AppMode::CommitMessage; + assert_eq!(state.mode, AppMode::CommitMessage); + + state.mode = AppMode::GroupView; + assert_eq!(state.mode, AppMode::GroupView); + + state.mode = AppMode::DiffView; + assert_eq!(state.mode, AppMode::DiffView); + + state.mode = AppMode::Help; + assert_eq!(state.mode, AppMode::Help); + + state.mode = AppMode::FileSelection; + assert_eq!(state.mode, AppMode::FileSelection); +} \ No newline at end of file