LCOV - code coverage report
Current view: top level - src - app.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 57.4 % 1029 591
Test Date: 2026-02-25 04:31:59 Functions: 86.6 % 67 58

            Line data    Source code
       1              : use anyhow::Result;
       2              : use crossterm::{
       3              :     event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
       4              :     execute,
       5              :     terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
       6              : };
       7              : use ratatui::{
       8              :     backend::{Backend, CrosstermBackend},
       9              :     Terminal,
      10              : };
      11              : use std::collections::HashMap;
      12              : use std::io::{self};
      13              : use std::time::{Duration, Instant};
      14              : use tokio::sync::mpsc;
      15              : 
      16              : use crate::diff::{CommitInfo, DiffSnapshot, FileChange};
      17              : use crate::git::GitRepo;
      18              : use crate::ui::UI;
      19              : use crate::watcher::FileWatcher;
      20              : 
      21              : // Debug logging helper
      22           41 : fn debug_log(msg: String) {
      23           41 :     crate::logger::debug(msg);
      24           41 : }
      25              : 
      26              : #[derive(Debug, Clone, Copy, PartialEq)]
      27              : pub enum StreamSpeed {
      28              :     Fast,   // 1x multiplier: 0.3s base + 0.2s per change
      29              :     Medium, // 2x multiplier: 0.5s base + 0.5s per change
      30              :     Slow,   // 3x multiplier: 0.5s base + 1.0s per change
      31              : }
      32              : 
      33              : #[derive(Debug, Clone, Copy, PartialEq)]
      34              : pub enum StreamingType {
      35              :     Auto(StreamSpeed), // Automatically advance with timing based on speed
      36              :     Buffered,          // Manual advance with Space
      37              : }
      38              : 
      39              : #[derive(Debug, Clone, Copy, PartialEq)]
      40              : pub enum Mode {
      41              :     View,                     // View all current changes, full navigation
      42              :     Streaming(StreamingType), // Stream new hunks as they arrive
      43              :     Review,                   // Review hunks in a specific commit
      44              : }
      45              : 
      46              : #[derive(Debug, Clone, Copy, PartialEq)]
      47              : pub enum FocusPane {
      48              :     FileList,
      49              :     HunkView,
      50              :     HelpSidebar,
      51              : }
      52              : 
      53              : impl StreamSpeed {
      54            3 :     pub fn duration_for_hunk(&self, change_count: usize) -> Duration {
      55            3 :         let (base_ms, per_change_ms) = match self {
      56            1 :             StreamSpeed::Fast => (300, 200),   // 0.3s base + 0.2s per change
      57            1 :             StreamSpeed::Medium => (500, 500), // 0.5s base + 0.5s per change
      58            1 :             StreamSpeed::Slow => (500, 1000),  // 0.5s base + 1.0s per change
      59              :         };
      60            3 :         let total_ms = base_ms + (per_change_ms * change_count as u64);
      61            3 :         Duration::from_millis(total_ms)
      62            3 :     }
      63              : }
      64              : 
      65              : pub struct App {
      66              :     git_repo: GitRepo,
      67              :     snapshots: Vec<DiffSnapshot>,
      68              :     current_snapshot_index: usize,
      69              :     current_file_index: usize,
      70              :     current_hunk_index: usize,
      71              :     mode: Mode,
      72              :     show_filenames_only: bool,
      73              :     wrap_lines: bool,
      74              :     show_help: bool,
      75              :     syntax_highlighting: bool,
      76              :     focus: FocusPane,
      77              :     line_selection_mode: bool,
      78              :     selected_line_index: usize,
      79              :     // Track last selected line per hunk (file_index, hunk_index) -> line_index
      80              :     hunk_line_memory: HashMap<(usize, usize), usize>,
      81              :     snapshot_receiver: mpsc::UnboundedReceiver<DiffSnapshot>,
      82              :     last_auto_advance: Instant,
      83              :     scroll_offset: u16,
      84              :     help_scroll_offset: u16,
      85              :     // Snapshot index when we entered Streaming mode (everything before is "seen")
      86              :     streaming_start_snapshot: Option<usize>,
      87              :     show_extended_help: bool,
      88              :     extended_help_scroll_offset: u16,
      89              :     // Cached viewport heights to prevent scroll flashing
      90              :     last_diff_viewport_height: u16,
      91              :     last_help_viewport_height: u16,
      92              :     needs_full_redraw: bool,
      93              :     _watcher: FileWatcher,
      94              :     // Review mode state
      95              :     review_commits: Vec<CommitInfo>,
      96              :     review_commit_cursor: usize,
      97              :     review_selecting_commit: bool,
      98              :     review_snapshot: Option<DiffSnapshot>,
      99              : }
     100              : 
     101              : impl App {
     102           26 :     pub async fn new(repo_path: &str) -> Result<Self> {
     103           26 :         let git_repo = GitRepo::new(repo_path)?;
     104              : 
     105              :         // Get initial snapshot
     106           26 :         let mut initial_snapshot = git_repo.get_diff_snapshot()?;
     107              : 
     108              :         // Detect staged lines for initial snapshot
     109           26 :         for file in &mut initial_snapshot.files {
     110            6 :             for hunk in &mut file.hunks {
     111              :                 // Detect which lines are actually staged in git's index
     112            6 :                 if let Ok(staged_indices) = git_repo.detect_staged_lines(hunk, &file.path) {
     113            6 :                     hunk.staged_line_indices = staged_indices;
     114              : 
     115              :                     // Check if all change lines are staged
     116            6 :                     let total_change_lines = hunk
     117            6 :                         .lines
     118            6 :                         .iter()
     119           21 :                         .filter(|line| {
     120           21 :                             (line.starts_with('+') && !line.starts_with("+++"))
     121           13 :                                 || (line.starts_with('-') && !line.starts_with("---"))
     122           21 :                         })
     123            6 :                         .count();
     124              : 
     125            6 :                     hunk.staged = hunk.staged_line_indices.len() == total_change_lines
     126            0 :                         && total_change_lines > 0;
     127            0 :                 }
     128              :             }
     129              :         }
     130              : 
     131              :         // Set up file watcher
     132           26 :         let (tx, rx) = mpsc::unbounded_channel();
     133           26 :         let watcher = FileWatcher::new(git_repo.clone(), tx)?;
     134              : 
     135           26 :         let app = Self {
     136           26 :             git_repo,
     137           26 :             snapshots: vec![initial_snapshot],
     138           26 :             current_snapshot_index: 0,
     139           26 :             current_file_index: 0,
     140           26 :             current_hunk_index: 0,
     141           26 :             mode: Mode::View, // Start in View mode
     142           26 :             show_filenames_only: false,
     143           26 :             wrap_lines: false,
     144           26 :             show_help: false,
     145           26 :             syntax_highlighting: true, // Enabled by default
     146           26 :             focus: FocusPane::HunkView,
     147           26 :             line_selection_mode: false,
     148           26 :             selected_line_index: 0,
     149           26 :             hunk_line_memory: HashMap::new(),
     150           26 :             snapshot_receiver: rx,
     151           26 :             last_auto_advance: Instant::now(),
     152           26 :             scroll_offset: 0,
     153           26 :             help_scroll_offset: 0,
     154           26 :             streaming_start_snapshot: None, // Not in streaming mode initially
     155           26 :             show_extended_help: false,
     156           26 :             extended_help_scroll_offset: 0,
     157           26 :             last_diff_viewport_height: 20, // Reasonable default
     158           26 :             last_help_viewport_height: 20, // Reasonable default
     159           26 :             needs_full_redraw: true,
     160           26 :             _watcher: watcher,
     161           26 :             review_commits: Vec::new(),
     162           26 :             review_commit_cursor: 0,
     163           26 :             review_selecting_commit: false,
     164           26 :             review_snapshot: None,
     165           26 :         };
     166              : 
     167           26 :         Ok(app)
     168           26 :     }
     169              : 
     170            0 :     pub async fn run(&mut self) -> Result<()> {
     171              :         // Setup terminal
     172            0 :         enable_raw_mode()?;
     173            0 :         let mut stdout = io::stdout();
     174            0 :         execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
     175            0 :         let backend = CrosstermBackend::new(stdout);
     176            0 :         let mut terminal = Terminal::new(backend)?;
     177              : 
     178            0 :         let result = self.run_loop(&mut terminal).await;
     179              : 
     180              :         // Restore terminal
     181            0 :         disable_raw_mode()?;
     182            0 :         execute!(
     183            0 :             terminal.backend_mut(),
     184              :             LeaveAlternateScreen,
     185              :             DisableMouseCapture
     186            0 :         )?;
     187            0 :         terminal.show_cursor()?;
     188              : 
     189            0 :         result
     190            0 :     }
     191              : 
     192            0 :     async fn run_loop<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<()> {
     193              :         loop {
     194              :             // Check for new snapshots
     195            0 :             while let Ok(mut snapshot) = self.snapshot_receiver.try_recv() {
     196            0 :                 debug_log(format!(
     197              :                     "Received snapshot with {} files",
     198            0 :                     snapshot.files.len()
     199              :                 ));
     200              : 
     201              :                 // Detect staged lines for all hunks
     202            0 :                 for file in &mut snapshot.files {
     203            0 :                     for hunk in &mut file.hunks {
     204              :                         // Detect which lines are actually staged in git's index
     205            0 :                         match self.git_repo.detect_staged_lines(hunk, &file.path) {
     206            0 :                             Ok(staged_indices) => {
     207            0 :                                 hunk.staged_line_indices = staged_indices;
     208              : 
     209              :                                 // Check if all change lines are staged
     210            0 :                                 let total_change_lines = hunk
     211            0 :                                     .lines
     212            0 :                                     .iter()
     213            0 :                                     .filter(|line| {
     214            0 :                                         (line.starts_with('+') && !line.starts_with("+++"))
     215            0 :                                             || (line.starts_with('-') && !line.starts_with("---"))
     216            0 :                                     })
     217            0 :                                     .count();
     218              : 
     219            0 :                                 hunk.staged = hunk.staged_line_indices.len() == total_change_lines
     220            0 :                                     && total_change_lines > 0;
     221              : 
     222            0 :                                 if !hunk.staged_line_indices.is_empty() {
     223            0 :                                     debug_log(format!("Detected {} staged lines in hunk (total: {}, fully staged: {})", 
     224            0 :                                         hunk.staged_line_indices.len(), total_change_lines, hunk.staged));
     225            0 :                                 }
     226              :                             }
     227            0 :                             Err(e) => {
     228            0 :                                 debug_log(format!("Failed to detect staged lines: {}", e));
     229            0 :                             }
     230              :                         }
     231              :                     }
     232              :                 }
     233              : 
     234            0 :                 match self.mode {
     235              :                     Mode::View => {
     236              :                         // In View mode, update the current snapshot with new staged line info
     237              :                         // Replace the current snapshot entirely with the new one
     238            0 :                         if !self.snapshots.is_empty() {
     239            0 :                             self.snapshots[self.current_snapshot_index] = snapshot;
     240            0 :                             debug_log("Updated current snapshot in View mode".to_string());
     241            0 :                         }
     242              :                     }
     243            0 :                     Mode::Review => {
     244            0 :                         // In Review mode, ignore live snapshot updates (reviewing a commit)
     245            0 :                         debug_log("Ignoring snapshot update in Review mode".to_string());
     246            0 :                     }
     247              :                     Mode::Streaming(_) => {
     248              :                         // In Streaming mode, only add snapshots that arrived after we entered streaming
     249              :                         // These are "new" changes to stream
     250            0 :                         self.snapshots.push(snapshot);
     251            0 :                         debug_log(format!(
     252              :                             "Added new snapshot in Streaming mode. Total snapshots: {}",
     253            0 :                             self.snapshots.len()
     254              :                         ));
     255              : 
     256              :                         // If we're on an empty/old snapshot, advance to the new one
     257            0 :                         if let Some(start_idx) = self.streaming_start_snapshot {
     258            0 :                             if self.current_snapshot_index <= start_idx {
     259            0 :                                 self.current_snapshot_index = self.snapshots.len() - 1;
     260            0 :                                 self.current_file_index = 0;
     261            0 :                                 self.current_hunk_index = 0;
     262            0 :                                 debug_log("Advanced to new snapshot in Streaming mode".to_string());
     263            0 :                             }
     264            0 :                         }
     265              :                     }
     266              :                 }
     267              :             }
     268              : 
     269              :             // Auto-advance in Streaming Auto mode
     270            0 :             if let Mode::Streaming(StreamingType::Auto(speed)) = self.mode {
     271            0 :                 let elapsed = self.last_auto_advance.elapsed();
     272              :                 // Get current hunk change count (not including context lines) for duration calculation
     273            0 :                 let change_count = self
     274            0 :                     .current_file()
     275            0 :                     .and_then(|f| f.hunks.get(self.current_hunk_index))
     276            0 :                     .map(|h| h.count_changes())
     277            0 :                     .unwrap_or(1); // Default to 1 change if no hunk
     278            0 :                 if elapsed >= speed.duration_for_hunk(change_count) {
     279            0 :                     self.advance_hunk();
     280            0 :                     self.last_auto_advance = Instant::now();
     281            0 :                 }
     282            0 :             }
     283              : 
     284              :             // Draw UI
     285            0 :             if self.needs_full_redraw {
     286            0 :                 terminal.clear()?;
     287            0 :                 self.needs_full_redraw = false;
     288            0 :             }
     289              : 
     290            0 :             let mut diff_viewport_height = 0;
     291            0 :             let mut help_viewport_height = 0;
     292            0 :             terminal.draw(|f| {
     293            0 :                 let ui = UI::new(self);
     294            0 :                 let (diff_h, help_h, _file_list_h) = ui.draw(f);
     295            0 :                 diff_viewport_height = diff_h;
     296            0 :                 help_viewport_height = help_h;
     297            0 :             })?;
     298              : 
     299              :             // Cache viewport heights for next frame's pre-clamping
     300            0 :             self.last_diff_viewport_height = diff_viewport_height;
     301            0 :             self.last_help_viewport_height = help_viewport_height;
     302              : 
     303              :             // Clamp scroll offsets after drawing (still needed for content size changes)
     304            0 :             self.clamp_scroll_offset(diff_viewport_height);
     305            0 :             if self.show_help {
     306            0 :                 self.clamp_help_scroll_offset(help_viewport_height);
     307            0 :             }
     308            0 :             if self.show_extended_help {
     309            0 :                 self.clamp_extended_help_scroll_offset(help_viewport_height);
     310            0 :             }
     311              : 
     312              :             // Handle input (non-blocking)
     313            0 :             if event::poll(Duration::from_millis(50))? {
     314            0 :                 if let Event::Key(key) = event::read()? {
     315              :                     // If the commit picker overlay is active, handle its keys first
     316            0 :                     if self.review_selecting_commit {
     317            0 :                         match key.code {
     318            0 :                             KeyCode::Char('q') | KeyCode::Char('Q') => break,
     319            0 :                             KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
     320            0 :                                 break
     321              :                             }
     322              :                             KeyCode::Char('j') | KeyCode::Down => {
     323            0 :                                 if !self.review_commits.is_empty()
     324            0 :                                     && self.review_commit_cursor + 1 < self.review_commits.len()
     325            0 :                                 {
     326            0 :                                     self.review_commit_cursor += 1;
     327            0 :                                 }
     328              :                             }
     329            0 :                             KeyCode::Char('k') | KeyCode::Up => {
     330            0 :                                 self.review_commit_cursor =
     331            0 :                                     self.review_commit_cursor.saturating_sub(1);
     332            0 :                             }
     333            0 :                             KeyCode::Enter => {
     334            0 :                                 self.select_review_commit();
     335            0 :                             }
     336            0 :                             KeyCode::Esc => {
     337            0 :                                 // Cancel commit selection, go back to View mode
     338            0 :                                 self.review_selecting_commit = false;
     339            0 :                                 self.review_commits.clear();
     340            0 :                                 self.mode = Mode::View;
     341            0 :                                 debug_log("Cancelled review commit selection".to_string());
     342            0 :                             }
     343            0 :                             _ => {}
     344              :                         }
     345            0 :                         continue;
     346            0 :                     }
     347              : 
     348            0 :                     match key.code {
     349            0 :                         KeyCode::Char('q') | KeyCode::Char('Q') => break,
     350            0 :                         KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
     351            0 :                             break
     352              :                         }
     353            0 :                         KeyCode::Char(' ') if key.modifiers.contains(KeyModifiers::SHIFT) => {
     354              :                             // Shift+Space goes to previous hunk (works in View, Streaming Buffered, and Review)
     355            0 :                             debug_log(format!("Shift+Space pressed, mode: {:?}", self.mode));
     356            0 :                             match self.mode {
     357            0 :                                 Mode::View | Mode::Review => {
     358            0 :                                     self.previous_hunk();
     359            0 :                                 }
     360            0 :                                 Mode::Streaming(StreamingType::Buffered) => {
     361            0 :                                     // In buffered mode, allow going back if there's a previous hunk
     362            0 :                                     self.previous_hunk();
     363            0 :                                 }
     364            0 :                                 Mode::Streaming(StreamingType::Auto(_)) => {
     365            0 :                                     // Auto mode doesn't support going back
     366            0 :                                     debug_log("Auto mode - ignoring Shift+Space".to_string());
     367            0 :                                 }
     368              :                             }
     369              :                         }
     370              :                         KeyCode::Char('r') | KeyCode::Char('R') => {
     371            0 :                             if self.mode != Mode::Review {
     372            0 :                                 self.enter_review_mode();
     373            0 :                             }
     374              :                         }
     375              :                         KeyCode::Char('c') => {
     376            0 :                             if self.mode != Mode::Review {
     377            0 :                                 if let Err(e) = self.open_commit_mode() {
     378            0 :                                     debug_log(format!("Failed to open commit mode: {}", e));
     379            0 :                                 }
     380            0 :                             }
     381              :                         }
     382              :                         KeyCode::Char('m') => {
     383            0 :                             if self.mode != Mode::Review {
     384            0 :                                 self.cycle_mode();
     385            0 :                             }
     386              :                         }
     387            0 :                         KeyCode::Char(' ') => {
     388            0 :                             // Advance to next hunk
     389            0 :                             self.advance_hunk();
     390            0 :                         }
     391              :                         KeyCode::Char('b') | KeyCode::Char('B') => {
     392              :                             // 'b' for back - alternative to Shift+Space
     393            0 :                             debug_log("B key pressed (back)".to_string());
     394            0 :                             match self.mode {
     395            0 :                                 Mode::View | Mode::Review => {
     396            0 :                                     self.previous_hunk();
     397            0 :                                 }
     398            0 :                                 Mode::Streaming(StreamingType::Buffered) => {
     399            0 :                                     self.previous_hunk();
     400            0 :                                 }
     401            0 :                                 Mode::Streaming(StreamingType::Auto(_)) => {
     402            0 :                                     debug_log("Auto mode - ignoring back".to_string());
     403            0 :                                 }
     404              :                             }
     405              :                         }
     406            0 :                         KeyCode::Tab => self.cycle_focus_forward(),
     407            0 :                         KeyCode::BackTab => self.cycle_focus_backward(),
     408              :                         KeyCode::Char('j') | KeyCode::Down => {
     409            0 :                             if self.show_extended_help {
     410              :                                 // Scroll down in extended help - pre-clamp to prevent flashing
     411            0 :                                 let content_height = self.extended_help_content_height() as u16;
     412            0 :                                 let viewport_height = self.last_help_viewport_height;
     413            0 :                                 if content_height > viewport_height {
     414            0 :                                     let max_scroll = content_height.saturating_sub(viewport_height);
     415            0 :                                     if self.extended_help_scroll_offset < max_scroll {
     416            0 :                                         self.extended_help_scroll_offset =
     417            0 :                                             self.extended_help_scroll_offset.saturating_add(1);
     418            0 :                                     }
     419            0 :                                 }
     420              :                             } else {
     421            0 :                                 match self.focus {
     422            0 :                                     FocusPane::FileList => {
     423            0 :                                         // Navigate to next file and jump to its first hunk
     424            0 :                                         self.next_file();
     425            0 :                                         self.scroll_offset = 0;
     426            0 :                                     }
     427              :                                     FocusPane::HunkView => {
     428            0 :                                         if self.line_selection_mode {
     429            0 :                                             // Navigate to next change line
     430            0 :                                             self.next_change_line();
     431            0 :                                         } else {
     432              :                                             // Scroll down in hunk view - pre-clamp to prevent flashing
     433            0 :                                             let content_height =
     434            0 :                                                 self.current_hunk_content_height() as u16;
     435            0 :                                             let viewport_height = self.last_diff_viewport_height;
     436            0 :                                             if content_height > viewport_height {
     437            0 :                                                 let max_scroll =
     438            0 :                                                     content_height.saturating_sub(viewport_height);
     439            0 :                                                 if self.scroll_offset < max_scroll {
     440            0 :                                                     self.scroll_offset =
     441            0 :                                                         self.scroll_offset.saturating_add(1);
     442            0 :                                                 }
     443            0 :                                             }
     444              :                                         }
     445              :                                     }
     446              :                                     FocusPane::HelpSidebar => {
     447              :                                         // Scroll down in help sidebar - pre-clamp to prevent flashing
     448            0 :                                         let content_height = self.help_content_height() as u16;
     449            0 :                                         let viewport_height = self.last_help_viewport_height;
     450            0 :                                         if content_height > viewport_height {
     451            0 :                                             let max_scroll =
     452            0 :                                                 content_height.saturating_sub(viewport_height);
     453            0 :                                             if self.help_scroll_offset < max_scroll {
     454            0 :                                                 self.help_scroll_offset =
     455            0 :                                                     self.help_scroll_offset.saturating_add(1);
     456            0 :                                             }
     457            0 :                                         }
     458              :                                     }
     459              :                                 }
     460              :                             }
     461              :                         }
     462              :                         KeyCode::Char('k') | KeyCode::Up => {
     463            0 :                             if self.show_extended_help {
     464            0 :                                 // Scroll up in extended help
     465            0 :                                 self.extended_help_scroll_offset =
     466            0 :                                     self.extended_help_scroll_offset.saturating_sub(1);
     467            0 :                             } else {
     468            0 :                                 match self.focus {
     469            0 :                                     FocusPane::FileList => {
     470            0 :                                         // Navigate to previous file and jump to its first hunk
     471            0 :                                         self.previous_file();
     472            0 :                                         self.scroll_offset = 0;
     473            0 :                                     }
     474              :                                     FocusPane::HunkView => {
     475            0 :                                         if self.line_selection_mode {
     476            0 :                                             // Navigate to previous change line
     477            0 :                                             self.previous_change_line();
     478            0 :                                         } else {
     479            0 :                                             // Scroll up in hunk view
     480            0 :                                             self.scroll_offset =
     481            0 :                                                 self.scroll_offset.saturating_sub(1);
     482            0 :                                         }
     483              :                                     }
     484            0 :                                     FocusPane::HelpSidebar => {
     485            0 :                                         // Scroll up in help sidebar
     486            0 :                                         self.help_scroll_offset =
     487            0 :                                             self.help_scroll_offset.saturating_sub(1);
     488            0 :                                     }
     489              :                                 }
     490              :                             }
     491              :                         }
     492            0 :                         KeyCode::Char('n') => {
     493            0 :                             // Next file
     494            0 :                             self.next_file();
     495            0 :                             self.scroll_offset = 0;
     496            0 :                         }
     497            0 :                         KeyCode::Char('p') => {
     498            0 :                             // Previous file
     499            0 :                             self.previous_file();
     500            0 :                             self.scroll_offset = 0;
     501            0 :                         }
     502            0 :                         KeyCode::Char('f') => {
     503            0 :                             // Toggle filenames only
     504            0 :                             self.show_filenames_only = !self.show_filenames_only;
     505            0 :                         }
     506              :                         KeyCode::Char('s') | KeyCode::Char('S') => {
     507            0 :                             if self.mode == Mode::Review {
     508            0 :                                 // In review mode, toggle acceptance of the current hunk (in-memory)
     509            0 :                                 self.toggle_review_acceptance();
     510            0 :                             } else {
     511            0 :                                 // Stage/unstage current selection (smart toggle)
     512            0 :                                 self.stage_current_selection();
     513            0 :                             }
     514              :                         }
     515            0 :                         KeyCode::Char('w') => {
     516            0 :                             // Toggle line wrapping
     517            0 :                             self.wrap_lines = !self.wrap_lines;
     518            0 :                         }
     519            0 :                         KeyCode::Char('y') => {
     520            0 :                             // Toggle syntax highlighting
     521            0 :                             self.syntax_highlighting = !self.syntax_highlighting;
     522            0 :                         }
     523              :                         KeyCode::Char('l') | KeyCode::Char('L') => {
     524            0 :                             self.toggle_line_selection_mode()
     525              :                         }
     526              :                         KeyCode::Char('h') => {
     527              :                             // Toggle help sidebar
     528            0 :                             self.show_help = !self.show_help;
     529            0 :                             self.help_scroll_offset = 0;
     530              :                             // If hiding help and focus was on help sidebar, move focus to hunk view
     531            0 :                             if !self.show_help && self.focus == FocusPane::HelpSidebar {
     532            0 :                                 self.focus = FocusPane::HunkView;
     533            0 :                             }
     534              :                         }
     535            0 :                         KeyCode::Char('H') => {
     536            0 :                             // Toggle extended help view
     537            0 :                             self.show_extended_help = !self.show_extended_help;
     538            0 :                             self.extended_help_scroll_offset = 0;
     539            0 :                         }
     540              :                         KeyCode::Esc => {
     541            0 :                             if self.mode == Mode::Review {
     542            0 :                                 // Exit review mode, go back to View
     543            0 :                                 self.exit_review_mode();
     544            0 :                             } else {
     545            0 :                                 // Reset to defaults
     546            0 :                                 self.show_extended_help = false;
     547            0 :                                 self.extended_help_scroll_offset = 0;
     548            0 :                                 self.mode = Mode::View;
     549            0 :                                 self.line_selection_mode = false;
     550            0 :                                 self.focus = FocusPane::HunkView;
     551            0 :                                 self.show_help = false;
     552            0 :                                 self.help_scroll_offset = 0;
     553            0 :                             }
     554              :                         }
     555            0 :                         _ => {}
     556              :                     }
     557            0 :                 }
     558            0 :             }
     559              :         }
     560              : 
     561            0 :         Ok(())
     562            0 :     }
     563              : 
     564          105 :     fn advance_hunk(&mut self) {
     565              :         // Get needed info from snapshot without holding borrow on self
     566          103 :         let (files_len, file_hunks_len) = {
     567          105 :             let snapshot = match self.active_snapshot() {
     568          104 :                 Some(s) if !s.files.is_empty() => s,
     569            2 :                 _ => return,
     570              :             };
     571          103 :             let fl = snapshot.files.len();
     572          103 :             if self.current_file_index >= fl {
     573            0 :                 return;
     574          103 :             }
     575          103 :             (fl, snapshot.files[self.current_file_index].hunks.len())
     576              :         };
     577              : 
     578              :         // Clear line memory for current hunk before moving
     579          103 :         let old_hunk_key = (self.current_file_index, self.current_hunk_index);
     580          103 :         self.hunk_line_memory.remove(&old_hunk_key);
     581              : 
     582              :         // Advance to next hunk
     583          103 :         self.current_hunk_index += 1;
     584          103 :         self.scroll_offset = 0;
     585              : 
     586              :         // If we've gone past the last hunk in this file, move to next file
     587          103 :         if self.current_hunk_index >= file_hunks_len {
     588          102 :             self.current_file_index += 1;
     589          102 :             self.current_hunk_index = 0;
     590              : 
     591              :             // If no more files, behavior depends on mode
     592          102 :             if self.current_file_index >= files_len {
     593          102 :                 if self.mode == Mode::View || self.mode == Mode::Review {
     594          101 :                     // View/Review mode: wrap to the first hunk of the first file
     595          101 :                     self.current_file_index = 0;
     596          101 :                     self.current_hunk_index = 0;
     597          101 :                 } else {
     598              :                     // Streaming/Buffered: pager semantics, stay at last hunk
     599            1 :                     self.current_file_index = files_len.saturating_sub(1);
     600            1 :                     if let Some(snapshot) = self.active_snapshot() {
     601            1 :                         if let Some(last_file) = snapshot.files.get(self.current_file_index) {
     602            1 :                             self.current_hunk_index = last_file.hunks.len().saturating_sub(1);
     603            1 :                         }
     604            0 :                     }
     605              :                 }
     606            0 :             }
     607            1 :         }
     608          105 :     }
     609              : 
     610            4 :     fn previous_hunk(&mut self) {
     611            4 :         debug_log("previous_hunk called".to_string());
     612            4 :         let files_len = match self.active_snapshot() {
     613            3 :             Some(s) => s.files.len(),
     614              :             None => {
     615            1 :                 debug_log("No snapshot, returning".to_string());
     616            1 :                 return;
     617              :             }
     618              :         };
     619              : 
     620            3 :         if files_len == 0 {
     621            1 :             debug_log("No files in snapshot, returning".to_string());
     622            1 :             return;
     623            2 :         }
     624              : 
     625            2 :         debug_log(format!(
     626              :             "Before: file_idx={}, hunk_idx={}",
     627              :             self.current_file_index, self.current_hunk_index
     628              :         ));
     629              : 
     630              :         // Clear line memory for current hunk before moving
     631            2 :         let old_hunk_key = (self.current_file_index, self.current_hunk_index);
     632            2 :         self.hunk_line_memory.remove(&old_hunk_key);
     633              : 
     634              :         // Reset scroll when moving to a different hunk
     635            2 :         self.scroll_offset = 0;
     636              : 
     637              :         // If we're at the first hunk of the current file, go to previous file's last hunk
     638            2 :         if self.current_hunk_index == 0 {
     639            2 :             if self.mode != Mode::View && self.mode != Mode::Review && self.current_file_index == 0
     640            1 :             {
     641            1 :                 // Streaming/Buffered: pager semantics, stay at first hunk
     642            1 :             } else {
     643            1 :                 self.previous_file();
     644              :                 // Set to the last hunk of the new file
     645            1 :                 if let Some(snapshot) = self.active_snapshot() {
     646            1 :                     if self.current_file_index < snapshot.files.len() {
     647            1 :                         let last_hunk_index = snapshot.files[self.current_file_index]
     648            1 :                             .hunks
     649            1 :                             .len()
     650            1 :                             .saturating_sub(1);
     651            1 :                         self.current_hunk_index = last_hunk_index;
     652            1 :                     }
     653            0 :                 }
     654              :             }
     655            0 :         } else {
     656            0 :             // Just go back one hunk in the current file
     657            0 :             self.current_hunk_index = self.current_hunk_index.saturating_sub(1);
     658            0 :         }
     659              : 
     660            2 :         debug_log(format!(
     661              :             "After: file_idx={}, hunk_idx={}",
     662              :             self.current_file_index, self.current_hunk_index
     663              :         ));
     664            4 :     }
     665              : 
     666            3 :     fn next_file(&mut self) {
     667            3 :         let files_len = match self.active_snapshot() {
     668            2 :             Some(s) if !s.files.is_empty() => s.files.len(),
     669            2 :             _ => return,
     670              :         };
     671              : 
     672              :         // Clear line memory for old file
     673            1 :         let old_file_index = self.current_file_index;
     674              : 
     675              :         // Calculate next file index before clearing memory
     676            1 :         self.current_file_index = (self.current_file_index + 1) % files_len;
     677            1 :         self.current_hunk_index = 0;
     678              : 
     679              :         // Now clear the memory for the old file (after we're done with snapshot)
     680            1 :         self.clear_line_memory_for_file(old_file_index);
     681            3 :     }
     682              : 
     683            4 :     fn previous_file(&mut self) {
     684            4 :         let files_len = match self.active_snapshot() {
     685            3 :             Some(s) if !s.files.is_empty() => s.files.len(),
     686            2 :             _ => return,
     687              :         };
     688              : 
     689              :         // Clear line memory for old file
     690            2 :         let old_file_index = self.current_file_index;
     691              : 
     692              :         // Calculate previous file index before clearing memory
     693            2 :         if self.current_file_index == 0 {
     694            1 :             self.current_file_index = files_len - 1;
     695            1 :         } else {
     696            1 :             self.current_file_index -= 1;
     697            1 :         }
     698            2 :         self.current_hunk_index = 0;
     699              : 
     700              :         // Now clear the memory for the old file (after we're done with snapshot)
     701            2 :         self.clear_line_memory_for_file(old_file_index);
     702            4 :     }
     703              : 
     704            3 :     fn next_change_line(&mut self) {
     705            3 :         if let Some(snapshot) = self.current_snapshot() {
     706            3 :             if let Some(file) = snapshot.files.get(self.current_file_index) {
     707            3 :                 if let Some(hunk) = file.hunks.get(self.current_hunk_index) {
     708              :                     // Build list of change lines (filter same way as UI does)
     709            3 :                     let changes: Vec<(usize, &String)> = hunk
     710            3 :                         .lines
     711            3 :                         .iter()
     712            3 :                         .enumerate()
     713           12 :                         .filter(|(_, line)| {
     714           12 :                             (line.starts_with('+') && !line.starts_with("+++"))
     715            9 :                                 || (line.starts_with('-') && !line.starts_with("---"))
     716           12 :                         })
     717            3 :                         .collect();
     718              : 
     719            3 :                     if !changes.is_empty() {
     720              :                         // Find where we are in the changes list
     721            3 :                         let current_in_changes = changes
     722            3 :                             .iter()
     723            5 :                             .position(|(idx, _)| *idx == self.selected_line_index);
     724              : 
     725            2 :                         match current_in_changes {
     726            2 :                             Some(pos) if pos + 1 < changes.len() => {
     727            1 :                                 // Move to next change
     728            1 :                                 self.selected_line_index = changes[pos + 1].0;
     729            1 :                             }
     730            1 :                             None => {
     731            1 :                                 // Not on a change line, go to first
     732            1 :                                 self.selected_line_index = changes[0].0;
     733            1 :                             }
     734            1 :                             _ => {
     735            1 :                                 // At the end, stay there (or could wrap to first)
     736            1 :                             }
     737              :                         }
     738            0 :                     }
     739            0 :                 }
     740            0 :             }
     741            0 :         }
     742            3 :     }
     743              : 
     744            3 :     fn previous_change_line(&mut self) {
     745            3 :         if let Some(snapshot) = self.current_snapshot() {
     746            3 :             if let Some(file) = snapshot.files.get(self.current_file_index) {
     747            3 :                 if let Some(hunk) = file.hunks.get(self.current_hunk_index) {
     748              :                     // Build list of change lines (filter same way as UI does)
     749            3 :                     let changes: Vec<(usize, &String)> = hunk
     750            3 :                         .lines
     751            3 :                         .iter()
     752            3 :                         .enumerate()
     753           12 :                         .filter(|(_, line)| {
     754           12 :                             (line.starts_with('+') && !line.starts_with("+++"))
     755            9 :                                 || (line.starts_with('-') && !line.starts_with("---"))
     756           12 :                         })
     757            3 :                         .collect();
     758              : 
     759            3 :                     if !changes.is_empty() {
     760              :                         // Find where we are in the changes list
     761            3 :                         let current_in_changes = changes
     762            3 :                             .iter()
     763            5 :                             .position(|(idx, _)| *idx == self.selected_line_index);
     764              : 
     765            2 :                         match current_in_changes {
     766            2 :                             Some(pos) if pos > 0 => {
     767            1 :                                 // Move to previous change
     768            1 :                                 self.selected_line_index = changes[pos - 1].0;
     769            1 :                             }
     770            1 :                             None => {
     771            1 :                                 // Not on a change line, go to last
     772            1 :                                 self.selected_line_index = changes[changes.len() - 1].0;
     773            1 :                             }
     774            1 :                             _ => {
     775            1 :                                 // At the beginning, stay there (or could wrap to last)
     776            1 :                             }
     777              :                         }
     778            0 :                     }
     779            0 :                 }
     780            0 :             }
     781            0 :         }
     782            3 :     }
     783              : 
     784            4 :     fn select_first_change_line(&mut self) {
     785            4 :         if let Some(snapshot) = self.current_snapshot() {
     786            4 :             if let Some(file) = snapshot.files.get(self.current_file_index) {
     787            4 :                 if let Some(hunk) = file.hunks.get(self.current_hunk_index) {
     788              :                     // Find first change line
     789            6 :                     for (idx, line) in hunk.lines.iter().enumerate() {
     790            6 :                         if (line.starts_with('+') && !line.starts_with("+++"))
     791            6 :                             || (line.starts_with('-') && !line.starts_with("---"))
     792              :                         {
     793            3 :                             self.selected_line_index = idx;
     794            3 :                             return;
     795            3 :                         }
     796              :                     }
     797            0 :                 }
     798            0 :             }
     799            0 :         }
     800              :         // Fallback
     801            1 :         self.selected_line_index = 0;
     802            4 :     }
     803              : 
     804            3 :     fn clear_line_memory_for_file(&mut self, file_index: usize) {
     805              :         // Remove all entries for this file
     806            3 :         self.hunk_line_memory
     807            3 :             .retain(|(f_idx, _), _| *f_idx != file_index);
     808            3 :     }
     809              : 
     810            5 :     fn cycle_mode(&mut self) {
     811            3 :         self.mode = match self.mode {
     812              :             Mode::View => {
     813            1 :                 self.streaming_start_snapshot = Some(self.current_snapshot_index);
     814            1 :                 debug_log(format!(
     815              :                     "Entering Streaming mode, baseline snapshot: {}",
     816              :                     self.current_snapshot_index
     817              :                 ));
     818            1 :                 Mode::Streaming(StreamingType::Buffered)
     819              :             }
     820              :             Mode::Streaming(StreamingType::Buffered) => {
     821            1 :                 Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast))
     822              :             }
     823              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast)) => {
     824            1 :                 Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium))
     825              :             }
     826              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium)) => {
     827            1 :                 Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow))
     828              :             }
     829              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow)) => {
     830            1 :                 self.streaming_start_snapshot = None;
     831            1 :                 self.current_snapshot_index = self.snapshots.len() - 1;
     832            1 :                 self.current_file_index = 0;
     833            1 :                 self.current_hunk_index = 0;
     834            1 :                 debug_log("Exiting Streaming mode, back to View".to_string());
     835            1 :                 Mode::View
     836              :             }
     837              :             Mode::Review => {
     838              :                 // cycle_mode should not be called in Review mode, but handle gracefully
     839            0 :                 Mode::Review
     840              :             }
     841              :         };
     842            5 :         self.last_auto_advance = Instant::now();
     843            5 :     }
     844              : 
     845            2 :     fn cycle_focus_forward(&mut self) {
     846            2 :         let old_focus = self.focus;
     847            2 :         self.focus = match self.focus {
     848            0 :             FocusPane::FileList => FocusPane::HunkView,
     849              :             FocusPane::HunkView => {
     850            1 :                 if self.show_help {
     851            1 :                     FocusPane::HelpSidebar
     852              :                 } else {
     853            0 :                     FocusPane::FileList
     854              :                 }
     855              :             }
     856            1 :             FocusPane::HelpSidebar => FocusPane::FileList,
     857              :         };
     858              : 
     859            2 :         if old_focus == FocusPane::HunkView
     860            1 :             && self.focus != FocusPane::HunkView
     861            1 :             && self.line_selection_mode
     862            1 :         {
     863            1 :             let hunk_key = (self.current_file_index, self.current_hunk_index);
     864            1 :             self.hunk_line_memory
     865            1 :                 .insert(hunk_key, self.selected_line_index);
     866            1 :             self.line_selection_mode = false;
     867            1 :         }
     868            2 :     }
     869              : 
     870            1 :     fn cycle_focus_backward(&mut self) {
     871            1 :         let old_focus = self.focus;
     872            1 :         self.focus = match self.focus {
     873              :             FocusPane::FileList => {
     874            1 :                 if self.show_help {
     875            1 :                     FocusPane::HelpSidebar
     876              :                 } else {
     877            0 :                     FocusPane::HunkView
     878              :                 }
     879              :             }
     880            0 :             FocusPane::HunkView => FocusPane::FileList,
     881            0 :             FocusPane::HelpSidebar => FocusPane::HunkView,
     882              :         };
     883              : 
     884            1 :         if old_focus == FocusPane::HunkView
     885            0 :             && self.focus != FocusPane::HunkView
     886            0 :             && self.line_selection_mode
     887            0 :         {
     888            0 :             let hunk_key = (self.current_file_index, self.current_hunk_index);
     889            0 :             self.hunk_line_memory
     890            0 :                 .insert(hunk_key, self.selected_line_index);
     891            0 :             self.line_selection_mode = false;
     892            1 :         }
     893            1 :     }
     894              : 
     895            3 :     fn toggle_line_selection_mode(&mut self) {
     896            3 :         if self.focus == FocusPane::HunkView {
     897            3 :             if self.line_selection_mode {
     898            1 :                 let hunk_key = (self.current_file_index, self.current_hunk_index);
     899            1 :                 self.hunk_line_memory
     900            1 :                     .insert(hunk_key, self.selected_line_index);
     901            1 :                 self.line_selection_mode = false;
     902            1 :             } else {
     903            2 :                 self.line_selection_mode = true;
     904            2 :                 let hunk_key = (self.current_file_index, self.current_hunk_index);
     905              : 
     906            2 :                 if let Some(&saved_line) = self.hunk_line_memory.get(&hunk_key) {
     907            1 :                     self.selected_line_index = saved_line;
     908            1 :                 } else {
     909            1 :                     self.select_first_change_line();
     910            1 :                 }
     911              :             }
     912            0 :         }
     913            3 :     }
     914              : 
     915            5 :     fn stage_current_selection(&mut self) {
     916            5 :         let mut refresh_needed = false;
     917              : 
     918            5 :         match self.focus {
     919              :             FocusPane::HunkView => {
     920              :                 // Check if we're in line selection mode
     921            2 :                 if self.line_selection_mode {
     922              :                     // Stage/unstage a single line
     923            1 :                     if let Some(snapshot) = self.snapshots.get_mut(self.current_snapshot_index) {
     924            1 :                         if let Some(file) = snapshot.files.get_mut(self.current_file_index) {
     925            1 :                             if let Some(hunk) = file.hunks.get_mut(self.current_hunk_index) {
     926              :                                 // Get the selected line
     927            1 :                                 if let Some(selected_line) =
     928            1 :                                     hunk.lines.get(self.selected_line_index)
     929              :                                 {
     930              :                                     // Only stage change lines (+ or -)
     931            1 :                                     if (selected_line.starts_with('+')
     932            1 :                                         && !selected_line.starts_with("+++"))
     933            0 :                                         || (selected_line.starts_with('-')
     934            0 :                                             && !selected_line.starts_with("---"))
     935              :                                     {
     936              :                                         // Check if line is already staged
     937            1 :                                         let is_staged = hunk
     938            1 :                                             .staged_line_indices
     939            1 :                                             .contains(&self.selected_line_index);
     940              : 
     941            1 :                                         if is_staged {
     942              :                                             // Unstage the single line
     943            0 :                                             match self.git_repo.unstage_single_line(
     944            0 :                                                 hunk,
     945            0 :                                                 self.selected_line_index,
     946            0 :                                                 &file.path,
     947            0 :                                             ) {
     948            0 :                                                 Ok(_) => {
     949            0 :                                                     // Remove this line from staged indices
     950            0 :                                                     hunk.staged_line_indices
     951            0 :                                                         .remove(&self.selected_line_index);
     952            0 :                                                     debug_log(format!(
     953            0 :                                                         "Unstaged line {} in {}",
     954            0 :                                                         self.selected_line_index,
     955            0 :                                                         file.path.display()
     956            0 :                                                     ));
     957            0 :                                                     refresh_needed = true;
     958            0 :                                                 }
     959            0 :                                                 Err(e) => {
     960            0 :                                                     debug_log(format!("Failed to unstage line: {}. Note: Line-level unstaging is experimental and may not work for all hunks. Consider unstaging the entire hunk with Shift+U instead.", e));
     961            0 :                                                 }
     962              :                                             }
     963              :                                         } else {
     964              :                                             // Stage the single line
     965            1 :                                             match self.git_repo.stage_single_line(
     966            1 :                                                 hunk,
     967            1 :                                                 self.selected_line_index,
     968            1 :                                                 &file.path,
     969            1 :                                             ) {
     970            1 :                                                 Ok(_) => {
     971            1 :                                                     // Mark this line as staged
     972            1 :                                                     hunk.staged_line_indices
     973            1 :                                                         .insert(self.selected_line_index);
     974            1 :                                                     debug_log(format!(
     975            1 :                                                         "Staged line {} in {}",
     976            1 :                                                         self.selected_line_index,
     977            1 :                                                         file.path.display()
     978            1 :                                                     ));
     979            1 :                                                     refresh_needed = true;
     980            1 :                                                 }
     981            0 :                                                 Err(e) => {
     982            0 :                                                     debug_log(format!("Failed to stage line: {}. Note: Line-level staging is experimental and may not work for all hunks. Consider staging the entire hunk with Shift+S instead.", e));
     983            0 :                                                 }
     984              :                                             }
     985              :                                         }
     986            0 :                                     }
     987            0 :                                 }
     988            0 :                             }
     989            0 :                         }
     990            0 :                     }
     991              :                 } else {
     992              :                     // Toggle staging for the current hunk
     993            1 :                     if let Some(snapshot) = self.snapshots.get_mut(self.current_snapshot_index) {
     994            1 :                         if let Some(file) = snapshot.files.get_mut(self.current_file_index) {
     995            1 :                             if let Some(hunk) = file.hunks.get_mut(self.current_hunk_index) {
     996            1 :                                 match self.git_repo.toggle_hunk_staging(hunk, &file.path) {
     997            1 :                                     Ok(is_staged_now) => {
     998            1 :                                         if is_staged_now {
     999            1 :                                             debug_log(format!(
    1000            1 :                                                 "Staged hunk in {}",
    1001            1 :                                                 file.path.display()
    1002            1 :                                             ));
    1003            1 :                                         } else {
    1004            0 :                                             debug_log(format!(
    1005            0 :                                                 "Unstaged hunk in {}",
    1006            0 :                                                 file.path.display()
    1007            0 :                                             ));
    1008            0 :                                         }
    1009            1 :                                         refresh_needed = true;
    1010              :                                     }
    1011            0 :                                     Err(e) => {
    1012            0 :                                         debug_log(format!("Failed to toggle hunk staging: {}", e));
    1013            0 :                                     }
    1014              :                                 }
    1015            0 :                             }
    1016            0 :                         }
    1017            0 :                     }
    1018              :                 }
    1019              :             }
    1020              :             FocusPane::FileList => {
    1021              :                 // Toggle staging for the entire file
    1022            2 :                 if let Some(snapshot) = self.snapshots.get_mut(self.current_snapshot_index) {
    1023            2 :                     if let Some(file) = snapshot.files.get_mut(self.current_file_index) {
    1024              :                         // Check if any hunks are staged
    1025            2 :                         let any_staged = file.hunks.iter().any(|h| h.staged);
    1026              : 
    1027            2 :                         if any_staged {
    1028              :                             // Unstage the file
    1029            1 :                             match self.git_repo.unstage_file(&file.path) {
    1030              :                                 Ok(_) => {
    1031              :                                     // Mark all hunks as unstaged
    1032            1 :                                     for hunk in &mut file.hunks {
    1033            1 :                                         hunk.staged = false;
    1034            1 :                                         hunk.staged_line_indices.clear();
    1035            1 :                                     }
    1036            1 :                                     debug_log(format!("Unstaged file {}", file.path.display()));
    1037            1 :                                     refresh_needed = true;
    1038              :                                 }
    1039            0 :                                 Err(e) => {
    1040            0 :                                     debug_log(format!("Failed to unstage file: {}", e));
    1041            0 :                                 }
    1042              :                             }
    1043              :                         } else {
    1044              :                             // Stage the file
    1045            1 :                             match self.git_repo.stage_file(&file.path) {
    1046              :                                 Ok(_) => {
    1047              :                                     // Mark all hunks as staged
    1048            1 :                                     for hunk in &mut file.hunks {
    1049            1 :                                         hunk.staged = true;
    1050              :                                         // Mark all change lines as staged
    1051            1 :                                         hunk.staged_line_indices.clear();
    1052            4 :                                         for (idx, line) in hunk.lines.iter().enumerate() {
    1053            4 :                                             if (line.starts_with('+') && !line.starts_with("+++"))
    1054            3 :                                                 || (line.starts_with('-')
    1055            1 :                                                     && !line.starts_with("---"))
    1056            2 :                                             {
    1057            2 :                                                 hunk.staged_line_indices.insert(idx);
    1058            2 :                                             }
    1059              :                                         }
    1060              :                                     }
    1061            1 :                                     debug_log(format!("Staged file {}", file.path.display()));
    1062            1 :                                     refresh_needed = true;
    1063              :                                 }
    1064            0 :                                 Err(e) => {
    1065            0 :                                     debug_log(format!("Failed to stage file: {}", e));
    1066            0 :                                 }
    1067              :                             }
    1068              :                         }
    1069            0 :                     }
    1070            0 :                 }
    1071              :             }
    1072            1 :             FocusPane::HelpSidebar => {
    1073            1 :                 // No staging action for help sidebar
    1074            1 :             }
    1075              :         }
    1076              : 
    1077            5 :         if refresh_needed {
    1078            4 :             self.refresh_current_snapshot_from_git();
    1079            4 :         }
    1080            5 :     }
    1081              : 
    1082            0 :     fn open_commit_mode(&mut self) -> Result<()> {
    1083              :         // Temporarily suspend the TUI so git/editor can take over the terminal.
    1084            0 :         disable_raw_mode()?;
    1085            0 :         execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?;
    1086              : 
    1087            0 :         let commit_result = self.git_repo.commit_with_editor();
    1088              : 
    1089              :         // Always restore TUI state before returning.
    1090            0 :         enable_raw_mode()?;
    1091            0 :         execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?;
    1092              : 
    1093            0 :         let status = commit_result?;
    1094            0 :         if !status.success() {
    1095            0 :             debug_log(format!(
    1096            0 :                 "git commit exited with status {:?} (possibly canceled or nothing to commit)",
    1097            0 :                 status.code()
    1098            0 :             ));
    1099            0 :         }
    1100              : 
    1101            0 :         self.refresh_current_snapshot_from_git();
    1102            0 :         self.last_auto_advance = Instant::now();
    1103            0 :         self.needs_full_redraw = true;
    1104            0 :         Ok(())
    1105            0 :     }
    1106              : 
    1107            6 :     fn annotate_staged_lines(&self, snapshot: &mut DiffSnapshot) {
    1108            6 :         for file in &mut snapshot.files {
    1109            6 :             for hunk in &mut file.hunks {
    1110            6 :                 match self.git_repo.detect_staged_lines(hunk, &file.path) {
    1111            6 :                     Ok(staged_indices) => {
    1112            6 :                         hunk.staged_line_indices = staged_indices;
    1113              : 
    1114            6 :                         let total_change_lines = hunk
    1115            6 :                             .lines
    1116            6 :                             .iter()
    1117           24 :                             .filter(|line| {
    1118           24 :                                 (line.starts_with('+') && !line.starts_with("+++"))
    1119           18 :                                     || (line.starts_with('-') && !line.starts_with("---"))
    1120           24 :                             })
    1121            6 :                             .count();
    1122              : 
    1123            6 :                         hunk.staged = hunk.staged_line_indices.len() == total_change_lines
    1124            2 :                             && total_change_lines > 0;
    1125              :                     }
    1126            0 :                     Err(e) => {
    1127            0 :                         debug_log(format!("Failed to detect staged lines: {}", e));
    1128            0 :                     }
    1129              :                 }
    1130              :             }
    1131              :         }
    1132            6 :     }
    1133              : 
    1134            6 :     fn refresh_current_snapshot_from_git(&mut self) {
    1135            6 :         let previous_selected_line = self.selected_line_index;
    1136              : 
    1137            6 :         match self.git_repo.get_diff_snapshot() {
    1138            6 :             Ok(mut snapshot) => {
    1139            6 :                 self.annotate_staged_lines(&mut snapshot);
    1140              : 
    1141            6 :                 if self.snapshots.is_empty() {
    1142            0 :                     self.snapshots.push(snapshot);
    1143            0 :                     self.current_snapshot_index = 0;
    1144            6 :                 } else {
    1145            6 :                     self.snapshots[self.current_snapshot_index] = snapshot;
    1146            6 :                 }
    1147              : 
    1148              :                 // Clamp indices after snapshot replacement
    1149            6 :                 if let Some(current_snapshot) = self.snapshots.get(self.current_snapshot_index) {
    1150            6 :                     if current_snapshot.files.is_empty() {
    1151            0 :                         self.current_file_index = 0;
    1152            0 :                         self.current_hunk_index = 0;
    1153            0 :                         self.selected_line_index = 0;
    1154            0 :                         return;
    1155            6 :                     }
    1156              : 
    1157            6 :                     if self.current_file_index >= current_snapshot.files.len() {
    1158            0 :                         self.current_file_index = current_snapshot.files.len().saturating_sub(1);
    1159            6 :                     }
    1160              : 
    1161            6 :                     if let Some(file) = current_snapshot.files.get(self.current_file_index) {
    1162            6 :                         if file.hunks.is_empty() {
    1163            0 :                             self.current_hunk_index = 0;
    1164            0 :                             self.selected_line_index = 0;
    1165            0 :                             return;
    1166            6 :                         }
    1167              : 
    1168            6 :                         if self.current_hunk_index >= file.hunks.len() {
    1169            0 :                             self.current_hunk_index = file.hunks.len().saturating_sub(1);
    1170            6 :                         }
    1171              : 
    1172            6 :                         if self.line_selection_mode {
    1173            2 :                             self.select_nearest_change_line(previous_selected_line);
    1174            4 :                         } else {
    1175            4 :                             self.selected_line_index = 0;
    1176            4 :                         }
    1177            0 :                     }
    1178            0 :                 }
    1179              :             }
    1180            0 :             Err(e) => {
    1181            0 :                 debug_log(format!(
    1182            0 :                     "Failed to refresh snapshot after staging action: {}",
    1183            0 :                     e
    1184            0 :                 ));
    1185            0 :             }
    1186              :         }
    1187            6 :     }
    1188              : 
    1189            2 :     fn select_nearest_change_line(&mut self, preferred_index: usize) {
    1190            2 :         if let Some(snapshot) = self.current_snapshot() {
    1191            2 :             if let Some(file) = snapshot.files.get(self.current_file_index) {
    1192            2 :                 if let Some(hunk) = file.hunks.get(self.current_hunk_index) {
    1193            2 :                     let change_indices: Vec<usize> = hunk
    1194            2 :                         .lines
    1195            2 :                         .iter()
    1196            2 :                         .enumerate()
    1197            8 :                         .filter_map(|(idx, line)| {
    1198            8 :                             ((line.starts_with('+') && !line.starts_with("+++"))
    1199            6 :                                 || (line.starts_with('-') && !line.starts_with("---")))
    1200            8 :                             .then_some(idx)
    1201            8 :                         })
    1202            2 :                         .collect();
    1203              : 
    1204            2 :                     if change_indices.is_empty() {
    1205            0 :                         self.selected_line_index = 0;
    1206            0 :                         return;
    1207            2 :                     }
    1208              : 
    1209            2 :                     let mut best_idx = change_indices[0];
    1210            2 :                     let mut best_dist = usize::abs_diff(best_idx, preferred_index);
    1211              : 
    1212            2 :                     for &idx in change_indices.iter().skip(1) {
    1213            2 :                         let dist = usize::abs_diff(idx, preferred_index);
    1214            2 :                         if dist < best_dist || (dist == best_dist && idx < best_idx) {
    1215            2 :                             best_idx = idx;
    1216            2 :                             best_dist = dist;
    1217            2 :                         }
    1218              :                     }
    1219              : 
    1220            2 :                     self.selected_line_index = best_idx;
    1221            2 :                     return;
    1222            0 :                 }
    1223            0 :             }
    1224            0 :         }
    1225              : 
    1226            0 :         self.selected_line_index = 0;
    1227            2 :     }
    1228              : 
    1229              :     /// Get the active snapshot for navigation — works in both normal and review modes.
    1230          118 :     fn active_snapshot(&self) -> Option<&DiffSnapshot> {
    1231          118 :         if self.mode == Mode::Review {
    1232          100 :             self.review_snapshot.as_ref()
    1233              :         } else {
    1234           18 :             self.snapshots.get(self.current_snapshot_index)
    1235              :         }
    1236          118 :     }
    1237              : 
    1238            9 :     fn enter_review_mode(&mut self) {
    1239            9 :         match self.git_repo.get_recent_commits(20) {
    1240            9 :             Ok(commits) => {
    1241            9 :                 if commits.is_empty() {
    1242            0 :                     debug_log("No commits found for review".to_string());
    1243            0 :                     return;
    1244            9 :                 }
    1245            9 :                 self.review_commits = commits;
    1246            9 :                 self.review_commit_cursor = 0;
    1247            9 :                 self.review_selecting_commit = true;
    1248            9 :                 self.mode = Mode::Review;
    1249            9 :                 debug_log("Entered review mode, showing commit picker".to_string());
    1250              :             }
    1251            0 :             Err(e) => {
    1252            0 :                 debug_log(format!("Failed to get commits for review: {}", e));
    1253            0 :             }
    1254              :         }
    1255            9 :     }
    1256              : 
    1257            6 :     fn select_review_commit(&mut self) {
    1258            6 :         if self.review_commit_cursor >= self.review_commits.len() {
    1259            0 :             return;
    1260            6 :         }
    1261            6 :         let sha = self.review_commits[self.review_commit_cursor].sha.clone();
    1262            6 :         debug_log(format!(
    1263              :             "Loading commit diff for {}",
    1264            6 :             &sha[..7.min(sha.len())]
    1265              :         ));
    1266              : 
    1267            6 :         match self.git_repo.get_commit_diff(&sha) {
    1268            6 :             Ok(snapshot) => {
    1269            6 :                 self.review_snapshot = Some(snapshot);
    1270            6 :                 self.review_selecting_commit = false;
    1271            6 :                 self.current_file_index = 0;
    1272            6 :                 self.current_hunk_index = 0;
    1273            6 :                 self.scroll_offset = 0;
    1274            6 :                 self.line_selection_mode = false;
    1275            6 :                 self.focus = FocusPane::HunkView;
    1276            6 :                 debug_log("Loaded commit diff for review".to_string());
    1277            6 :             }
    1278            0 :             Err(e) => {
    1279            0 :                 debug_log(format!("Failed to load commit diff: {}", e));
    1280            0 :                 self.review_selecting_commit = false;
    1281            0 :                 self.review_commits.clear();
    1282            0 :                 self.mode = Mode::View;
    1283            0 :             }
    1284              :         }
    1285            6 :     }
    1286              : 
    1287            1 :     fn exit_review_mode(&mut self) {
    1288            1 :         self.mode = Mode::View;
    1289            1 :         self.review_selecting_commit = false;
    1290            1 :         self.review_commits.clear();
    1291            1 :         self.review_snapshot = None;
    1292            1 :         self.current_file_index = 0;
    1293            1 :         self.current_hunk_index = 0;
    1294            1 :         self.scroll_offset = 0;
    1295            1 :         self.line_selection_mode = false;
    1296            1 :         self.focus = FocusPane::HunkView;
    1297            1 :         self.show_help = false;
    1298            1 :         self.help_scroll_offset = 0;
    1299            1 :         debug_log("Exited review mode".to_string());
    1300            1 :     }
    1301              : 
    1302            3 :     fn toggle_review_acceptance(&mut self) {
    1303            3 :         if let Some(ref mut snapshot) = self.review_snapshot {
    1304            3 :             if let Some(file) = snapshot.files.get_mut(self.current_file_index) {
    1305            3 :                 if let Some(hunk) = file.hunks.get_mut(self.current_hunk_index) {
    1306            3 :                     hunk.accepted = !hunk.accepted;
    1307            3 :                     debug_log(format!(
    1308            3 :                         "Hunk accepted={} in {}",
    1309            3 :                         hunk.accepted,
    1310            3 :                         file.path.display()
    1311            3 :                     ));
    1312            3 :                 }
    1313            0 :             }
    1314            0 :         }
    1315            3 :     }
    1316              : 
    1317           73 :     pub fn current_snapshot(&self) -> Option<&DiffSnapshot> {
    1318           73 :         if self.mode == Mode::Review {
    1319            4 :             self.review_snapshot.as_ref()
    1320              :         } else {
    1321           69 :             self.snapshots.get(self.current_snapshot_index)
    1322              :         }
    1323           73 :     }
    1324              : 
    1325           29 :     pub fn current_file(&self) -> Option<&FileChange> {
    1326           29 :         self.current_snapshot()?.files.get(self.current_file_index)
    1327           29 :     }
    1328              : 
    1329           42 :     pub fn current_file_index(&self) -> usize {
    1330           42 :         self.current_file_index
    1331           42 :     }
    1332              : 
    1333           21 :     pub fn current_hunk_index(&self) -> usize {
    1334           21 :         self.current_hunk_index
    1335           21 :     }
    1336              : 
    1337           10 :     pub fn scroll_offset(&self) -> u16 {
    1338           10 :         self.scroll_offset
    1339           10 :     }
    1340              : 
    1341            5 :     pub fn help_scroll_offset(&self) -> u16 {
    1342            5 :         self.help_scroll_offset
    1343            5 :     }
    1344              : 
    1345           69 :     pub fn mode(&self) -> Mode {
    1346           69 :         self.mode
    1347           69 :     }
    1348              : 
    1349           10 :     pub fn line_selection_mode(&self) -> bool {
    1350           10 :         self.line_selection_mode
    1351           10 :     }
    1352              : 
    1353           10 :     pub fn selected_line_index(&self) -> usize {
    1354           10 :         self.selected_line_index
    1355           10 :     }
    1356              : 
    1357           81 :     pub fn focus(&self) -> FocusPane {
    1358           81 :         self.focus
    1359           81 :     }
    1360              : 
    1361           12 :     pub fn show_filenames_only(&self) -> bool {
    1362           12 :         self.show_filenames_only
    1363           12 :     }
    1364              : 
    1365           10 :     pub fn wrap_lines(&self) -> bool {
    1366           10 :         self.wrap_lines
    1367           10 :     }
    1368              : 
    1369           29 :     pub fn show_help(&self) -> bool {
    1370           29 :         self.show_help
    1371           29 :     }
    1372              : 
    1373           30 :     pub fn show_extended_help(&self) -> bool {
    1374           30 :         self.show_extended_help
    1375           30 :     }
    1376              : 
    1377            1 :     pub fn extended_help_scroll_offset(&self) -> u16 {
    1378            1 :         self.extended_help_scroll_offset
    1379            1 :     }
    1380              : 
    1381           10 :     pub fn syntax_highlighting(&self) -> bool {
    1382           10 :         self.syntax_highlighting
    1383           10 :     }
    1384              : 
    1385           31 :     pub fn review_selecting_commit(&self) -> bool {
    1386           31 :         self.review_selecting_commit
    1387           31 :     }
    1388              : 
    1389            1 :     pub fn review_commits(&self) -> &[CommitInfo] {
    1390            1 :         &self.review_commits
    1391            1 :     }
    1392              : 
    1393            1 :     pub fn review_commit_cursor(&self) -> usize {
    1394            1 :         self.review_commit_cursor
    1395            1 :     }
    1396              : 
    1397              :     /// Get the height (line count) of the current hunk content
    1398            3 :     pub fn current_hunk_content_height(&self) -> usize {
    1399            3 :         if let Some(snapshot) = self.current_snapshot() {
    1400            3 :             if let Some(file) = snapshot.files.get(self.current_file_index) {
    1401            2 :                 if let Some(hunk) = file.hunks.get(self.current_hunk_index) {
    1402              :                     // Count: file header (2) + blank + hunk header + blank + context before (max 5) + changes + context after (max 5)
    1403            2 :                     let mut context_before = 0;
    1404            2 :                     let mut changes = 0;
    1405            2 :                     let mut context_after = 0;
    1406            2 :                     let mut in_changes = false;
    1407              : 
    1408           12 :                     for line in &hunk.lines {
    1409           12 :                         if line.starts_with('+') || line.starts_with('-') {
    1410            4 :                             in_changes = true;
    1411            4 :                             changes += 1;
    1412            8 :                         } else if !in_changes {
    1413            1 :                             context_before += 1;
    1414            7 :                         } else {
    1415            7 :                             context_after += 1;
    1416            7 :                         }
    1417              :                     }
    1418              : 
    1419              :                     // Limit context to 5 lines each
    1420            2 :                     let context_before_shown = context_before.min(5);
    1421            2 :                     let context_after_shown = context_after.min(5);
    1422              : 
    1423            2 :                     return 2 + 1 + 1 + 1 + context_before_shown + changes + context_after_shown;
    1424            0 :                 }
    1425            1 :             }
    1426            0 :         }
    1427            1 :         0
    1428            3 :     }
    1429              : 
    1430              :     /// Get the height (line count) of the help sidebar content
    1431            1 :     pub fn help_content_height(&self) -> usize {
    1432            1 :         32 // Number of help lines in draw_help_sidebar
    1433            1 :     }
    1434              : 
    1435              :     /// Clamp scroll offset to valid range based on content and viewport height
    1436            2 :     pub fn clamp_scroll_offset(&mut self, viewport_height: u16) {
    1437            2 :         let content_height = self.current_hunk_content_height() as u16;
    1438            2 :         if content_height > viewport_height {
    1439            1 :             let max_scroll = content_height.saturating_sub(viewport_height);
    1440            1 :             self.scroll_offset = self.scroll_offset.min(max_scroll);
    1441            1 :         } else {
    1442            1 :             self.scroll_offset = 0;
    1443            1 :         }
    1444            2 :     }
    1445              : 
    1446              :     /// Clamp help scroll offset to valid range based on content and viewport height
    1447            1 :     pub fn clamp_help_scroll_offset(&mut self, viewport_height: u16) {
    1448            1 :         let content_height = self.help_content_height() as u16;
    1449            1 :         if content_height > viewport_height {
    1450            1 :             let max_scroll = content_height.saturating_sub(viewport_height);
    1451            1 :             self.help_scroll_offset = self.help_scroll_offset.min(max_scroll);
    1452            1 :         } else {
    1453            0 :             self.help_scroll_offset = 0;
    1454            0 :         }
    1455            1 :     }
    1456              : 
    1457              :     /// Get the height (line count) of the extended help content
    1458            2 :     pub fn extended_help_content_height(&self) -> usize {
    1459            2 :         108 // Exact number of lines in draw_extended_help
    1460            2 :     }
    1461              : 
    1462              :     /// Clamp extended help scroll offset to valid range based on content and viewport height
    1463            2 :     pub fn clamp_extended_help_scroll_offset(&mut self, viewport_height: u16) {
    1464            2 :         let content_height = self.extended_help_content_height() as u16;
    1465            2 :         if content_height > viewport_height {
    1466            1 :             let max_scroll = content_height.saturating_sub(viewport_height);
    1467            1 :             self.extended_help_scroll_offset = self.extended_help_scroll_offset.min(max_scroll);
    1468            1 :         } else {
    1469            1 :             self.extended_help_scroll_offset = 0;
    1470            1 :         }
    1471            2 :     }
    1472              : }
    1473              : 
    1474              : #[cfg(test)]
    1475              : #[path = "../tests/app.rs"]
    1476              : mod tests;
    1477              : 
    1478              : #[cfg(all(test, not(test)))]
    1479              : mod tests {
    1480              :     use super::*;
    1481              :     use crate::diff::Hunk;
    1482              :     use crate::ui::UI;
    1483              :     use ratatui::{backend::TestBackend, Terminal};
    1484              :     use std::fs;
    1485              :     use std::path::PathBuf;
    1486              :     use std::process::Command;
    1487              :     use std::sync::atomic::{AtomicU64, Ordering};
    1488              :     use std::time::{Duration, SystemTime, UNIX_EPOCH};
    1489              : 
    1490              :     static TEST_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
    1491              : 
    1492              :     struct TestRepo {
    1493              :         path: PathBuf,
    1494              :     }
    1495              : 
    1496              :     impl TestRepo {
    1497              :         fn new() -> Self {
    1498              :             let unique = SystemTime::now()
    1499              :                 .duration_since(UNIX_EPOCH)
    1500              :                 .expect("failed to get system time")
    1501              :                 .as_nanos();
    1502              :             let counter = TEST_DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
    1503              :             let path = std::env::temp_dir().join(format!(
    1504              :                 "hunky-app-tests-{}-{}-{}",
    1505              :                 std::process::id(),
    1506              :                 unique,
    1507              :                 counter
    1508              :             ));
    1509              :             fs::create_dir_all(&path).expect("failed to create temp directory");
    1510              :             run_git(&path, &["init"]);
    1511              :             run_git(&path, &["config", "user.name", "Test User"]);
    1512              :             run_git(&path, &["config", "user.email", "test@example.com"]);
    1513              :             Self { path }
    1514              :         }
    1515              : 
    1516              :         fn write_file(&self, rel_path: &str, content: &str) {
    1517              :             fs::write(self.path.join(rel_path), content).expect("failed to write file");
    1518              :         }
    1519              : 
    1520              :         fn commit_all(&self, message: &str) {
    1521              :             run_git(&self.path, &["add", "."]);
    1522              :             run_git(&self.path, &["commit", "-m", message]);
    1523              :         }
    1524              :     }
    1525              : 
    1526              :     impl Drop for TestRepo {
    1527              :         fn drop(&mut self) {
    1528              :             let _ = fs::remove_dir_all(&self.path);
    1529              :         }
    1530              :     }
    1531              : 
    1532              :     fn run_git(repo_path: &std::path::Path, args: &[&str]) -> String {
    1533              :         let output = Command::new("git")
    1534              :             .args(args)
    1535              :             .current_dir(repo_path)
    1536              :             .output()
    1537              :             .expect("failed to execute git");
    1538              :         assert!(
    1539              :             output.status.success(),
    1540              :             "git {:?} failed: {}",
    1541              :             args,
    1542              :             String::from_utf8_lossy(&output.stderr)
    1543              :         );
    1544              :         String::from_utf8_lossy(&output.stdout).to_string()
    1545              :     }
    1546              : 
    1547              :     fn render_buffer_to_string(terminal: &Terminal<TestBackend>) -> String {
    1548              :         let buffer = terminal.backend().buffer();
    1549              :         let mut rows = Vec::new();
    1550              :         for y in 0..buffer.area.height {
    1551              :             let mut row = String::new();
    1552              :             for x in 0..buffer.area.width {
    1553              :                 row.push_str(
    1554              :                     buffer
    1555              :                         .cell((x, y))
    1556              :                         .expect("buffer cell should be available")
    1557              :                         .symbol(),
    1558              :                 );
    1559              :             }
    1560              :             rows.push(row);
    1561              :         }
    1562              :         rows.join("\n")
    1563              :     }
    1564              : 
    1565              :     fn sample_snapshot() -> DiffSnapshot {
    1566              :         let file1 = PathBuf::from("a.txt");
    1567              :         let file2 = PathBuf::from("b.txt");
    1568              :         DiffSnapshot {
    1569              :             timestamp: SystemTime::now(),
    1570              :             files: vec![
    1571              :                 FileChange {
    1572              :                     path: file1.clone(),
    1573              :                     status: "Modified".to_string(),
    1574              :                     hunks: vec![Hunk::new(
    1575              :                         1,
    1576              :                         1,
    1577              :                         vec!["-old\n".to_string(), "+new\n".to_string()],
    1578              :                         &file1,
    1579              :                     )],
    1580              :                 },
    1581              :                 FileChange {
    1582              :                     path: file2.clone(),
    1583              :                     status: "Modified".to_string(),
    1584              :                     hunks: vec![Hunk::new(
    1585              :                         1,
    1586              :                         1,
    1587              :                         vec!["-old2\n".to_string(), "+new2\n".to_string()],
    1588              :                         &file2,
    1589              :                     )],
    1590              :                 },
    1591              :             ],
    1592              :         }
    1593              :     }
    1594              : 
    1595              :     #[tokio::test]
    1596              :     async fn cycle_mode_transitions_and_resets_streaming_state() {
    1597              :         let repo = TestRepo::new();
    1598              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1599              :             .await
    1600              :             .expect("failed to create app");
    1601              :         app.snapshots = vec![sample_snapshot()];
    1602              :         app.current_snapshot_index = 0;
    1603              : 
    1604              :         app.cycle_mode();
    1605              :         assert_eq!(app.mode, Mode::Streaming(StreamingType::Buffered));
    1606              :         assert_eq!(app.streaming_start_snapshot, Some(0));
    1607              : 
    1608              :         app.cycle_mode();
    1609              :         assert_eq!(
    1610              :             app.mode,
    1611              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast))
    1612              :         );
    1613              :         app.cycle_mode();
    1614              :         assert_eq!(
    1615              :             app.mode,
    1616              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium))
    1617              :         );
    1618              :         app.cycle_mode();
    1619              :         assert_eq!(
    1620              :             app.mode,
    1621              :             Mode::Streaming(StreamingType::Auto(StreamSpeed::Slow))
    1622              :         );
    1623              : 
    1624              :         app.current_file_index = 1;
    1625              :         app.current_hunk_index = 1;
    1626              :         app.cycle_mode();
    1627              :         assert_eq!(app.mode, Mode::View);
    1628              :         assert_eq!(app.streaming_start_snapshot, None);
    1629              :         assert_eq!(app.current_file_index, 0);
    1630              :         assert_eq!(app.current_hunk_index, 0);
    1631              :     }
    1632              : 
    1633              :     #[tokio::test]
    1634              :     async fn focus_cycle_saves_line_mode_and_handles_help_sidebar() {
    1635              :         let repo = TestRepo::new();
    1636              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1637              :             .await
    1638              :             .expect("failed to create app");
    1639              :         app.show_help = true;
    1640              :         app.focus = FocusPane::HunkView;
    1641              :         app.line_selection_mode = true;
    1642              :         app.selected_line_index = 3;
    1643              : 
    1644              :         app.cycle_focus_forward();
    1645              :         assert_eq!(app.focus, FocusPane::HelpSidebar);
    1646              :         assert!(!app.line_selection_mode);
    1647              :         assert_eq!(
    1648              :             app.hunk_line_memory
    1649              :                 .get(&(app.current_file_index, app.current_hunk_index)),
    1650              :             Some(&3)
    1651              :         );
    1652              : 
    1653              :         app.cycle_focus_forward();
    1654              :         assert_eq!(app.focus, FocusPane::FileList);
    1655              :         app.cycle_focus_backward();
    1656              :         assert_eq!(app.focus, FocusPane::HelpSidebar);
    1657              :     }
    1658              : 
    1659              :     #[tokio::test]
    1660              :     async fn toggle_line_selection_mode_restores_saved_line() {
    1661              :         let repo = TestRepo::new();
    1662              :         repo.write_file("example.txt", "line 1\nline 2\n");
    1663              :         repo.commit_all("initial");
    1664              :         repo.write_file("example.txt", "line 1\nline 2 updated\n");
    1665              : 
    1666              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1667              :             .await
    1668              :             .expect("failed to create app");
    1669              :         app.focus = FocusPane::HunkView;
    1670              : 
    1671              :         app.toggle_line_selection_mode();
    1672              :         assert!(app.line_selection_mode);
    1673              :         app.selected_line_index = 1;
    1674              : 
    1675              :         app.toggle_line_selection_mode();
    1676              :         assert!(!app.line_selection_mode);
    1677              :         app.selected_line_index = 0;
    1678              : 
    1679              :         app.toggle_line_selection_mode();
    1680              :         assert!(app.line_selection_mode);
    1681              :         assert_eq!(app.selected_line_index, 1);
    1682              :     }
    1683              : 
    1684              :     #[tokio::test]
    1685              :     async fn advance_hunk_stops_at_last_hunk() {
    1686              :         let repo = TestRepo::new();
    1687              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1688              :             .await
    1689              :             .expect("failed to create app");
    1690              :         app.snapshots = vec![sample_snapshot()];
    1691              :         app.current_snapshot_index = 0;
    1692              :         app.current_file_index = 1;
    1693              :         app.current_hunk_index = 0;
    1694              : 
    1695              :         app.advance_hunk();
    1696              :         assert_eq!(app.current_file_index, 1);
    1697              :         assert_eq!(app.current_hunk_index, 0);
    1698              :     }
    1699              : 
    1700              :     #[tokio::test]
    1701              :     async fn navigation_and_scroll_helpers_cover_core_branches() {
    1702              :         let repo = TestRepo::new();
    1703              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1704              :             .await
    1705              :             .expect("failed to create app");
    1706              :         let mut snapshot = sample_snapshot();
    1707              :         snapshot.files[0].hunks[0].lines = vec![
    1708              :             " context a\n".to_string(),
    1709              :             "-old\n".to_string(),
    1710              :             "+new\n".to_string(),
    1711              :             " context b\n".to_string(),
    1712              :         ];
    1713              :         app.snapshots = vec![snapshot];
    1714              :         app.current_snapshot_index = 0;
    1715              : 
    1716              :         assert_eq!(
    1717              :             StreamSpeed::Fast.duration_for_hunk(2),
    1718              :             Duration::from_millis(700)
    1719              :         );
    1720              :         assert_eq!(
    1721              :             StreamSpeed::Medium.duration_for_hunk(1),
    1722              :             Duration::from_millis(1000)
    1723              :         );
    1724              :         assert_eq!(
    1725              :             StreamSpeed::Slow.duration_for_hunk(3),
    1726              :             Duration::from_millis(3500)
    1727              :         );
    1728              : 
    1729              :         app.select_first_change_line();
    1730              :         assert_eq!(app.selected_line_index, 1);
    1731              :         app.next_change_line();
    1732              :         assert_eq!(app.selected_line_index, 2);
    1733              :         app.previous_change_line();
    1734              :         assert_eq!(app.selected_line_index, 1);
    1735              : 
    1736              :         app.hunk_line_memory.insert((0, 0), 1);
    1737              :         app.current_file_index = 0;
    1738              :         app.next_file();
    1739              :         assert_eq!(app.current_file_index, 1);
    1740              :         assert_eq!(app.current_hunk_index, 0);
    1741              :         assert!(!app.hunk_line_memory.contains_key(&(0, 0)));
    1742              :         app.previous_file();
    1743              :         assert_eq!(app.current_file_index, 0);
    1744              : 
    1745              :         app.scroll_offset = 50;
    1746              :         app.clamp_scroll_offset(20);
    1747              :         assert_eq!(app.scroll_offset, 0);
    1748              :         app.help_scroll_offset = 50;
    1749              :         app.clamp_help_scroll_offset(10);
    1750              :         assert_eq!(app.help_scroll_offset, 17);
    1751              :         app.extended_help_scroll_offset = 500;
    1752              :         app.clamp_extended_help_scroll_offset(20);
    1753              :         assert_eq!(app.extended_help_scroll_offset, 88);
    1754              :     }
    1755              : 
    1756              :     #[tokio::test]
    1757              :     async fn ui_draw_renders_mode_and_help_states() {
    1758              :         let repo = TestRepo::new();
    1759              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1760              :             .await
    1761              :             .expect("failed to create app");
    1762              : 
    1763              :         app.mode = Mode::Streaming(StreamingType::Auto(StreamSpeed::Fast));
    1764              :         let ui = UI::new(&app);
    1765              :         let backend = TestBackend::new(160, 30);
    1766              :         let mut terminal = Terminal::new(backend).expect("failed to create terminal");
    1767              :         terminal
    1768              :             .draw(|frame| {
    1769              :                 ui.draw(frame);
    1770              :             })
    1771              :             .expect("failed to draw ui");
    1772              :         let rendered = render_buffer_to_string(&terminal);
    1773              :         assert!(rendered.contains("STREAMING (Auto - Fast)"));
    1774              : 
    1775              :         app.show_help = true;
    1776              :         app.show_extended_help = false;
    1777              :         let ui = UI::new(&app);
    1778              :         terminal
    1779              :             .draw(|frame| {
    1780              :                 ui.draw(frame);
    1781              :             })
    1782              :             .expect("failed to draw ui");
    1783              :         let rendered = render_buffer_to_string(&terminal);
    1784              :         assert!(rendered.contains("Keys"));
    1785              : 
    1786              :         app.show_extended_help = true;
    1787              :         let ui = UI::new(&app);
    1788              :         terminal
    1789              :             .draw(|frame| {
    1790              :                 ui.draw(frame);
    1791              :             })
    1792              :             .expect("failed to draw ui");
    1793              :         let rendered = render_buffer_to_string(&terminal);
    1794              :         assert!(rendered.contains("Extended Help"));
    1795              :     }
    1796              : 
    1797              :     #[tokio::test]
    1798              :     async fn stage_current_selection_handles_line_hunk_and_file_modes() {
    1799              :         let repo = TestRepo::new();
    1800              :         repo.write_file("example.txt", "line 1\nline 2\nline 3\n");
    1801              :         repo.commit_all("initial");
    1802              :         repo.write_file("example.txt", "line 1\nline two updated\nline 3\n");
    1803              : 
    1804              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1805              :             .await
    1806              :             .expect("failed to create app");
    1807              :         app.current_snapshot_index = 0;
    1808              :         app.current_file_index = 0;
    1809              :         app.current_hunk_index = 0;
    1810              :         app.focus = FocusPane::HunkView;
    1811              :         app.line_selection_mode = true;
    1812              : 
    1813              :         let selected = app.snapshots[0].files[0].hunks[0]
    1814              :             .lines
    1815              :             .iter()
    1816              :             .position(|line| line.starts_with('+') && !line.starts_with("+++"))
    1817              :             .expect("expected added line");
    1818              :         app.selected_line_index = selected;
    1819              : 
    1820              :         // Line mode: stage selected line and verify index changed
    1821              :         app.stage_current_selection();
    1822              :         let cached_after_line_stage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1823              :         assert!(cached_after_line_stage.contains("example.txt"));
    1824              : 
    1825              :         // Reset to clean index before hunk-mode checks
    1826              :         run_git(&repo.path, &["reset", "HEAD", "--", "example.txt"]);
    1827              :         app.refresh_current_snapshot_from_git();
    1828              : 
    1829              :         // Hunk mode: stage current hunk and verify index changed
    1830              :         app.line_selection_mode = false;
    1831              :         app.stage_current_selection();
    1832              :         let cached_after_hunk_stage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1833              :         assert!(cached_after_hunk_stage.contains("example.txt"));
    1834              : 
    1835              :         // Reset to clean index before file-mode checks
    1836              :         run_git(&repo.path, &["reset", "HEAD", "--", "example.txt"]);
    1837              :         app.refresh_current_snapshot_from_git();
    1838              : 
    1839              :         app.focus = FocusPane::FileList;
    1840              :         app.stage_current_selection();
    1841              :         let cached_after_file_stage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1842              :         assert!(cached_after_file_stage.contains("example.txt"));
    1843              : 
    1844              :         app.stage_current_selection();
    1845              :         let cached_after_file_unstage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1846              :         assert!(cached_after_file_unstage.trim().is_empty());
    1847              :     }
    1848              : 
    1849              :     #[tokio::test]
    1850              :     #[ignore = "Known flaky hunk restage path; run explicitly during debugging"]
    1851              :     async fn hunk_toggle_can_restage_after_unstage_on_simple_file() {
    1852              :         let repo = TestRepo::new();
    1853              :         repo.write_file("example.txt", "line 1\nline 2\nline 3\n");
    1854              :         repo.commit_all("initial");
    1855              :         repo.write_file("example.txt", "line 1\nline two updated\nline 3\n");
    1856              : 
    1857              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1858              :             .await
    1859              :             .expect("failed to create app");
    1860              :         app.current_snapshot_index = 0;
    1861              :         app.current_file_index = 0;
    1862              :         app.current_hunk_index = 0;
    1863              :         app.focus = FocusPane::HunkView;
    1864              :         app.line_selection_mode = false;
    1865              : 
    1866              :         // Stage hunk
    1867              :         app.stage_current_selection();
    1868              :         let cached_after_stage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1869              :         assert!(cached_after_stage.contains("example.txt"));
    1870              : 
    1871              :         // Unstage hunk
    1872              :         app.stage_current_selection();
    1873              :         let cached_after_unstage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1874              :         assert!(cached_after_unstage.trim().is_empty());
    1875              : 
    1876              :         // Restage hunk (regression target)
    1877              :         app.stage_current_selection();
    1878              :         let cached_after_restage = run_git(&repo.path, &["diff", "--cached", "--name-only"]);
    1879              :         assert!(
    1880              :             cached_after_restage.contains("example.txt"),
    1881              :             "expected example.txt to be restaged, got:\n{}",
    1882              :             cached_after_restage
    1883              :         );
    1884              :     }
    1885              : 
    1886              :     #[tokio::test]
    1887              :     async fn ui_draw_renders_file_list_variants() {
    1888              :         let repo = TestRepo::new();
    1889              :         repo.write_file("a.txt", "one\n");
    1890              :         repo.write_file("b.txt", "two\n");
    1891              :         repo.commit_all("initial");
    1892              :         repo.write_file("a.txt", "one changed\n");
    1893              :         repo.write_file("b.txt", "two changed\n");
    1894              : 
    1895              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1896              :             .await
    1897              :             .expect("failed to create app");
    1898              :         app.current_snapshot_index = 0;
    1899              :         app.current_file_index = 0;
    1900              :         app.current_hunk_index = 0;
    1901              :         app.mode = Mode::View;
    1902              :         app.show_help = true;
    1903              :         app.focus = FocusPane::FileList;
    1904              : 
    1905              :         let backend = TestBackend::new(120, 35);
    1906              :         let mut terminal = Terminal::new(backend).expect("failed to create terminal");
    1907              :         terminal
    1908              :             .draw(|frame| {
    1909              :                 UI::new(&app).draw(frame);
    1910              :             })
    1911              :             .expect("failed to draw ui");
    1912              :         let rendered = render_buffer_to_string(&terminal);
    1913              :         assert!(rendered.contains("Files"));
    1914              :         assert!(rendered.contains("Help"));
    1915              : 
    1916              :         app.show_filenames_only = true;
    1917              :         app.wrap_lines = true;
    1918              :         app.line_selection_mode = true;
    1919              :         app.select_first_change_line();
    1920              :         terminal
    1921              :             .draw(|frame| {
    1922              :                 UI::new(&app).draw(frame);
    1923              :             })
    1924              :             .expect("failed to draw ui");
    1925              :         let rendered = render_buffer_to_string(&terminal);
    1926              :         assert!(rendered.contains("File Info"));
    1927              :     }
    1928              : 
    1929              :     #[tokio::test]
    1930              :     async fn navigation_handles_empty_and_boundary_states() {
    1931              :         let repo = TestRepo::new();
    1932              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    1933              :             .await
    1934              :             .expect("failed to create app");
    1935              : 
    1936              :         app.snapshots.clear();
    1937              :         app.advance_hunk();
    1938              :         app.previous_hunk();
    1939              :         app.next_file();
    1940              :         app.previous_file();
    1941              : 
    1942              :         app.snapshots = vec![DiffSnapshot {
    1943              :             timestamp: SystemTime::now(),
    1944              :             files: vec![],
    1945              :         }];
    1946              :         app.current_snapshot_index = 0;
    1947              :         app.advance_hunk();
    1948              :         app.previous_hunk();
    1949              :         app.next_file();
    1950              :         app.previous_file();
    1951              : 
    1952              :         let mut snapshot = sample_snapshot();
    1953              :         snapshot.files[0].hunks[0].lines = vec![
    1954              :             " context before\n".to_string(),
    1955              :             "-old\n".to_string(),
    1956              :             "+new\n".to_string(),
    1957              :             " context after\n".to_string(),
    1958              :         ];
    1959              :         app.snapshots = vec![snapshot];
    1960              :         app.current_snapshot_index = 0;
    1961              :         app.current_file_index = 0;
    1962              :         app.current_hunk_index = 0;
    1963              : 
    1964              :         app.selected_line_index = 0;
    1965              :         app.next_change_line();
    1966              :         assert_eq!(app.selected_line_index, 1);
    1967              :         app.selected_line_index = 2;
    1968              :         app.next_change_line();
    1969              :         assert_eq!(app.selected_line_index, 2);
    1970              : 
    1971              :         app.selected_line_index = 0;
    1972              :         app.previous_change_line();
    1973              :         assert_eq!(app.selected_line_index, 2);
    1974              :         app.selected_line_index = 1;
    1975              :         app.previous_change_line();
    1976              :         assert_eq!(app.selected_line_index, 1);
    1977              : 
    1978              :         app.snapshots[0].files[0].hunks[0].lines = vec![" context only\n".to_string()];
    1979              :         app.selected_line_index = 9;
    1980              :         app.select_first_change_line();
    1981              :         assert_eq!(app.selected_line_index, 0);
    1982              : 
    1983              :         app.focus = FocusPane::HelpSidebar;
    1984              :         app.stage_current_selection();
    1985              : 
    1986              :         app.current_file_index = 99;
    1987              :         assert_eq!(app.current_hunk_content_height(), 0);
    1988              : 
    1989              :         app.snapshots[0].files[0].hunks[0].lines = vec![
    1990              :             "-old\n".to_string(),
    1991              :             "+new\n".to_string(),
    1992              :             " context after 1\n".to_string(),
    1993              :             " context after 2\n".to_string(),
    1994              :             " context after 3\n".to_string(),
    1995              :             " context after 4\n".to_string(),
    1996              :             " context after 5\n".to_string(),
    1997              :             " context after 6\n".to_string(),
    1998              :         ];
    1999              :         app.current_file_index = 0;
    2000              :         app.current_hunk_index = 0;
    2001              :         app.scroll_offset = 99;
    2002              :         app.clamp_scroll_offset(5);
    2003              :         assert!(app.scroll_offset > 0);
    2004              : 
    2005              :         app.extended_help_scroll_offset = 20;
    2006              :         app.clamp_extended_help_scroll_offset(200);
    2007              :         assert_eq!(app.extended_help_scroll_offset, 0);
    2008              :     }
    2009              : 
    2010              :     #[tokio::test]
    2011              :     async fn ui_draw_renders_mini_compact_help_and_empty_states() {
    2012              :         let repo = TestRepo::new();
    2013              :         repo.write_file("example.rs", "fn main() {\n    println!(\"one\");\n}\n");
    2014              :         repo.commit_all("initial");
    2015              :         repo.write_file(
    2016              :             "example.rs",
    2017              :             "fn main() {\n    println!(\"two\");\n    println!(\"three\");\n}\n",
    2018              :         );
    2019              : 
    2020              :         let mut app = App::new(repo.path.to_str().expect("path should be utf-8"))
    2021              :             .await
    2022              :             .expect("failed to create app");
    2023              : 
    2024              :         let mut terminal =
    2025              :             Terminal::new(TestBackend::new(36, 20)).expect("failed to create terminal");
    2026              :         terminal
    2027              :             .draw(|frame| {
    2028              :                 UI::new(&app).draw(frame);
    2029              :             })
    2030              :             .expect("failed to draw mini layout");
    2031              :         let rendered = render_buffer_to_string(&terminal);
    2032              :         assert!(rendered.contains("Hunky"));
    2033              : 
    2034              :         app.mode = Mode::Streaming(StreamingType::Auto(StreamSpeed::Medium));
    2035              :         terminal = Terminal::new(TestBackend::new(52, 20)).expect("failed to create terminal");
    2036              :         terminal
    2037              :             .draw(|frame| {
    2038              :                 UI::new(&app).draw(frame);
    2039              :             })
    2040              :             .expect("failed to draw compact layout");
    2041              :         let rendered = render_buffer_to_string(&terminal);
    2042              :         assert!(rendered.contains("STM:M"));
    2043              : 
    2044              :         app.show_help = true;
    2045              :         app.focus = FocusPane::HelpSidebar;
    2046              :         terminal = Terminal::new(TestBackend::new(90, 24)).expect("failed to create terminal");
    2047              :         terminal
    2048              :             .draw(|frame| {
    2049              :                 UI::new(&app).draw(frame);
    2050              :             })
    2051              :             .expect("failed to draw focused help");
    2052              :         let rendered = render_buffer_to_string(&terminal);
    2053              :         assert!(rendered.contains("Keys [FOCUSED]"));
    2054              : 
    2055              :         app.syntax_highlighting = false;
    2056              :         app.wrap_lines = true;
    2057              :         terminal
    2058              :             .draw(|frame| {
    2059              :                 UI::new(&app).draw(frame);
    2060              :             })
    2061              :             .expect("failed to draw non-highlighted wrapped diff");
    2062              :         let rendered = render_buffer_to_string(&terminal);
    2063              :         assert!(rendered.contains("+ "));
    2064              : 
    2065              :         app.snapshots[0].files[0].hunks.clear();
    2066              :         app.show_help = false;
    2067              :         app.focus = FocusPane::HunkView;
    2068              :         terminal
    2069              :             .draw(|frame| {
    2070              :                 UI::new(&app).draw(frame);
    2071              :             })
    2072              :             .expect("failed to draw no-hunks state");
    2073              :         let rendered = render_buffer_to_string(&terminal);
    2074              :         assert!(rendered.contains("No hunks to display yet"));
    2075              : 
    2076              :         app.snapshots.clear();
    2077              :         terminal
    2078              :             .draw(|frame| {
    2079              :                 UI::new(&app).draw(frame);
    2080              :             })
    2081              :             .expect("failed to draw no-snapshot state");
    2082              :         let rendered = render_buffer_to_string(&terminal);
    2083              :         assert!(rendered.contains("No changes"));
    2084              :     }
    2085              : }
        

Generated by: LCOV version 2.0-1