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