LCOV - code coverage report
Current view: top level - src - app.rs (source / functions) Coverage Total Hit
Test: Hunky Coverage Lines: 58.9 % 1027 605
Test Date: 2026-10-10 16:52:03 Functions: 41.7 % 156 65

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

Generated by: LCOV version 2.0-1