LCOV - code coverage report
Current view: top level - src - app.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 73.0 % 1200 876
Test Date: 2026-02-20 16:10:39 Functions: 91.5 % 94 86

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

Generated by: LCOV version 2.0-1