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