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