Skip to main content

wisp/components/
git_diff_view.rs

1use crate::components::app::git_diff_mode::{QueuedComment, format_review_prompt};
2use crate::components::app::{GitDiffLoadState, GitDiffViewState, PatchFocus};
3use crate::components::file_list_renderer::{render_file_list_cell, render_file_tree_cell};
4use crate::components::file_tree::FileTree;
5pub use crate::components::patch_renderer::build_patch_lines;
6use crate::git_diff::{FileDiff, FileStatus, PatchLineKind};
7use tui::{
8    Component, Event, Frame, KeyCode, Line, MouseEvent, MouseEventKind, Style, ViewContext,
9    truncate_text,
10};
11
12pub enum GitDiffViewMessage {
13    Close,
14    Refresh,
15    SubmitPrompt(String),
16}
17
18pub struct GitDiffView<'a> {
19    pub state: &'a mut GitDiffViewState,
20}
21
22impl GitDiffView<'_> {
23    pub fn render_from_state(state: &GitDiffViewState, context: &ViewContext) -> Vec<Line> {
24        render_git_diff_state(state, context)
25    }
26}
27
28pub(crate) fn diff_layout(total_width: usize, delta: i16) -> (usize, usize) {
29    let base = (total_width / 3)
30        .clamp(20, 28)
31        .min(total_width.saturating_sub(4));
32    #[allow(
33        clippy::cast_possible_wrap,
34        clippy::cast_possible_truncation,
35        clippy::cast_sign_loss
36    )]
37    let left = (base as i16 + delta).clamp(12, (total_width / 2) as i16) as usize;
38    let right = total_width.saturating_sub(left + 1);
39    (left, right)
40}
41
42pub(crate) fn should_use_split_patch(total_width: usize, delta: i16, file: &FileDiff) -> bool {
43    let (_left_width, right_width) = diff_layout(total_width, delta);
44    let has_removals = file
45        .hunks
46        .iter()
47        .flat_map(|h| &h.lines)
48        .any(|line| line.kind == PatchLineKind::Removed);
49
50    right_width >= 80 && has_removals
51}
52
53fn render_git_diff_state(state: &GitDiffViewState, context: &ViewContext) -> Vec<Line> {
54    let theme = &context.theme;
55    let total_width = context.size.width as usize;
56    if total_width < 10 {
57        return vec![Line::new("Too narrow")];
58    }
59
60    let (left_width, right_width) = diff_layout(total_width, state.sidebar_width_delta);
61    let available_height = context.size.height as usize;
62
63    match &state.load_state {
64        GitDiffLoadState::Loading => {
65            render_message_layout("Loading...", left_width, available_height, theme)
66        }
67        GitDiffLoadState::Empty => render_message_layout(
68            "No changes in working tree relative to HEAD",
69            left_width,
70            available_height,
71            theme,
72        ),
73        GitDiffLoadState::Error { message } => {
74            let msg = format!("Git diff unavailable: {message}");
75            render_message_layout(&msg, left_width, available_height, theme)
76        }
77        GitDiffLoadState::Ready(doc) if doc.files.is_empty() => render_message_layout(
78            "No changes in working tree relative to HEAD",
79            left_width,
80            available_height,
81            theme,
82        ),
83        GitDiffLoadState::Ready(doc) => render_ready(
84            &doc.files,
85            state,
86            left_width,
87            right_width,
88            available_height,
89            context,
90        ),
91    }
92}
93
94impl Component for GitDiffView<'_> {
95    type Message = GitDiffViewMessage;
96
97    async fn on_event(&mut self, event: &Event) -> Option<Vec<Self::Message>> {
98        match event {
99            Event::Mouse(mouse) => Some(self.on_mouse_event(*mouse)),
100            Event::Key(key) => Some(self.on_key_event(key.code)),
101            _ => None,
102        }
103    }
104
105    fn render(&mut self, context: &ViewContext) -> Frame {
106        Frame::new(render_git_diff_state(self.state, context))
107    }
108}
109
110impl GitDiffView<'_> {
111    fn on_key_event(&mut self, code: KeyCode) -> Vec<GitDiffViewMessage> {
112        if self.state.focus == PatchFocus::CommentInput {
113            return self.on_comment_input(code);
114        }
115
116        match code {
117            KeyCode::Esc => vec![GitDiffViewMessage::Close],
118            KeyCode::Char('r') => vec![GitDiffViewMessage::Refresh],
119            KeyCode::Char('h') | KeyCode::Left => {
120                if self.state.focus == PatchFocus::FileList {
121                    self.state.tree_collapse_or_parent();
122                } else {
123                    self.state.set_focus(PatchFocus::FileList);
124                }
125                vec![]
126            }
127            KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => {
128                if self.state.focus == PatchFocus::FileList {
129                    if self.state.tree_expand_or_enter() {
130                        self.state.set_focus(PatchFocus::Patch);
131                    }
132                } else {
133                    self.state.set_focus(PatchFocus::Patch);
134                }
135                vec![]
136            }
137            KeyCode::Char('j') | KeyCode::Down => {
138                self.navigate_down();
139                vec![]
140            }
141            KeyCode::Char('k') | KeyCode::Up => {
142                self.navigate_up();
143                vec![]
144            }
145            KeyCode::Char('g') => {
146                self.state.move_cursor_to_start();
147                vec![]
148            }
149            KeyCode::Char('G') => {
150                self.state.move_cursor_to_end();
151                vec![]
152            }
153            KeyCode::PageDown => {
154                self.state.move_cursor(20);
155                vec![]
156            }
157            KeyCode::PageUp => {
158                self.state.move_cursor(-20);
159                vec![]
160            }
161            KeyCode::Char('n') => {
162                self.state.jump_next_hunk();
163                vec![]
164            }
165            KeyCode::Char('p') => {
166                self.state.jump_prev_hunk();
167                vec![]
168            }
169            KeyCode::Char('c') => {
170                self.enter_comment_mode();
171                vec![]
172            }
173            KeyCode::Char('s') => self.submit_review(),
174            KeyCode::Char('u') => {
175                self.state.queued_comments.pop();
176                vec![]
177            }
178            KeyCode::Char('<') => {
179                self.state.sidebar_width_delta -= 4;
180                self.state.invalidate_patch_cache();
181                vec![]
182            }
183            KeyCode::Char('>') => {
184                self.state.sidebar_width_delta += 4;
185                self.state.invalidate_patch_cache();
186                vec![]
187            }
188            _ => vec![],
189        }
190    }
191
192    fn on_mouse_event(&mut self, mouse: MouseEvent) -> Vec<GitDiffViewMessage> {
193        match mouse.kind {
194            MouseEventKind::ScrollUp => {
195                match self.state.focus {
196                    PatchFocus::FileList => {
197                        self.state.select_relative(-1);
198                    }
199                    PatchFocus::Patch => {
200                        self.state.move_cursor(-3);
201                    }
202                    PatchFocus::CommentInput => {}
203                }
204                vec![]
205            }
206            MouseEventKind::ScrollDown => {
207                match self.state.focus {
208                    PatchFocus::FileList => {
209                        self.state.select_relative(1);
210                    }
211                    PatchFocus::Patch => {
212                        self.state.move_cursor(3);
213                    }
214                    PatchFocus::CommentInput => {}
215                }
216                vec![]
217            }
218            _ => vec![],
219        }
220    }
221
222    fn navigate_down(&mut self) {
223        match self.state.focus {
224            PatchFocus::FileList => {
225                self.state.select_relative(1);
226            }
227            PatchFocus::Patch => {
228                self.state.move_cursor(1);
229            }
230            PatchFocus::CommentInput => {}
231        }
232    }
233
234    fn navigate_up(&mut self) {
235        match self.state.focus {
236            PatchFocus::FileList => {
237                self.state.select_relative(-1);
238            }
239            PatchFocus::Patch => {
240                self.state.move_cursor(-1);
241            }
242            PatchFocus::CommentInput => {}
243        }
244    }
245
246    fn enter_comment_mode(&mut self) {
247        if self.state.focus != PatchFocus::Patch {
248            return;
249        }
250        let cursor = self.state.cursor_line;
251        if cursor >= self.state.cached_patch_line_refs.len() {
252            return;
253        }
254        if self.state.cached_patch_line_refs[cursor].is_none() {
255            return;
256        }
257        self.state.focus = PatchFocus::CommentInput;
258        self.state.comment_buffer.clear();
259        self.state.comment_cursor = 0;
260    }
261
262    fn submit_review(&mut self) -> Vec<GitDiffViewMessage> {
263        if self.state.queued_comments.is_empty() {
264            return vec![];
265        }
266        let prompt = format_review_prompt(&self.state.queued_comments);
267        vec![GitDiffViewMessage::SubmitPrompt(prompt)]
268    }
269
270    fn on_comment_input(&mut self, code: KeyCode) -> Vec<GitDiffViewMessage> {
271        match code {
272            KeyCode::Esc => {
273                self.state.focus = PatchFocus::Patch;
274                self.state.comment_buffer.clear();
275                self.state.comment_cursor = 0;
276                vec![]
277            }
278            KeyCode::Enter => {
279                if let Some(comment) = build_queued_comment(self.state) {
280                    self.state.queued_comments.push(comment);
281                }
282                self.state.focus = PatchFocus::Patch;
283                self.state.comment_buffer.clear();
284                self.state.comment_cursor = 0;
285                vec![]
286            }
287            KeyCode::Char(c) => {
288                let byte_pos =
289                    char_to_byte_pos(&self.state.comment_buffer, self.state.comment_cursor);
290                self.state.comment_buffer.insert(byte_pos, c);
291                self.state.comment_cursor += 1;
292                vec![]
293            }
294            KeyCode::Backspace => {
295                if self.state.comment_cursor > 0 {
296                    self.state.comment_cursor -= 1;
297                    let byte_pos =
298                        char_to_byte_pos(&self.state.comment_buffer, self.state.comment_cursor);
299                    self.state.comment_buffer.remove(byte_pos);
300                }
301                vec![]
302            }
303            KeyCode::Left => {
304                self.state.comment_cursor = self.state.comment_cursor.saturating_sub(1);
305                vec![]
306            }
307            KeyCode::Right => {
308                let max = self.state.comment_buffer.chars().count();
309                self.state.comment_cursor = (self.state.comment_cursor + 1).min(max);
310                vec![]
311            }
312            _ => vec![],
313        }
314    }
315}
316
317fn render_ready(
318    files: &[FileDiff],
319    state: &GitDiffViewState,
320    left_width: usize,
321    right_width: usize,
322    available_height: usize,
323    context: &ViewContext,
324) -> Vec<Line> {
325    let theme = &context.theme;
326    let selected = state.selected_file.min(files.len().saturating_sub(1));
327    let selected_file = &files[selected];
328
329    let show_comment_bar = state.focus == PatchFocus::CommentInput;
330    let content_height = if show_comment_bar {
331        available_height.saturating_sub(1)
332    } else {
333        available_height
334    };
335
336    let visible_entries = state
337        .file_tree
338        .as_ref()
339        .map(FileTree::visible_entries)
340        .unwrap_or_default();
341    let tree_selected = state
342        .file_tree
343        .as_ref()
344        .map_or(0, FileTree::selected_visible);
345    let file_scroll = state.file_list_scroll;
346
347    let file_list_len = if visible_entries.is_empty() {
348        files.len()
349    } else {
350        visible_entries.len()
351    };
352    let row_count = content_height.max(file_list_len);
353    let mut rows = Vec::with_capacity(available_height);
354
355    for i in 0..row_count {
356        let mut line = Line::default();
357
358        // Show queue indicator in last file list row
359        let queue_row = !state.queued_comments.is_empty() && i == content_height.saturating_sub(1);
360
361        if queue_row {
362            let indicator = format!(
363                " [{} comment{}] s:submit u:undo",
364                state.queued_comments.len(),
365                if state.queued_comments.len() == 1 {
366                    ""
367                } else {
368                    "s"
369                },
370            );
371            let padded = truncate_text(&indicator, left_width);
372            let pad = left_width.saturating_sub(padded.chars().count());
373            line.push_with_style(
374                padded.as_ref(),
375                Style::fg(theme.accent()).bg_color(theme.sidebar_bg()),
376            );
377            if pad > 0 {
378                line.push_with_style(
379                    " ".repeat(pad),
380                    Style::default().bg_color(theme.sidebar_bg()),
381                );
382            }
383        } else if !visible_entries.is_empty() {
384            let scrolled_i = i + file_scroll;
385            if let Some(entry) = visible_entries.get(scrolled_i) {
386                render_file_tree_cell(
387                    &mut line,
388                    entry,
389                    scrolled_i == tree_selected,
390                    left_width,
391                    theme,
392                );
393            } else {
394                line.push_with_style(
395                    " ".repeat(left_width),
396                    Style::default().bg_color(theme.sidebar_bg()),
397                );
398            }
399        } else {
400            render_file_list_cell(&mut line, files, i, selected, left_width, theme);
401        }
402
403        line.push_with_style(" ", Style::default().bg_color(theme.code_bg()));
404        render_patch_cell(
405            &mut line,
406            selected_file,
407            &state.cached_patch_lines,
408            i,
409            state.patch_scroll,
410            state.cursor_line,
411            state.focus,
412            right_width,
413            theme,
414        );
415
416        rows.push(line);
417    }
418
419    if show_comment_bar {
420        rows.push(render_comment_bar(
421            &state.comment_buffer,
422            left_width,
423            right_width,
424            theme,
425        ));
426    }
427
428    rows
429}
430
431fn render_comment_bar(
432    comment_buffer: &str,
433    left_width: usize,
434    right_width: usize,
435    theme: &tui::Theme,
436) -> Line {
437    let mut bar = Line::default();
438    let label = format!("Comment: {comment_buffer}");
439    let total = left_width + 1 + right_width;
440    let truncated = truncate_text(&label, total);
441    bar.push_with_style(
442        truncated.as_ref(),
443        Style::fg(theme.text_primary()).bg_color(theme.highlight_bg()),
444    );
445    let bar_width = truncated.chars().count();
446    if bar_width < total {
447        bar.push_with_style(
448            " ".repeat(total - bar_width),
449            Style::default().bg_color(theme.highlight_bg()),
450        );
451    }
452    bar
453}
454
455#[allow(clippy::too_many_arguments)]
456fn render_patch_cell(
457    line: &mut Line,
458    selected_file: &FileDiff,
459    patch_lines: &[Line],
460    row: usize,
461    patch_scroll: usize,
462    cursor_line: usize,
463    focus: PatchFocus,
464    right_width: usize,
465    theme: &tui::Theme,
466) {
467    if row == 0 {
468        let header_text = match selected_file.status {
469            FileStatus::Renamed => {
470                let old = selected_file.old_path.as_deref().unwrap_or("?");
471                format!("{old} -> {}", selected_file.path)
472            }
473            _ => selected_file.path.clone(),
474        };
475        let status_label = match selected_file.status {
476            FileStatus::Modified => "modified",
477            FileStatus::Added => "new file",
478            FileStatus::Deleted => "deleted",
479            FileStatus::Renamed => "renamed",
480        };
481        let full_header = format!("{header_text}  ({status_label})");
482        let truncated = truncate_text(&full_header, right_width);
483        line.push_with_style(truncated.as_ref(), Style::default().bold());
484    } else if row == 1 {
485        // spacer
486    } else if selected_file.binary {
487        if row == 2 {
488            line.push_with_style("Binary file", Style::fg(theme.text_secondary()));
489        }
490    } else {
491        let patch_row = row - 2;
492        let scrolled_row = patch_row + patch_scroll;
493        if scrolled_row < patch_lines.len() {
494            let is_cursor = matches!(focus, PatchFocus::Patch | PatchFocus::CommentInput)
495                && scrolled_row == cursor_line;
496            if is_cursor {
497                append_with_cursor_highlight(line, &patch_lines[scrolled_row], theme);
498            } else {
499                line.append_line(&patch_lines[scrolled_row]);
500            }
501        }
502    }
503}
504
505fn append_with_cursor_highlight(dest: &mut Line, source: &Line, theme: &tui::Theme) {
506    let highlight_bg = theme.highlight_bg();
507    for span in source.spans() {
508        let mut style = span.style();
509        style.bg = Some(highlight_bg);
510        dest.push_with_style(span.text(), style);
511    }
512    if source.is_empty() {
513        dest.push_with_style(" ", Style::default().bg_color(highlight_bg));
514    }
515}
516
517fn render_message_layout(
518    message: &str,
519    left_width: usize,
520    available_height: usize,
521    theme: &tui::Theme,
522) -> Vec<Line> {
523    let mut rows = Vec::with_capacity(available_height);
524    for i in 0..available_height {
525        let mut line = Line::default();
526        line.push_with_style(
527            " ".repeat(left_width),
528            Style::default().bg_color(theme.sidebar_bg()),
529        );
530        line.push_with_style(" ", Style::default().bg_color(theme.code_bg()));
531        if i == 0 {
532            line.push_with_style(message, Style::fg(theme.text_secondary()));
533        }
534        rows.push(line);
535    }
536    rows
537}
538
539fn char_to_byte_pos(s: &str, char_idx: usize) -> usize {
540    s.char_indices().nth(char_idx).map_or(s.len(), |(i, _)| i)
541}
542
543fn build_queued_comment(state: &GitDiffViewState) -> Option<QueuedComment> {
544    let cursor = state.cursor_line;
545    let patch_ref = state.cached_patch_line_refs.get(cursor)?.as_ref()?;
546
547    let GitDiffLoadState::Ready(doc) = &state.load_state else {
548        return None;
549    };
550    let file = doc.files.get(state.selected_file)?;
551    let hunk = file.hunks.get(patch_ref.hunk_index)?;
552    let patch_line = hunk.lines.get(patch_ref.line_index)?;
553
554    // Reconstruct hunk text as unified diff
555    let mut hunk_text = String::new();
556    for pl in &hunk.lines {
557        match pl.kind {
558            PatchLineKind::Context => {
559                hunk_text.push(' ');
560                hunk_text.push_str(&pl.text);
561                hunk_text.push('\n');
562            }
563            PatchLineKind::Added => {
564                hunk_text.push('+');
565                hunk_text.push_str(&pl.text);
566                hunk_text.push('\n');
567            }
568            PatchLineKind::Removed => {
569                hunk_text.push('-');
570                hunk_text.push_str(&pl.text);
571                hunk_text.push('\n');
572            }
573            PatchLineKind::HunkHeader | PatchLineKind::Meta => {
574                hunk_text.push_str(&pl.text);
575                hunk_text.push('\n');
576            }
577        }
578    }
579    // Trim trailing newline
580    if hunk_text.ends_with('\n') {
581        hunk_text.pop();
582    }
583
584    let line_number = patch_line.new_line_no.or(patch_line.old_line_no);
585
586    Some(QueuedComment {
587        file_path: file.path.clone(),
588        hunk_index: patch_ref.hunk_index,
589        hunk_text,
590        line_text: patch_line.text.clone(),
591        line_number,
592        line_kind: patch_line.kind,
593        comment: state.comment_buffer.clone(),
594    })
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use crate::git_diff::{FileDiff, FileStatus, GitDiffDocument, Hunk, PatchLine, PatchLineKind};
601    use std::path::PathBuf;
602    use tui::{KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
603
604    fn key(code: KeyCode) -> KeyEvent {
605        KeyEvent::new(code, KeyModifiers::NONE)
606    }
607
608    fn patch_line(
609        kind: PatchLineKind,
610        text: &str,
611        old: Option<usize>,
612        new: Option<usize>,
613    ) -> PatchLine {
614        PatchLine {
615            kind,
616            text: text.to_string(),
617            old_line_no: old,
618            new_line_no: new,
619        }
620    }
621
622    fn hunk(header: &str, old: (usize, usize), new: (usize, usize), lines: Vec<PatchLine>) -> Hunk {
623        Hunk {
624            header: header.to_string(),
625            old_start: old.0,
626            old_count: old.1,
627            new_start: new.0,
628            new_count: new.1,
629            lines,
630        }
631    }
632
633    fn file_diff(
634        path: &str,
635        old_path: Option<&str>,
636        status: FileStatus,
637        hunks: Vec<Hunk>,
638    ) -> FileDiff {
639        FileDiff {
640            old_path: old_path.map(str::to_string),
641            path: path.to_string(),
642            status,
643            hunks,
644            binary: false,
645        }
646    }
647
648    fn make_test_doc() -> GitDiffDocument {
649        use PatchLineKind::*;
650        let h = "@@ -1,3 +1,3 @@";
651        GitDiffDocument {
652            repo_root: PathBuf::from("/tmp/test"),
653            files: vec![
654                file_diff(
655                    "a.rs",
656                    Some("a.rs"),
657                    FileStatus::Modified,
658                    vec![hunk(
659                        h,
660                        (1, 3),
661                        (1, 3),
662                        vec![
663                            patch_line(HunkHeader, h, None, None),
664                            patch_line(Context, "fn main() {", Some(1), Some(1)),
665                            patch_line(Removed, "    old();", Some(2), None),
666                            patch_line(Added, "    new();", None, Some(2)),
667                            patch_line(Context, "}", Some(3), Some(3)),
668                        ],
669                    )],
670                ),
671                file_diff(
672                    "b.rs",
673                    None,
674                    FileStatus::Added,
675                    vec![hunk(
676                        "@@ -0,0 +1,1 @@",
677                        (0, 0),
678                        (1, 1),
679                        vec![
680                            patch_line(HunkHeader, "@@ -0,0 +1,1 @@", None, None),
681                            patch_line(Added, "new_content", None, Some(1)),
682                        ],
683                    )],
684                ),
685            ],
686        }
687    }
688
689    fn make_view_state(doc: GitDiffDocument) -> GitDiffViewState {
690        GitDiffViewState::new(GitDiffLoadState::Ready(doc))
691    }
692
693    fn make_state_with_cache() -> GitDiffViewState {
694        let mut state = make_view_state(make_test_doc());
695        state.ensure_patch_cache(&ViewContext::new((100, 24)));
696        state
697    }
698
699    #[test]
700    fn split_patch_requires_width_109_with_default_sidebar() {
701        let doc = make_test_doc();
702        assert!(!should_use_split_patch(108, 0, &doc.files[0]));
703        assert!(should_use_split_patch(109, 0, &doc.files[0]));
704    }
705
706    #[test]
707    fn split_patch_requires_removals_even_when_wide() {
708        let doc = make_test_doc();
709        assert!(!should_use_split_patch(140, 0, &doc.files[1]));
710    }
711
712    fn queued_comment(line_text: &str, comment: &str, kind: PatchLineKind) -> QueuedComment {
713        QueuedComment {
714            file_path: "a.rs".to_string(),
715            hunk_index: 0,
716            hunk_text: "hunk".to_string(),
717            line_text: line_text.to_string(),
718            line_number: Some(1),
719            line_kind: kind,
720            comment: comment.to_string(),
721        }
722    }
723
724    async fn send_key(view: &mut GitDiffView<'_>, code: KeyCode) -> Vec<GitDiffViewMessage> {
725        view.on_event(&Event::Key(key(code)))
726            .await
727            .unwrap_or_default()
728    }
729
730    async fn send_mouse(
731        view: &mut GitDiffView<'_>,
732        kind: MouseEventKind,
733    ) -> Vec<GitDiffViewMessage> {
734        view.on_event(&Event::Mouse(MouseEvent {
735            kind,
736            column: 0,
737            row: 0,
738            modifiers: KeyModifiers::NONE,
739        }))
740        .await
741        .unwrap_or_default()
742    }
743
744    fn has_msg(msgs: &[GitDiffViewMessage], pred: fn(&GitDiffViewMessage) -> bool) -> bool {
745        msgs.iter().any(pred)
746    }
747
748    #[tokio::test]
749    async fn key_emits_expected_message() {
750        let cases: Vec<(KeyCode, fn(&GitDiffViewMessage) -> bool)> = vec![
751            (KeyCode::Esc, |m| matches!(m, GitDiffViewMessage::Close)),
752            (KeyCode::Char('r'), |m| {
753                matches!(m, GitDiffViewMessage::Refresh)
754            }),
755        ];
756        for (code, pred) in cases {
757            let mut state = make_view_state(make_test_doc());
758            let mut view = GitDiffView { state: &mut state };
759            let msgs = send_key(&mut view, code).await;
760            assert!(has_msg(&msgs, pred), "failed for key: {code:?}");
761        }
762    }
763
764    #[tokio::test]
765    async fn j_and_k_move_file_selection() {
766        let mut state = make_view_state(make_test_doc());
767        assert_eq!(state.selected_file, 0);
768
769        let mut view = GitDiffView { state: &mut state };
770        send_key(&mut view, KeyCode::Char('j')).await;
771        assert_eq!(view.state.selected_file, 1);
772
773        // k from 0 wraps to last
774        let mut state2 = make_view_state(make_test_doc());
775        let mut view2 = GitDiffView { state: &mut state2 };
776        send_key(&mut view2, KeyCode::Char('k')).await;
777        assert_eq!(view2.state.selected_file, 1);
778    }
779
780    #[tokio::test]
781    async fn focus_switching() {
782        // Enter switches FileList -> Patch
783        let mut state = make_view_state(make_test_doc());
784        assert_eq!(state.focus, PatchFocus::FileList);
785        let mut view = GitDiffView { state: &mut state };
786        send_key(&mut view, KeyCode::Enter).await;
787        assert_eq!(view.state.focus, PatchFocus::Patch);
788
789        // h switches Patch -> FileList
790        let mut state2 = make_view_state(make_test_doc());
791        state2.focus = PatchFocus::Patch;
792        let mut view2 = GitDiffView { state: &mut state2 };
793        send_key(&mut view2, KeyCode::Char('h')).await;
794        assert_eq!(view2.state.focus, PatchFocus::FileList);
795    }
796
797    #[tokio::test]
798    async fn file_selection_resets_patch_scroll() {
799        let mut state = make_view_state(make_test_doc());
800        state.patch_scroll = 5;
801        let mut view = GitDiffView { state: &mut state };
802        send_key(&mut view, KeyCode::Char('j')).await;
803        assert_eq!(view.state.patch_scroll, 0);
804    }
805
806    #[tokio::test]
807    async fn c_enters_comment_mode() {
808        let mut state = make_state_with_cache();
809        state.focus = PatchFocus::Patch;
810        state.cursor_line = 1;
811        let mut view = GitDiffView { state: &mut state };
812        send_key(&mut view, KeyCode::Char('c')).await;
813        assert_eq!(view.state.focus, PatchFocus::CommentInput);
814    }
815
816    #[tokio::test]
817    async fn c_on_spacer_is_noop() {
818        use PatchLineKind::HunkHeader;
819        let h1 = "@@ -1,1 +1,1 @@";
820        let h2 = "@@ -5,1 +5,1 @@";
821        let doc = GitDiffDocument {
822            repo_root: PathBuf::from("/tmp/test"),
823            files: vec![file_diff(
824                "a.rs",
825                None,
826                FileStatus::Modified,
827                vec![
828                    hunk(
829                        h1,
830                        (1, 1),
831                        (1, 1),
832                        vec![patch_line(HunkHeader, h1, None, None)],
833                    ),
834                    hunk(
835                        h2,
836                        (5, 1),
837                        (5, 1),
838                        vec![patch_line(HunkHeader, h2, None, None)],
839                    ),
840                ],
841            )],
842        };
843        let mut state = make_view_state(doc);
844        state.ensure_patch_cache(&ViewContext::new((100, 24)));
845        state.focus = PatchFocus::Patch;
846        state.cursor_line = 1; // spacer between two hunks
847
848        let mut view = GitDiffView { state: &mut state };
849        send_key(&mut view, KeyCode::Char('c')).await;
850        assert_eq!(view.state.focus, PatchFocus::Patch);
851    }
852
853    #[tokio::test]
854    async fn esc_exits_comment_mode() {
855        let mut state = make_state_with_cache();
856        state.focus = PatchFocus::CommentInput;
857        state.comment_buffer = "partial".to_string();
858        let mut view = GitDiffView { state: &mut state };
859        send_key(&mut view, KeyCode::Esc).await;
860        assert_eq!(view.state.focus, PatchFocus::Patch);
861        assert!(view.state.comment_buffer.is_empty());
862    }
863
864    #[tokio::test]
865    async fn enter_queues_comment() {
866        let mut state = make_state_with_cache();
867        state.focus = PatchFocus::Patch;
868        state.cursor_line = 1;
869        let mut view = GitDiffView { state: &mut state };
870        send_key(&mut view, KeyCode::Char('c')).await;
871        assert_eq!(view.state.focus, PatchFocus::CommentInput);
872
873        for ch in "test comment".chars() {
874            send_key(&mut view, KeyCode::Char(ch)).await;
875        }
876        assert_eq!(view.state.comment_buffer, "test comment");
877
878        send_key(&mut view, KeyCode::Enter).await;
879        assert_eq!(view.state.focus, PatchFocus::Patch);
880        assert_eq!(view.state.queued_comments.len(), 1);
881        assert_eq!(view.state.queued_comments[0].comment, "test comment");
882        assert!(view.state.comment_buffer.is_empty());
883    }
884
885    #[tokio::test]
886    async fn s_submits_review() {
887        let mut state = make_state_with_cache();
888        state.focus = PatchFocus::Patch;
889        state
890            .queued_comments
891            .push(queued_comment("line", "looks good", PatchLineKind::Context));
892        let mut view = GitDiffView { state: &mut state };
893        let msgs = send_key(&mut view, KeyCode::Char('s')).await;
894        assert!(has_msg(&msgs, |m| matches!(
895            m,
896            GitDiffViewMessage::SubmitPrompt(_)
897        )));
898        assert_eq!(
899            view.state.queued_comments.len(),
900            1,
901            "submit should not clear queued comments before send is accepted"
902        );
903    }
904
905    #[tokio::test]
906    async fn s_without_comments_is_noop() {
907        let mut state = make_state_with_cache();
908        state.focus = PatchFocus::Patch;
909        let mut view = GitDiffView { state: &mut state };
910        let msgs = send_key(&mut view, KeyCode::Char('s')).await;
911        assert!(msgs.is_empty());
912    }
913
914    #[tokio::test]
915    async fn u_removes_last_comment() {
916        let mut state = make_state_with_cache();
917        state.focus = PatchFocus::Patch;
918        state
919            .queued_comments
920            .push(queued_comment("line1", "first", PatchLineKind::Context));
921        state
922            .queued_comments
923            .push(queued_comment("line2", "second", PatchLineKind::Added));
924        let mut view = GitDiffView { state: &mut state };
925        send_key(&mut view, KeyCode::Char('u')).await;
926        assert_eq!(view.state.queued_comments.len(), 1);
927        assert_eq!(view.state.queued_comments[0].comment, "first");
928    }
929
930    #[test]
931    fn cursor_navigation_clamps() {
932        let mut state = make_state_with_cache();
933        state.focus = PatchFocus::Patch;
934        state.cursor_line = 0;
935
936        state.move_cursor(-1);
937        assert_eq!(state.cursor_line, 0);
938
939        let max = state.max_patch_scroll();
940        state.cursor_line = max;
941        state.move_cursor(1);
942        assert_eq!(state.cursor_line, max);
943    }
944
945    #[tokio::test]
946    async fn cursor_replaces_scroll() {
947        let mut state = make_state_with_cache();
948        state.focus = PatchFocus::Patch;
949        state.cursor_line = 0;
950        let mut view = GitDiffView { state: &mut state };
951        send_key(&mut view, KeyCode::Char('j')).await;
952        assert_eq!(view.state.cursor_line, 1);
953        send_key(&mut view, KeyCode::Char('k')).await;
954        assert_eq!(view.state.cursor_line, 0);
955    }
956
957    #[test]
958    fn build_queued_comment_extracts_data() {
959        let mut state = make_state_with_cache();
960        state.focus = PatchFocus::Patch;
961        state.cursor_line = 3; // Added line "    new();"
962        state.comment_buffer = "test review".to_string();
963
964        let comment = build_queued_comment(&state).unwrap();
965        assert_eq!(comment.file_path, "a.rs");
966        assert_eq!(comment.hunk_index, 0);
967        assert_eq!(comment.line_text, "    new();");
968        assert_eq!(comment.line_kind, PatchLineKind::Added);
969        assert_eq!(comment.line_number, Some(2));
970        assert_eq!(comment.comment, "test review");
971        assert!(comment.hunk_text.contains("+    new();"));
972        assert!(comment.hunk_text.contains("-    old();"));
973    }
974
975    #[tokio::test]
976    async fn mouse_scroll_down_in_file_list_selects_next() {
977        let mut state = make_view_state(make_test_doc());
978        assert_eq!(state.selected_file, 0);
979        let mut view = GitDiffView { state: &mut state };
980        send_mouse(&mut view, MouseEventKind::ScrollDown).await;
981        assert_eq!(view.state.selected_file, 1);
982    }
983
984    #[tokio::test]
985    async fn mouse_scroll_up_in_patch_moves_cursor() {
986        let mut state = make_state_with_cache();
987        state.focus = PatchFocus::Patch;
988        state.cursor_line = 4;
989        let mut view = GitDiffView { state: &mut state };
990        send_mouse(&mut view, MouseEventKind::ScrollUp).await;
991        assert_eq!(view.state.cursor_line, 1);
992    }
993
994    #[tokio::test]
995    async fn mouse_scroll_during_comment_input_is_noop() {
996        let mut state = make_state_with_cache();
997        state.focus = PatchFocus::CommentInput;
998        state.cursor_line = 2;
999        let original_cursor = state.cursor_line;
1000        let original_file = state.selected_file;
1001        let mut view = GitDiffView { state: &mut state };
1002        send_mouse(&mut view, MouseEventKind::ScrollDown).await;
1003        assert_eq!(view.state.cursor_line, original_cursor);
1004        assert_eq!(view.state.selected_file, original_file);
1005        assert_eq!(view.state.focus, PatchFocus::CommentInput);
1006    }
1007
1008    fn simple_hunks() -> Vec<Hunk> {
1009        use PatchLineKind::*;
1010        let h = "@@ -1,1 +1,1 @@";
1011        vec![hunk(
1012            h,
1013            (1, 1),
1014            (1, 1),
1015            vec![
1016                patch_line(HunkHeader, h, None, None),
1017                patch_line(Context, "line", Some(1), Some(1)),
1018            ],
1019        )]
1020    }
1021
1022    fn make_tree_doc() -> GitDiffDocument {
1023        GitDiffDocument {
1024            repo_root: PathBuf::from("/tmp/test"),
1025            files: vec![
1026                file_diff("src/a.rs", None, FileStatus::Modified, simple_hunks()),
1027                file_diff("src/b.rs", None, FileStatus::Added, simple_hunks()),
1028                file_diff("lib/c.rs", None, FileStatus::Modified, simple_hunks()),
1029            ],
1030        }
1031    }
1032
1033    fn make_tree_state() -> GitDiffViewState {
1034        GitDiffViewState::new(GitDiffLoadState::Ready(make_tree_doc()))
1035    }
1036
1037    #[tokio::test]
1038    async fn h_in_file_list_collapses_directory() {
1039        let mut state = make_tree_state();
1040        state.file_tree = Some(crate::components::file_tree::FileTree::from_files(
1041            &make_tree_doc().files,
1042        ));
1043        // Tree: lib/ (dir), c.rs, src/ (dir), a.rs, b.rs
1044        // Select "src/" dir (visible index 2)
1045        state.file_tree.as_mut().unwrap().navigate(2);
1046        let entries_before = state.file_tree.as_ref().unwrap().visible_entries().len();
1047        assert_eq!(entries_before, 5);
1048
1049        let mut view = GitDiffView { state: &mut state };
1050        send_key(&mut view, KeyCode::Char('h')).await;
1051
1052        // src/ should be collapsed, hiding a.rs and b.rs
1053        let entries_after = view
1054            .state
1055            .file_tree
1056            .as_ref()
1057            .unwrap()
1058            .visible_entries()
1059            .len();
1060        assert_eq!(entries_after, 3); // lib/, c.rs, src/ (collapsed)
1061    }
1062
1063    #[tokio::test]
1064    async fn enter_on_directory_expands_it() {
1065        let mut state = make_tree_state();
1066        state.file_tree = Some(crate::components::file_tree::FileTree::from_files(
1067            &make_tree_doc().files,
1068        ));
1069        // Collapse src/ first
1070        state.file_tree.as_mut().unwrap().navigate(2);
1071        state.file_tree.as_mut().unwrap().collapse_or_parent();
1072        assert_eq!(state.file_tree.as_ref().unwrap().visible_entries().len(), 3);
1073
1074        let mut view = GitDiffView { state: &mut state };
1075        send_key(&mut view, KeyCode::Enter).await;
1076
1077        // Should expand, stay in FileList
1078        assert_eq!(view.state.focus, PatchFocus::FileList);
1079        assert_eq!(
1080            view.state
1081                .file_tree
1082                .as_ref()
1083                .unwrap()
1084                .visible_entries()
1085                .len(),
1086            5
1087        );
1088    }
1089
1090    #[tokio::test]
1091    async fn enter_on_file_switches_to_patch() {
1092        let mut state = make_tree_state();
1093        state.file_tree = Some(crate::components::file_tree::FileTree::from_files(
1094            &make_tree_doc().files,
1095        ));
1096        // Navigate to c.rs (visible index 1, which is a file)
1097        state.file_tree.as_mut().unwrap().navigate(1);
1098
1099        let mut view = GitDiffView { state: &mut state };
1100        send_key(&mut view, KeyCode::Enter).await;
1101
1102        assert_eq!(view.state.focus, PatchFocus::Patch);
1103    }
1104}