Skip to main content

gitkraft_tui/features/diff/
events.rs

1use crossterm::event::{KeyCode, KeyEvent};
2
3use crate::app::{App, DiffSubPane};
4
5/// Handle keys when the Diff pane is the active pane.
6pub fn handle_key(app: &mut App, key: KeyEvent) {
7    match app.tab().diff_sub_pane {
8        DiffSubPane::FileList => handle_file_list_key(app, key),
9        DiffSubPane::Content => handle_content_key(app, key),
10    }
11}
12
13fn handle_file_list_key(app: &mut App, key: KeyEvent) {
14    match key.code {
15        // Navigate files (clears selection)
16        KeyCode::Char('j') => navigate_file_down(app),
17        KeyCode::Char('k') => navigate_file_up(app),
18        // Extend range selection downward.
19        // J (Shift+j) works in every terminal because terminals uppercase the
20        // letter; Shift+j with explicit SHIFT modifier is also handled for
21        // terminals that support keyboard enhancement.
22        KeyCode::Char('J') => select_file_down(app),
23        // Extend range selection upward.
24        KeyCode::Char('K') => select_file_up(app),
25        // Enter diff content sub-pane
26        KeyCode::Enter | KeyCode::Char('l') if !app.tab().commit_files.is_empty() => {
27            app.tab_mut().diff_sub_pane = DiffSubPane::Content;
28        }
29        // File history for the currently highlighted commit file
30        KeyCode::Char('H') => {
31            let path = app
32                .tab()
33                .commit_files
34                .get(app.tab().commit_diff_file_index)
35                .map(|f| f.display_path().to_string());
36            if let Some(p) = path {
37                app.open_file_history(p);
38            }
39        }
40        // Blame for the currently highlighted commit file
41        KeyCode::Char('B') => {
42            let path = app
43                .tab()
44                .commit_files
45                .get(app.tab().commit_diff_file_index)
46                .map(|f| f.display_path().to_string());
47            if let Some(p) = path {
48                app.open_file_blame(p);
49            }
50        }
51        // Open the focused file (or all selected files) in the configured editor.
52        KeyCode::Char('e') => {
53            app.open_commit_files_in_editor();
54        }
55        _ => {}
56    }
57}
58
59fn handle_content_key(app: &mut App, key: KeyEvent) {
60    match key.code {
61        // Scroll down
62        KeyCode::Char('j') => {
63            app.tab_mut().diff_scroll = app.tab().diff_scroll.saturating_add(1);
64        }
65        // Scroll up
66        KeyCode::Char('k') => {
67            app.tab_mut().diff_scroll = app.tab().diff_scroll.saturating_sub(1);
68        }
69        // Scroll to top
70        KeyCode::Char('g') => {
71            app.tab_mut().diff_scroll = 0;
72        }
73        // Scroll to bottom (Shift+G)
74        KeyCode::Char('G') => {
75            let total_lines = app
76                .tab()
77                .selected_diff
78                .as_ref()
79                .map(|d| d.hunks.iter().map(|h| h.lines.len() as u16).sum::<u16>())
80                .unwrap_or(0);
81            app.tab_mut().diff_scroll = total_lines.saturating_sub(1);
82        }
83        // Page down
84        KeyCode::PageDown | KeyCode::Char('d') => {
85            app.tab_mut().diff_scroll = app.tab().diff_scroll.saturating_add(20);
86        }
87        // Page up
88        KeyCode::PageUp | KeyCode::Char('u') => {
89            app.tab_mut().diff_scroll = app.tab().diff_scroll.saturating_sub(20);
90        }
91        // Previous file in commit diff (stay in content sub-pane)
92        KeyCode::Char('h') => {
93            navigate_file_up(app);
94            app.tab_mut().diff_sub_pane = DiffSubPane::Content;
95        }
96        // Next file in commit diff (stay in content sub-pane)
97        KeyCode::Char('l') => {
98            navigate_file_down(app);
99            app.tab_mut().diff_sub_pane = DiffSubPane::Content;
100        }
101        // Return to file list
102        KeyCode::Esc => {
103            app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
104        }
105        // Open the focused file (or all selected files) in the configured editor.
106        // Works from Content sub-pane too so the user doesn't need to switch back.
107        KeyCode::Char('e') => {
108            app.open_commit_files_in_editor();
109        }
110        _ => {}
111    }
112}
113
114/// Navigate to the next file without multi-select (clears selection).
115pub fn navigate_file_down(app: &mut App) {
116    if app.tab().commit_files.is_empty() {
117        return;
118    }
119    let len = app.tab().commit_files.len();
120    let current = app.tab().commit_diff_file_index;
121    let new_idx = (current + 1) % len;
122    apply_single_file_navigation(app, new_idx);
123}
124
125/// Navigate to the previous file without multi-select (clears selection).
126pub fn navigate_file_up(app: &mut App) {
127    if app.tab().commit_files.is_empty() {
128        return;
129    }
130    let len = app.tab().commit_files.len();
131    let current = app.tab().commit_diff_file_index;
132    let new_idx = if current == 0 { len - 1 } else { current - 1 };
133    apply_single_file_navigation(app, new_idx);
134}
135
136/// Shared body for Shift+Up/Down file-range selection.
137///
138/// Uses `anchor_file_index` as the fixed end of the range and moves the
139/// cursor to the result of `next_idx_fn`.  If already at a boundary the
140/// cursor stays but the anchor-to-current range is still applied so the
141/// user always gets visible feedback on the first key press.
142///
143/// The selection is **replaced** (not accumulated) with
144/// `ascending_range(anchor, new_cursor)` on every call, matching standard
145/// range-selection behaviour (Shift+Up shrinks what Shift+Down expanded).
146fn extend_file_selection(app: &mut App, next_idx_fn: impl Fn(usize, usize) -> Option<usize>) {
147    if app.tab().commit_files.is_empty() {
148        return;
149    }
150    let len = app.tab().commit_files.len();
151    let current = app.tab().commit_diff_file_index;
152    let anchor = app.tab().anchor_file_index.unwrap_or(current);
153    // At boundary: stay on current item but still build the range so the
154    // first press always produces visible feedback.
155    let new_idx = next_idx_fn(current, len).unwrap_or(current);
156
157    // Replace the selection with the full anchor-to-cursor range.
158    let range: std::collections::HashSet<usize> = gitkraft_core::ascending_range(anchor, new_idx)
159        .into_iter()
160        .collect();
161    app.tab_mut().selected_file_indices = range;
162    app.tab_mut().commit_diff_file_index = new_idx;
163
164    let count = app.tab().selected_file_indices.len();
165    app.tab_mut().status_message = Some(format!("{count} file(s) selected"));
166
167    // Load diffs for ALL files in the new selection range, not just the
168    // focused one.  `load_diff_for_file_index` is a no-op for files already
169    // in `commit_diffs`, so iterating the whole set is safe and cheap.
170    // This ensures the multi-file concatenated view renders immediately
171    // instead of showing "Loading…" for every non-focused file.
172    let all_selected: Vec<usize> = app.tab().selected_file_indices.iter().copied().collect();
173    for idx in all_selected {
174        app.load_diff_for_file_index(idx);
175    }
176
177    // Also reset the scroll so the concatenated view starts at the top.
178    app.tab_mut().diff_scroll = 0;
179}
180
181/// Extend the multi-selection downward (Shift+Down in file list).
182pub fn select_file_down(app: &mut App) {
183    extend_file_selection(
184        app,
185        |cur, len| {
186            if cur + 1 >= len {
187                None
188            } else {
189                Some(cur + 1)
190            }
191        },
192    );
193}
194
195/// Extend the multi-selection upward (Shift+Up in file list).
196pub fn select_file_up(app: &mut App) {
197    extend_file_selection(app, |cur, _| if cur == 0 { None } else { Some(cur - 1) });
198}
199
200/// Handle keys when the file-history overlay is active.
201pub fn handle_file_history_key(app: &mut App, key: KeyEvent) {
202    let len = app.tab().file_history_commits.len();
203    match key.code {
204        KeyCode::Char('j') | KeyCode::Down if len > 0 => {
205            let cur = app.tab().file_history_cursor;
206            app.tab_mut().file_history_cursor = (cur + 1).min(len - 1);
207        }
208        KeyCode::Char('k') | KeyCode::Up => {
209            let cur = app.tab().file_history_cursor;
210            app.tab_mut().file_history_cursor = cur.saturating_sub(1);
211        }
212        KeyCode::Char('g') => {
213            app.tab_mut().file_history_cursor = 0;
214        }
215        KeyCode::Char('G') if len > 0 => {
216            app.tab_mut().file_history_cursor = len - 1;
217        }
218        KeyCode::Enter => {
219            // Jump to the selected commit and close the overlay
220            let cursor = app.tab().file_history_cursor;
221            if let Some(commit) = app.tab().file_history_commits.get(cursor).cloned() {
222                let oid = commit.oid.clone();
223                let tab = app.tab_mut();
224                tab.file_history_path = None;
225                tab.file_history_commits.clear();
226                tab.selected_commit_oid = Some(oid);
227            }
228            app.load_commit_diff_by_oid();
229        }
230        KeyCode::Esc | KeyCode::Char('q') => {
231            let tab = app.tab_mut();
232            tab.file_history_path = None;
233            tab.file_history_commits.clear();
234            tab.file_history_cursor = 0;
235            tab.status_message = Some("File history closed".into());
236        }
237        _ => {}
238    }
239}
240
241/// Handle keys when the blame overlay is active.
242pub fn handle_blame_key(app: &mut App, key: KeyEvent) {
243    match key.code {
244        KeyCode::Char('j') | KeyCode::Down => {
245            app.tab_mut().blame_scroll = app.tab().blame_scroll.saturating_add(1);
246        }
247        KeyCode::Char('k') | KeyCode::Up => {
248            app.tab_mut().blame_scroll = app.tab().blame_scroll.saturating_sub(1);
249        }
250        KeyCode::Char('d') => {
251            app.tab_mut().blame_scroll = app.tab().blame_scroll.saturating_add(10);
252        }
253        KeyCode::Char('u') => {
254            app.tab_mut().blame_scroll = app.tab().blame_scroll.saturating_sub(10);
255        }
256        KeyCode::Char('g') => {
257            app.tab_mut().blame_scroll = 0;
258        }
259        KeyCode::Char('G') => {
260            let len = app.tab().blame_lines.len() as u16;
261            app.tab_mut().blame_scroll = len.saturating_sub(1);
262        }
263        KeyCode::Esc | KeyCode::Char('q') => {
264            let tab = app.tab_mut();
265            tab.blame_path = None;
266            tab.blame_lines.clear();
267            tab.blame_scroll = 0;
268            tab.status_message = Some("Blame closed".into());
269        }
270        _ => {}
271    }
272}
273
274// ── Internal helpers ──────────────────────────────────────────────────────────
275
276/// Move to a single file, clearing multi-selection, and trigger a diff load.
277fn apply_single_file_navigation(app: &mut App, new_idx: usize) {
278    // Plain navigation fixes the anchor for any subsequent Shift range selection.
279    app.tab_mut().anchor_file_index = Some(new_idx);
280    app.tab_mut().commit_diff_file_index = new_idx;
281    app.tab_mut().selected_file_indices.clear();
282    app.tab_mut().selected_file_indices.insert(new_idx);
283    app.tab_mut().diff_scroll = 0;
284    if let Some(cached) = app.tab().commit_diffs.get(&new_idx).cloned() {
285        app.tab_mut().selected_diff = Some(cached);
286    } else {
287        let file_path = app.tab().commit_files[new_idx].display_path().to_string();
288        app.load_single_file_diff(new_idx, file_path);
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295    use crate::app::{App, DiffSubPane};
296    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
297
298    // ── handle_file_history_key ───────────────────────────────────────────
299
300    fn make_commit_info(summary: &str) -> gitkraft_core::CommitInfo {
301        gitkraft_core::CommitInfo {
302            oid: "abc1234567890".to_string(),
303            short_oid: "abc1234".to_string(),
304            summary: summary.to_string(),
305            message: summary.to_string(),
306            author_name: "author".to_string(),
307            author_email: "a@b.com".to_string(),
308            time: Default::default(),
309            parent_ids: vec![],
310        }
311    }
312
313    fn app_with_history() -> App {
314        let mut app = App::new();
315        app.tab_mut().file_history_path = Some("src/main.rs".to_string());
316        app.tab_mut().file_history_commits = vec![
317            make_commit_info("commit 0"),
318            make_commit_info("commit 1"),
319            make_commit_info("commit 2"),
320        ];
321        app.tab_mut().file_history_cursor = 0;
322        app
323    }
324
325    #[test]
326    fn file_history_j_moves_cursor_down() {
327        let mut app = app_with_history();
328        handle_file_history_key(&mut app, key(KeyCode::Char('j')));
329        assert_eq!(app.tab().file_history_cursor, 1);
330    }
331
332    #[test]
333    fn file_history_k_moves_cursor_up() {
334        let mut app = app_with_history();
335        app.tab_mut().file_history_cursor = 2;
336        handle_file_history_key(&mut app, key(KeyCode::Char('k')));
337        assert_eq!(app.tab().file_history_cursor, 1);
338    }
339
340    #[test]
341    fn file_history_cursor_clamps_at_bottom() {
342        let mut app = app_with_history();
343        app.tab_mut().file_history_cursor = 2;
344        handle_file_history_key(&mut app, key(KeyCode::Char('j')));
345        assert_eq!(app.tab().file_history_cursor, 2);
346    }
347
348    #[test]
349    fn file_history_cursor_clamps_at_top() {
350        let mut app = app_with_history();
351        handle_file_history_key(&mut app, key(KeyCode::Char('k')));
352        assert_eq!(app.tab().file_history_cursor, 0);
353    }
354
355    #[test]
356    fn file_history_esc_closes_overlay() {
357        let mut app = app_with_history();
358        handle_file_history_key(&mut app, key(KeyCode::Esc));
359        assert!(app.tab().file_history_path.is_none());
360        assert!(app.tab().file_history_commits.is_empty());
361    }
362
363    // ── handle_blame_key ──────────────────────────────────────────────────
364
365    fn app_with_blame() -> App {
366        let mut app = App::new();
367        app.tab_mut().blame_path = Some("src/main.rs".to_string());
368        app.tab_mut().blame_scroll = 5;
369        app
370    }
371
372    #[test]
373    fn blame_j_scrolls_down() {
374        let mut app = app_with_blame();
375        handle_blame_key(&mut app, key(KeyCode::Char('j')));
376        assert_eq!(app.tab().blame_scroll, 6);
377    }
378
379    #[test]
380    fn blame_k_scrolls_up() {
381        let mut app = app_with_blame();
382        handle_blame_key(&mut app, key(KeyCode::Char('k')));
383        assert_eq!(app.tab().blame_scroll, 4);
384    }
385
386    #[test]
387    fn blame_esc_closes_overlay() {
388        let mut app = app_with_blame();
389        handle_blame_key(&mut app, key(KeyCode::Esc));
390        assert!(app.tab().blame_path.is_none());
391        assert!(app.tab().blame_lines.is_empty());
392        assert_eq!(app.tab().blame_scroll, 0);
393    }
394
395    fn key(code: KeyCode) -> KeyEvent {
396        KeyEvent::new(code, KeyModifiers::NONE)
397    }
398
399    fn make_commit_files(count: usize) -> Vec<gitkraft_core::DiffFileEntry> {
400        (0..count)
401            .map(|i| gitkraft_core::DiffFileEntry {
402                old_file: String::new(),
403                new_file: format!("file{i}.rs"),
404                status: gitkraft_core::FileStatus::Modified,
405            })
406            .collect()
407    }
408
409    // ── navigate_file_down ───────────────────────────────────────────────────
410
411    #[test]
412    fn navigate_file_down_noop_on_empty_files() {
413        let mut app = App::new();
414        navigate_file_down(&mut app);
415        assert_eq!(app.tab().commit_diff_file_index, 0);
416        assert!(app.tab().selected_file_indices.is_empty());
417    }
418
419    #[test]
420    fn navigate_file_down_advances_index() {
421        let mut app = App::new();
422        app.tab_mut().commit_files = make_commit_files(3);
423        app.tab_mut().commit_diff_file_index = 0;
424        navigate_file_down(&mut app);
425        assert_eq!(app.tab().commit_diff_file_index, 1);
426    }
427
428    #[test]
429    fn navigate_file_down_wraps_to_first() {
430        let mut app = App::new();
431        app.tab_mut().commit_files = make_commit_files(3);
432        app.tab_mut().commit_diff_file_index = 2;
433        navigate_file_down(&mut app);
434        assert_eq!(app.tab().commit_diff_file_index, 0);
435    }
436
437    #[test]
438    fn navigate_file_down_clears_multi_selection() {
439        let mut app = App::new();
440        app.tab_mut().commit_files = make_commit_files(3);
441        app.tab_mut().selected_file_indices.insert(0);
442        app.tab_mut().selected_file_indices.insert(1);
443        navigate_file_down(&mut app);
444        assert_eq!(app.tab().selected_file_indices.len(), 1);
445        assert!(app.tab().selected_file_indices.contains(&1));
446    }
447
448    #[test]
449    fn navigate_file_down_resets_scroll() {
450        let mut app = App::new();
451        app.tab_mut().commit_files = make_commit_files(2);
452        app.tab_mut().diff_scroll = 99;
453        navigate_file_down(&mut app);
454        assert_eq!(app.tab().diff_scroll, 0);
455    }
456
457    // ── navigate_file_up ─────────────────────────────────────────────────────
458
459    #[test]
460    fn navigate_file_up_noop_on_empty_files() {
461        let mut app = App::new();
462        navigate_file_up(&mut app);
463        assert_eq!(app.tab().commit_diff_file_index, 0);
464        assert!(app.tab().selected_file_indices.is_empty());
465    }
466
467    #[test]
468    fn navigate_file_up_decreases_index() {
469        let mut app = App::new();
470        app.tab_mut().commit_files = make_commit_files(3);
471        app.tab_mut().commit_diff_file_index = 2;
472        navigate_file_up(&mut app);
473        assert_eq!(app.tab().commit_diff_file_index, 1);
474    }
475
476    #[test]
477    fn navigate_file_up_wraps_to_last() {
478        let mut app = App::new();
479        app.tab_mut().commit_files = make_commit_files(3);
480        app.tab_mut().commit_diff_file_index = 0;
481        navigate_file_up(&mut app);
482        assert_eq!(app.tab().commit_diff_file_index, 2);
483    }
484
485    #[test]
486    fn navigate_file_up_clears_multi_selection() {
487        let mut app = App::new();
488        app.tab_mut().commit_files = make_commit_files(3);
489        app.tab_mut().commit_diff_file_index = 2;
490        app.tab_mut().selected_file_indices.insert(1);
491        app.tab_mut().selected_file_indices.insert(2);
492        navigate_file_up(&mut app);
493        assert_eq!(app.tab().selected_file_indices.len(), 1);
494        assert!(app.tab().selected_file_indices.contains(&1));
495    }
496
497    // ── select_file_down ─────────────────────────────────────────────────────
498
499    #[test]
500    fn select_file_down_noop_on_empty_files() {
501        let mut app = App::new();
502        select_file_down(&mut app);
503        assert!(app.tab().selected_file_indices.is_empty());
504        assert_eq!(app.tab().commit_diff_file_index, 0);
505    }
506
507    #[test]
508    fn select_file_down_adds_both_indices_to_selection() {
509        let mut app = App::new();
510        app.tab_mut().commit_files = make_commit_files(3);
511        app.tab_mut().commit_diff_file_index = 0;
512        select_file_down(&mut app);
513        assert!(app.tab().selected_file_indices.contains(&0));
514        assert!(app.tab().selected_file_indices.contains(&1));
515        assert_eq!(app.tab().commit_diff_file_index, 1);
516    }
517
518    #[test]
519    fn select_file_down_stops_at_last_file() {
520        let mut app = App::new();
521        app.tab_mut().commit_files = make_commit_files(3);
522        app.tab_mut().commit_diff_file_index = 2;
523        app.tab_mut().anchor_file_index = Some(2);
524        select_file_down(&mut app);
525        // cursor must not move past the end
526        assert_eq!(app.tab().commit_diff_file_index, 2);
527        // but the anchor-to-current range ({2}) is still applied
528        assert!(app.tab().selected_file_indices.contains(&2));
529    }
530
531    #[test]
532    fn select_file_down_extends_existing_selection() {
533        let mut app = App::new();
534        app.tab_mut().commit_files = make_commit_files(4);
535        // Anchor at 0; cursor at 1 after a prior Shift+Down.
536        app.tab_mut().anchor_file_index = Some(0);
537        app.tab_mut().commit_diff_file_index = 1;
538        select_file_down(&mut app);
539        // Range is replaced with ascending_range(anchor=0, new_cursor=2) = {0,1,2}
540        assert!(app.tab().selected_file_indices.contains(&0));
541        assert!(app.tab().selected_file_indices.contains(&1));
542        assert!(app.tab().selected_file_indices.contains(&2));
543        assert_eq!(app.tab().commit_diff_file_index, 2);
544    }
545
546    // ── select_file_up ───────────────────────────────────────────────────────
547
548    #[test]
549    fn select_file_up_noop_on_empty_files() {
550        let mut app = App::new();
551        select_file_up(&mut app);
552        assert!(app.tab().selected_file_indices.is_empty());
553        assert_eq!(app.tab().commit_diff_file_index, 0);
554    }
555
556    #[test]
557    fn select_file_up_adds_both_indices_to_selection() {
558        let mut app = App::new();
559        app.tab_mut().commit_files = make_commit_files(3);
560        app.tab_mut().commit_diff_file_index = 2;
561        select_file_up(&mut app);
562        assert!(app.tab().selected_file_indices.contains(&2));
563        assert!(app.tab().selected_file_indices.contains(&1));
564        assert_eq!(app.tab().commit_diff_file_index, 1);
565    }
566
567    #[test]
568    fn select_file_up_stops_at_first_file() {
569        let mut app = App::new();
570        app.tab_mut().commit_files = make_commit_files(3);
571        app.tab_mut().commit_diff_file_index = 0;
572        app.tab_mut().anchor_file_index = Some(0);
573        select_file_up(&mut app);
574        // cursor must not move before the start
575        assert_eq!(app.tab().commit_diff_file_index, 0);
576        // but the anchor-to-current range ({0}) is still applied
577        assert!(app.tab().selected_file_indices.contains(&0));
578    }
579
580    #[test]
581    fn select_file_up_extends_existing_selection() {
582        let mut app = App::new();
583        app.tab_mut().commit_files = make_commit_files(4);
584        // Anchor at 3; cursor at 2 after a prior Shift+Up.
585        app.tab_mut().anchor_file_index = Some(3);
586        app.tab_mut().commit_diff_file_index = 2;
587        select_file_up(&mut app);
588        // Range is replaced with ascending_range(anchor=3, new_cursor=1) = {1,2,3}
589        assert!(app.tab().selected_file_indices.contains(&1));
590        assert!(app.tab().selected_file_indices.contains(&2));
591        assert!(app.tab().selected_file_indices.contains(&3));
592        assert_eq!(app.tab().commit_diff_file_index, 1);
593    }
594
595    // ── handle_key — FileList sub-pane ───────────────────────────────────────
596
597    #[test]
598    fn j_in_file_list_navigates_down() {
599        let mut app = App::new();
600        app.tab_mut().commit_files = make_commit_files(3);
601        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
602        handle_key(&mut app, key(KeyCode::Char('j')));
603        assert_eq!(app.tab().commit_diff_file_index, 1);
604    }
605
606    #[test]
607    fn k_in_file_list_navigates_up() {
608        let mut app = App::new();
609        app.tab_mut().commit_files = make_commit_files(3);
610        app.tab_mut().commit_diff_file_index = 2;
611        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
612        handle_key(&mut app, key(KeyCode::Char('k')));
613        assert_eq!(app.tab().commit_diff_file_index, 1);
614    }
615
616    #[test]
617    fn enter_in_file_list_enters_content_sub_pane() {
618        let mut app = App::new();
619        app.tab_mut().commit_files = make_commit_files(2);
620        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
621        handle_key(&mut app, key(KeyCode::Enter));
622        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::Content);
623    }
624
625    #[test]
626    fn l_in_file_list_enters_content_sub_pane() {
627        let mut app = App::new();
628        app.tab_mut().commit_files = make_commit_files(2);
629        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
630        handle_key(&mut app, key(KeyCode::Char('l')));
631        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::Content);
632    }
633
634    #[test]
635    fn enter_in_file_list_without_files_stays_in_file_list() {
636        let mut app = App::new();
637        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
638        handle_key(&mut app, key(KeyCode::Enter));
639        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::FileList);
640    }
641
642    // ── handle_key — Content sub-pane ────────────────────────────────────────
643
644    #[test]
645    fn j_in_content_scrolls_down() {
646        let mut app = App::new();
647        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
648        app.tab_mut().diff_scroll = 3;
649        handle_key(&mut app, key(KeyCode::Char('j')));
650        assert_eq!(app.tab().diff_scroll, 4);
651    }
652
653    #[test]
654    fn k_in_content_scrolls_up() {
655        let mut app = App::new();
656        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
657        app.tab_mut().diff_scroll = 5;
658        handle_key(&mut app, key(KeyCode::Char('k')));
659        assert_eq!(app.tab().diff_scroll, 4);
660    }
661
662    #[test]
663    fn k_in_content_does_not_underflow() {
664        let mut app = App::new();
665        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
666        app.tab_mut().diff_scroll = 0;
667        handle_key(&mut app, key(KeyCode::Char('k')));
668        assert_eq!(app.tab().diff_scroll, 0);
669    }
670
671    #[test]
672    fn g_in_content_scrolls_to_top() {
673        let mut app = App::new();
674        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
675        app.tab_mut().diff_scroll = 42;
676        handle_key(&mut app, key(KeyCode::Char('g')));
677        assert_eq!(app.tab().diff_scroll, 0);
678    }
679
680    #[test]
681    fn d_in_content_pages_down() {
682        let mut app = App::new();
683        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
684        app.tab_mut().diff_scroll = 0;
685        handle_key(&mut app, key(KeyCode::Char('d')));
686        assert_eq!(app.tab().diff_scroll, 20);
687    }
688
689    #[test]
690    fn u_in_content_pages_up() {
691        let mut app = App::new();
692        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
693        app.tab_mut().diff_scroll = 25;
694        handle_key(&mut app, key(KeyCode::Char('u')));
695        assert_eq!(app.tab().diff_scroll, 5);
696    }
697
698    #[test]
699    fn esc_in_content_returns_to_file_list() {
700        let mut app = App::new();
701        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
702        handle_key(&mut app, key(KeyCode::Esc));
703        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::FileList);
704    }
705
706    #[test]
707    fn h_in_content_navigates_file_up_and_stays_in_content() {
708        let mut app = App::new();
709        app.tab_mut().commit_files = make_commit_files(3);
710        app.tab_mut().commit_diff_file_index = 2;
711        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
712        handle_key(&mut app, key(KeyCode::Char('h')));
713        assert_eq!(app.tab().commit_diff_file_index, 1);
714        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::Content);
715    }
716
717    #[test]
718    fn l_in_content_navigates_file_down_and_stays_in_content() {
719        let mut app = App::new();
720        app.tab_mut().commit_files = make_commit_files(3);
721        app.tab_mut().commit_diff_file_index = 0;
722        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
723        handle_key(&mut app, key(KeyCode::Char('l')));
724        assert_eq!(app.tab().commit_diff_file_index, 1);
725        assert_eq!(app.tab().diff_sub_pane, DiffSubPane::Content);
726    }
727
728    #[test]
729    fn e_in_content_sub_pane_also_opens_file() {
730        let mut app = App::new();
731        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
732        app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
733            old_file: String::new(),
734            new_file: "src/lib.rs".to_string(),
735            status: gitkraft_core::FileStatus::Modified,
736        }];
737        // User has pressed → to enter the Content sub-pane
738        app.tab_mut().diff_sub_pane = DiffSubPane::Content;
739        app.editor = gitkraft_core::Editor::Helix;
740
741        handle_key(&mut app, key(KeyCode::Char('e')));
742
743        assert!(
744            app.pending_editor_open.is_some(),
745            "e in Content sub-pane must also queue a terminal editor open"
746        );
747    }
748
749    #[test]
750    fn e_in_file_list_queues_editor_open() {
751        let mut app = App::new();
752        app.tab_mut().repo_path = Some(std::path::PathBuf::from("/tmp/repo"));
753        app.tab_mut().commit_files = vec![gitkraft_core::DiffFileEntry {
754            old_file: String::new(),
755            new_file: "src/lib.rs".to_string(),
756            status: gitkraft_core::FileStatus::Modified,
757        }];
758        app.tab_mut().diff_sub_pane = DiffSubPane::FileList;
759        app.editor = gitkraft_core::Editor::Helix; // terminal editor → queued
760
761        handle_key(&mut app, key(KeyCode::Char('e')));
762
763        assert!(
764            app.pending_editor_open.is_some(),
765            "e in file list must queue a terminal editor open"
766        );
767    }
768}