Skip to main content

wisp/components/app/
git_diff_mode.rs

1use crate::components::file_list_panel::{FileListMessage, FileListPanel};
2use crate::components::git_diff_panel::{GitDiffPanel, GitDiffPanelMessage};
3use crate::git_diff::{GitDiffDocument, PatchLineKind, load_git_diff};
4use std::path::PathBuf;
5use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
6
7pub enum GitDiffViewMessage {
8    Close,
9    Refresh,
10    SubmitPrompt(String),
11}
12
13pub enum GitDiffLoadState {
14    Loading,
15    Ready(GitDiffDocument),
16    Empty,
17    Error { message: String },
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub struct PatchLineRef {
22    pub hunk_index: usize,
23    pub line_index: usize,
24}
25
26#[derive(Debug, Clone)]
27pub struct QueuedComment {
28    pub file_path: String,
29    pub patch_ref: PatchLineRef,
30    pub line_text: String,
31    pub line_number: Option<usize>,
32    pub line_kind: PatchLineKind,
33    pub comment: String,
34}
35
36pub struct GitDiffMode {
37    working_dir: PathBuf,
38    cached_repo_root: Option<PathBuf>,
39    pub load_state: GitDiffLoadState,
40    pub(crate) split: SplitPanel<FileListPanel, GitDiffPanel>,
41    pub(crate) queued_comments: Vec<QueuedComment>,
42    pending_restore: Option<RefreshState>,
43}
44
45impl GitDiffMode {
46    pub fn new(working_dir: PathBuf) -> Self {
47        Self {
48            working_dir,
49            cached_repo_root: None,
50            load_state: GitDiffLoadState::Empty,
51            split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
52                .with_separator(" ", Style::default())
53                .with_resize_keys(),
54            queued_comments: Vec::new(),
55            pending_restore: None,
56        }
57    }
58
59    pub(crate) fn begin_open(&mut self) {
60        self.reset(GitDiffLoadState::Loading);
61    }
62
63    pub(crate) fn begin_refresh(&mut self) {
64        self.pending_restore = Some(RefreshState {
65            selected_path: self.selected_file_path().map(ToOwned::to_owned),
66            was_right_focused: !self.split.is_left_focused(),
67        });
68        self.load_state = GitDiffLoadState::Loading;
69        self.split.right_mut().invalidate_cache();
70    }
71
72    pub(crate) async fn complete_load(&mut self) {
73        match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref()).await {
74            Ok(doc) => {
75                if self.cached_repo_root.is_none() {
76                    self.cached_repo_root = Some(doc.repo_root.clone());
77                }
78                let restore = self.pending_restore.take();
79                self.apply_loaded_document(doc, restore);
80            }
81            Err(error) => {
82                self.pending_restore = None;
83                self.load_state = GitDiffLoadState::Error { message: error.to_string() };
84                self.split.right_mut().invalidate_cache();
85            }
86        }
87    }
88
89    pub(crate) fn close(&mut self) {
90        self.reset(GitDiffLoadState::Empty);
91    }
92
93    fn reset(&mut self, load_state: GitDiffLoadState) {
94        self.pending_restore = None;
95        self.load_state = load_state;
96        *self.split.left_mut() = FileListPanel::new();
97        *self.split.right_mut() = GitDiffPanel::new();
98        self.queued_comments.clear();
99        self.split.focus_left();
100    }
101
102    pub(crate) async fn on_key_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
103        if self.split.right().is_in_comment_mode() {
104            return self.on_comment_mode_event(event).await;
105        }
106
107        if let Event::Key(key) = event {
108            match key.code {
109                KeyCode::Esc => return vec![GitDiffViewMessage::Close],
110                KeyCode::Char('r') => return vec![GitDiffViewMessage::Refresh],
111                KeyCode::Char('u') => {
112                    self.queued_comments.pop();
113                    self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
114                    self.split.right_mut().invalidate_cache();
115                    return vec![];
116                }
117                KeyCode::Char('s') if !self.split.is_left_focused() => {
118                    return self.submit_review();
119                }
120                KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
121                    self.split.focus_left();
122                    return vec![];
123                }
124                _ => {}
125            }
126        }
127
128        if let Some(msgs) = self.split.on_event(event).await {
129            return self.handle_split_messages(msgs);
130        }
131
132        vec![]
133    }
134
135    pub fn render_frame(&mut self, context: &ViewContext) -> Frame {
136        let theme = &context.theme;
137        if context.size.width < 10 {
138            return Frame::new(vec![Line::new("Too narrow")]);
139        }
140
141        let status_msg = match &self.load_state {
142            GitDiffLoadState::Loading => Some("Loading...".to_string()),
143            GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
144            GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
145                Some("No changes in working tree relative to HEAD".to_string())
146            }
147            GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
148            GitDiffLoadState::Ready(_) => None,
149        };
150
151        if let Some(msg) = status_msg {
152            let height = context.size.height as usize;
153            let widths = self.split.widths(context.size.width);
154            let left_width = widths.left as usize;
155            let mut rows = Vec::with_capacity(height);
156            for i in 0..height {
157                let mut line = Line::default();
158                line.push_with_style(" ".repeat(left_width), Style::default().bg_color(theme.sidebar_bg()));
159                line.push_with_style(" ", Style::default().bg_color(theme.code_bg()));
160                if i == 0 {
161                    line.push_with_style(&msg, Style::fg(theme.text_secondary()));
162                }
163                rows.push(line);
164            }
165            return Frame::new(rows);
166        }
167
168        self.prepare_right_panel_cache(context);
169        self.split.set_separator_style(Style::default().bg_color(theme.code_bg()));
170        self.split.render(context)
171    }
172
173    fn prepare_right_panel_cache(&mut self, context: &ViewContext) {
174        let GitDiffLoadState::Ready(doc) = &self.load_state else {
175            return;
176        };
177
178        let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
179        let file = &doc.files[selected];
180
181        let mut file_comments: Vec<QueuedComment> =
182            self.queued_comments.iter().filter(|c| c.file_path == file.path).cloned().collect();
183        if self.split.right().is_in_comment_mode()
184            && let Some(draft) = self.split.right().build_draft_comment(file)
185        {
186            file_comments.push(draft);
187        }
188
189        let right_width = self.split.widths(context.size.width).right;
190        self.split.right_mut().ensure_cache(file, &file_comments, right_width);
191    }
192
193    fn on_file_selected(&mut self, idx: usize) {
194        self.split.left_mut().select_file_index(idx);
195        self.split.right_mut().reset_for_new_file();
196    }
197
198    async fn on_comment_mode_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
199        if let Some(msgs) = self.split.right_mut().on_event(event).await {
200            return self.handle_right_panel_messages(msgs);
201        }
202        vec![]
203    }
204
205    fn handle_split_messages(
206        &mut self,
207        msgs: Vec<Either<FileListMessage, GitDiffPanelMessage>>,
208    ) -> Vec<GitDiffViewMessage> {
209        let mut right_msgs = Vec::new();
210        for msg in msgs {
211            match msg {
212                Either::Left(FileListMessage::Selected(idx)) => {
213                    self.on_file_selected(idx);
214                }
215                Either::Left(FileListMessage::FileOpened(idx)) => {
216                    self.on_file_selected(idx);
217                    self.split.focus_right();
218                }
219                Either::Right(panel_msg) => right_msgs.push(panel_msg),
220            }
221        }
222        self.handle_right_panel_messages(right_msgs)
223    }
224
225    fn handle_right_panel_messages(&mut self, msgs: Vec<GitDiffPanelMessage>) -> Vec<GitDiffViewMessage> {
226        for msg in msgs {
227            let GitDiffPanelMessage::CommentSubmitted { anchor, text } = msg;
228            self.queue_comment(anchor, &text);
229        }
230        vec![]
231    }
232
233    fn queue_comment(&mut self, anchor: PatchLineRef, text: &str) {
234        let GitDiffLoadState::Ready(doc) = &self.load_state else {
235            return;
236        };
237        let selected = self.split.left().selected_file_index().unwrap_or(0);
238        let Some(file) = doc.files.get(selected) else {
239            return;
240        };
241        let Some(hunk) = file.hunks.get(anchor.hunk_index) else {
242            return;
243        };
244        let Some(patch_line) = hunk.lines.get(anchor.line_index) else {
245            return;
246        };
247
248        self.queued_comments.push(QueuedComment {
249            file_path: file.path.clone(),
250            patch_ref: anchor,
251            line_text: patch_line.text.clone(),
252            line_number: patch_line.new_line_no.or(patch_line.old_line_no),
253            line_kind: patch_line.kind,
254            comment: text.to_string(),
255        });
256        self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
257        self.split.right_mut().invalidate_cache();
258    }
259
260    fn submit_review(&self) -> Vec<GitDiffViewMessage> {
261        if self.queued_comments.is_empty() {
262            return vec![];
263        }
264        let prompt = format_review_prompt(&self.queued_comments);
265        vec![GitDiffViewMessage::SubmitPrompt(prompt)]
266    }
267
268    fn selected_file_path(&self) -> Option<&str> {
269        let GitDiffLoadState::Ready(doc) = &self.load_state else {
270            return None;
271        };
272        let idx = self.split.left().selected_file_index()?;
273        doc.files.get(idx).map(|f| f.path.as_str())
274    }
275
276    pub fn load_document(&mut self, doc: GitDiffDocument) {
277        self.apply_loaded_document(doc, None);
278    }
279
280    fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
281        if doc.files.is_empty() {
282            self.load_state = GitDiffLoadState::Empty;
283            self.split.right_mut().invalidate_cache();
284            return;
285        }
286
287        self.split.left_mut().rebuild_from_files(&doc.files);
288        self.split.right_mut().invalidate_cache();
289
290        if let Some(restore) = restore {
291            if restore.was_right_focused {
292                self.split.focus_right();
293            } else {
294                self.split.focus_left();
295            }
296            self.split.right_mut().reset_scroll();
297            if let Some(path) = &restore.selected_path
298                && let Some(idx) = doc.files.iter().position(|file| file.path == *path)
299            {
300                self.split.left_mut().select_file_index(idx);
301            }
302        }
303
304        self.load_state = GitDiffLoadState::Ready(doc);
305    }
306}
307
308struct RefreshState {
309    selected_path: Option<String>,
310    was_right_focused: bool,
311}
312
313pub(crate) fn format_review_prompt(comments: &[QueuedComment]) -> String {
314    use std::fmt::Write;
315
316    let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
317
318    let mut file_groups: Vec<(&str, Vec<&QueuedComment>)> = Vec::new();
319    for comment in comments {
320        if let Some(group) = file_groups.iter_mut().find(|(path, _)| *path == comment.file_path) {
321            group.1.push(comment);
322        } else {
323            file_groups.push((&comment.file_path, vec![comment]));
324        }
325    }
326
327    for (file_path, file_comments) in &file_groups {
328        write!(prompt, "\n## `{file_path}`\n").unwrap();
329
330        for comment in file_comments {
331            let kind_label = match comment.line_kind {
332                PatchLineKind::Added => "added",
333                PatchLineKind::Removed => "removed",
334                PatchLineKind::Context => "context",
335                PatchLineKind::HunkHeader => "header",
336                PatchLineKind::Meta => "meta",
337            };
338            let line_ref = match comment.line_number {
339                Some(n) => format!("Line {n} ({kind_label})"),
340                None => kind_label.to_string(),
341            };
342            write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.line_text, comment.comment).unwrap();
343        }
344    }
345
346    prompt
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::git_diff::{FileDiff, FileStatus, GitDiffDocument, Hunk, PatchLine, PatchLineKind};
353    use tui::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind, ViewContext};
354
355    fn key(code: KeyCode) -> KeyEvent {
356        KeyEvent::new(code, KeyModifiers::NONE)
357    }
358
359    fn patch_line(kind: PatchLineKind, text: &str, old: Option<usize>, new: Option<usize>) -> PatchLine {
360        PatchLine { kind, text: text.to_string(), old_line_no: old, new_line_no: new }
361    }
362
363    fn hunk(header: &str, old: (usize, usize), new: (usize, usize), lines: Vec<PatchLine>) -> Hunk {
364        Hunk {
365            header: header.to_string(),
366            old_start: old.0,
367            old_count: old.1,
368            new_start: new.0,
369            new_count: new.1,
370            lines,
371        }
372    }
373
374    fn file_diff(path: &str, old_path: Option<&str>, status: FileStatus, hunks: Vec<Hunk>) -> FileDiff {
375        FileDiff { old_path: old_path.map(str::to_string), path: path.to_string(), status, hunks, binary: false }
376    }
377
378    fn make_test_doc() -> GitDiffDocument {
379        use PatchLineKind::*;
380        let h = "@@ -1,3 +1,3 @@";
381        GitDiffDocument {
382            repo_root: PathBuf::from("/tmp/test"),
383            files: vec![
384                file_diff(
385                    "a.rs",
386                    Some("a.rs"),
387                    FileStatus::Modified,
388                    vec![hunk(
389                        h,
390                        (1, 3),
391                        (1, 3),
392                        vec![
393                            patch_line(HunkHeader, h, None, None),
394                            patch_line(Context, "fn main() {", Some(1), Some(1)),
395                            patch_line(Removed, "    old();", Some(2), None),
396                            patch_line(Added, "    new();", None, Some(2)),
397                            patch_line(Context, "}", Some(3), Some(3)),
398                        ],
399                    )],
400                ),
401                file_diff(
402                    "b.rs",
403                    None,
404                    FileStatus::Added,
405                    vec![hunk(
406                        "@@ -0,0 +1,1 @@",
407                        (0, 0),
408                        (1, 1),
409                        vec![
410                            patch_line(HunkHeader, "@@ -0,0 +1,1 @@", None, None),
411                            patch_line(Added, "new_content", None, Some(1)),
412                        ],
413                    )],
414                ),
415            ],
416        }
417    }
418
419    fn make_mode(doc: GitDiffDocument) -> GitDiffMode {
420        let mut mode = GitDiffMode::new(PathBuf::from("."));
421        mode.apply_loaded_document(doc, None);
422        mode
423    }
424
425    fn make_mode_with_cache() -> GitDiffMode {
426        let mut mode = make_mode(make_test_doc());
427        mode.render_frame(&ViewContext::new((100, 24)));
428        mode
429    }
430
431    async fn send_key(mode: &mut GitDiffMode, code: KeyCode) -> Vec<GitDiffViewMessage> {
432        mode.on_key_event(&Event::Key(key(code))).await
433    }
434
435    async fn send_mouse(mode: &mut GitDiffMode, kind: MouseEventKind) -> Vec<GitDiffViewMessage> {
436        mode.on_key_event(&Event::Mouse(MouseEvent { kind, column: 0, row: 0, modifiers: KeyModifiers::NONE })).await
437    }
438
439    fn has_msg(msgs: &[GitDiffViewMessage], pred: fn(&GitDiffViewMessage) -> bool) -> bool {
440        msgs.iter().any(pred)
441    }
442
443    fn queued_comment(line_text: &str, comment: &str, kind: PatchLineKind) -> QueuedComment {
444        QueuedComment {
445            file_path: "a.rs".to_string(),
446            patch_ref: PatchLineRef { hunk_index: 0, line_index: 0 },
447            line_text: line_text.to_string(),
448            line_number: Some(1),
449            line_kind: kind,
450            comment: comment.to_string(),
451        }
452    }
453
454    fn render_diff_text(mode: &mut GitDiffMode, width: u16) -> Vec<String> {
455        let ctx = ViewContext::new((width, 24));
456        mode.render_frame(&ctx).into_parts().0.iter().map(tui::Line::plain_text).collect()
457    }
458
459    fn make_doc(paths: &[&str]) -> GitDiffDocument {
460        GitDiffDocument {
461            repo_root: PathBuf::from("/tmp/repo"),
462            files: paths
463                .iter()
464                .map(|path| FileDiff {
465                    old_path: None,
466                    path: (*path).to_string(),
467                    status: FileStatus::Modified,
468                    hunks: vec![Hunk {
469                        header: "@@ -1 +1 @@".to_string(),
470                        old_start: 1,
471                        old_count: 1,
472                        new_start: 1,
473                        new_count: 2,
474                        lines: vec![
475                            PatchLine {
476                                kind: PatchLineKind::HunkHeader,
477                                text: "@@ -1 +1 @@".to_string(),
478                                old_line_no: None,
479                                new_line_no: None,
480                            },
481                            PatchLine {
482                                kind: PatchLineKind::Context,
483                                text: "line one".to_string(),
484                                old_line_no: Some(1),
485                                new_line_no: Some(1),
486                            },
487                            PatchLine {
488                                kind: PatchLineKind::Added,
489                                text: "line two".to_string(),
490                                old_line_no: None,
491                                new_line_no: Some(2),
492                            },
493                        ],
494                    }],
495                    binary: false,
496                })
497                .collect(),
498        }
499    }
500
501    fn mode_with(paths: &[&str]) -> GitDiffMode {
502        let mut mode = GitDiffMode::new(PathBuf::from("."));
503        let doc = make_doc(paths);
504        mode.apply_loaded_document(doc, None);
505        mode
506    }
507
508    fn comment(file: &str, line_text: &str, line_number: usize, kind: PatchLineKind, comment: &str) -> QueuedComment {
509        QueuedComment {
510            file_path: file.to_string(),
511            patch_ref: PatchLineRef { hunk_index: 0, line_index: 0 },
512            line_text: line_text.to_string(),
513            line_number: Some(line_number),
514            line_kind: kind,
515            comment: comment.to_string(),
516        }
517    }
518
519    #[test]
520    fn begin_refresh_preserves_selected_path_and_focus_after_load() {
521        let mut mode = mode_with(&["a.rs", "b.rs"]);
522        mode.split.left_mut().select_file_index(1);
523        mode.split.focus_right();
524        mode.begin_refresh();
525
526        let restore = mode.pending_restore.take();
527        mode.apply_loaded_document(make_doc(&["c.rs", "b.rs"]), restore);
528
529        assert_eq!(mode.selected_file_path(), Some("b.rs"));
530        assert!(!mode.split.is_left_focused());
531        assert_eq!(mode.split.right().scroll, 0);
532    }
533
534    #[test]
535    fn format_review_prompt_groups_by_file() {
536        let comments = vec![
537            comment("src/foo.rs", "    new();", 2, PatchLineKind::Added, "Looks risky"),
538            comment("src/foo.rs", "    old();", 2, PatchLineKind::Removed, "Why remove this?"),
539            comment("src/bar.rs", "new_line", 1, PatchLineKind::Added, "Needs a test"),
540        ];
541
542        let prompt = format_review_prompt(&comments);
543        assert!(prompt.contains("## `src/foo.rs`"), "should have foo.rs header");
544        assert!(prompt.contains("## `src/bar.rs`"), "should have bar.rs header");
545        assert!(!prompt.contains("```diff"), "should not include diff blocks");
546        for expected in
547            ["Looks risky", "Why remove this?", "Needs a test", "Line 2 (added)", "Line 2 (removed)", "Line 1 (added)"]
548        {
549            assert!(prompt.contains(expected), "missing: {expected}");
550        }
551    }
552
553    #[test]
554    fn narrow_terminal_renders_unified_diff() {
555        let mut mode = make_mode(make_test_doc());
556        let lines = render_diff_text(&mut mode, 108);
557        assert!(lines.iter().any(|l| l.contains("old()")), "should contain removed line");
558        assert!(lines.iter().any(|l| l.contains("new()")), "should contain added line");
559        assert!(
560            !lines.iter().any(|l| l.contains("old()") && l.contains("new()")),
561            "unified mode: old and new should be on separate rows"
562        );
563    }
564
565    #[test]
566    fn wide_terminal_renders_split_diff() {
567        let mut mode = make_mode(make_test_doc());
568        let lines = render_diff_text(&mut mode, 109);
569        assert!(
570            lines.iter().any(|l| l.contains("old()") && l.contains("new()")),
571            "split mode: old and new should appear on the same row"
572        );
573    }
574
575    #[tokio::test]
576    async fn resizing_split_panel_rebuilds_right_cache_for_new_width() {
577        let mut mode = make_mode(make_test_doc());
578        let ctx = ViewContext::new((130, 24));
579
580        mode.render_frame(&ctx);
581        send_key(&mut mode, KeyCode::Char('>')).await;
582
583        let resized_right_width = usize::from(mode.split.widths(ctx.size.width).right);
584        mode.render_frame(&ctx);
585
586        assert!(
587            mode.split.right().cached_lines.iter().all(|line| line.display_width() <= resized_right_width),
588            "expected all cached lines to fit resized right width {resized_right_width}, got widths: {:?}",
589            mode.split.right().cached_lines.iter().map(tui::Line::display_width).collect::<Vec<_>>()
590        );
591    }
592
593    #[tokio::test]
594    async fn key_emits_expected_message() {
595        type KeyCase = (KeyCode, fn(&GitDiffViewMessage) -> bool);
596        let cases: Vec<KeyCase> = vec![
597            (KeyCode::Esc, |m| matches!(m, GitDiffViewMessage::Close)),
598            (KeyCode::Char('r'), |m| matches!(m, GitDiffViewMessage::Refresh)),
599        ];
600        for (code, pred) in cases {
601            let mut mode = make_mode(make_test_doc());
602            let msgs = send_key(&mut mode, code).await;
603            assert!(has_msg(&msgs, pred), "failed for key: {code:?}");
604        }
605    }
606
607    #[tokio::test]
608    async fn j_and_k_move_file_selection() {
609        let mut mode = make_mode(make_test_doc());
610        assert_eq!(mode.split.left().selected_file_index(), Some(0));
611
612        send_key(&mut mode, KeyCode::Char('j')).await;
613        assert_eq!(mode.split.left().selected_file_index(), Some(1));
614
615        let mut mode2 = make_mode(make_test_doc());
616        send_key(&mut mode2, KeyCode::Char('k')).await;
617        assert_eq!(mode2.split.left().selected_file_index(), Some(1));
618    }
619
620    #[tokio::test]
621    async fn focus_switching() {
622        let mut mode = make_mode(make_test_doc());
623        assert!(mode.split.is_left_focused());
624        send_key(&mut mode, KeyCode::Enter).await;
625        assert!(!mode.split.is_left_focused());
626
627        let mut mode2 = make_mode(make_test_doc());
628        mode2.split.focus_right();
629        send_key(&mut mode2, KeyCode::Char('h')).await;
630        assert!(mode2.split.is_left_focused());
631    }
632
633    #[tokio::test]
634    async fn file_selection_resets_patch_scroll() {
635        let mut mode = make_mode(make_test_doc());
636        mode.split.right_mut().scroll = 5;
637        send_key(&mut mode, KeyCode::Char('j')).await;
638        assert_eq!(mode.split.right().scroll, 0);
639    }
640
641    #[tokio::test]
642    async fn c_enters_comment_mode() {
643        let mut mode = make_mode_with_cache();
644        mode.split.focus_right();
645        mode.split.right_mut().cursor_line = 1;
646        send_key(&mut mode, KeyCode::Char('c')).await;
647        assert!(mode.split.right().is_in_comment_mode());
648    }
649
650    #[tokio::test]
651    async fn c_on_spacer_is_noop() {
652        use PatchLineKind::HunkHeader;
653        let h1 = "@@ -1,1 +1,1 @@";
654        let h2 = "@@ -5,1 +5,1 @@";
655        let doc = GitDiffDocument {
656            repo_root: PathBuf::from("/tmp/test"),
657            files: vec![file_diff(
658                "a.rs",
659                None,
660                FileStatus::Modified,
661                vec![
662                    hunk(h1, (1, 1), (1, 1), vec![patch_line(HunkHeader, h1, None, None)]),
663                    hunk(h2, (5, 1), (5, 1), vec![patch_line(HunkHeader, h2, None, None)]),
664                ],
665            )],
666        };
667        let mut mode = make_mode(doc);
668        mode.render_frame(&ViewContext::new((100, 24)));
669        mode.split.focus_right();
670        mode.split.right_mut().cursor_line = 1;
671
672        send_key(&mut mode, KeyCode::Char('c')).await;
673        assert!(!mode.split.right().is_in_comment_mode());
674    }
675
676    #[tokio::test]
677    async fn esc_exits_comment_mode() {
678        let mut mode = make_mode_with_cache();
679        mode.split.focus_right();
680        mode.split.right_mut().in_comment_mode = true;
681        mode.split.right_mut().comment_buffer = "partial".to_string();
682        send_key(&mut mode, KeyCode::Esc).await;
683        assert!(!mode.split.right().is_in_comment_mode());
684        assert!(mode.split.right().comment_buffer.is_empty());
685    }
686
687    #[tokio::test]
688    async fn enter_queues_comment() {
689        let mut mode = make_mode_with_cache();
690        mode.split.focus_right();
691        mode.split.right_mut().cursor_line = 1;
692        send_key(&mut mode, KeyCode::Char('c')).await;
693        assert!(mode.split.right().is_in_comment_mode());
694
695        for ch in "test comment".chars() {
696            send_key(&mut mode, KeyCode::Char(ch)).await;
697        }
698        assert_eq!(mode.split.right().comment_buffer, "test comment");
699
700        send_key(&mut mode, KeyCode::Enter).await;
701        assert!(!mode.split.right().is_in_comment_mode());
702        assert_eq!(mode.queued_comments.len(), 1);
703        assert_eq!(mode.queued_comments[0].comment, "test comment");
704        assert!(mode.split.right().comment_buffer.is_empty());
705    }
706
707    #[tokio::test]
708    async fn s_submits_review() {
709        let mut mode = make_mode_with_cache();
710        mode.split.focus_right();
711        mode.queued_comments.push(queued_comment("line", "looks good", PatchLineKind::Context));
712        let msgs = send_key(&mut mode, KeyCode::Char('s')).await;
713        assert!(has_msg(&msgs, |m| matches!(m, GitDiffViewMessage::SubmitPrompt(_))));
714        assert_eq!(mode.queued_comments.len(), 1, "submit should not clear queued comments before send is accepted");
715    }
716
717    #[tokio::test]
718    async fn s_without_comments_is_noop() {
719        let mut mode = make_mode_with_cache();
720        mode.split.focus_right();
721        let msgs = send_key(&mut mode, KeyCode::Char('s')).await;
722        assert!(msgs.is_empty());
723    }
724
725    #[tokio::test]
726    async fn u_removes_last_comment() {
727        let mut mode = make_mode_with_cache();
728        mode.split.focus_right();
729        mode.queued_comments.push(queued_comment("line1", "first", PatchLineKind::Context));
730        mode.queued_comments.push(queued_comment("line2", "second", PatchLineKind::Added));
731        send_key(&mut mode, KeyCode::Char('u')).await;
732        assert_eq!(mode.queued_comments.len(), 1);
733        assert_eq!(mode.queued_comments[0].comment, "first");
734    }
735
736    #[test]
737    fn cursor_navigation_clamps() {
738        let mut mode = make_mode_with_cache();
739        mode.split.focus_right();
740        mode.split.right_mut().cursor_line = 0;
741
742        mode.split.right_mut().move_cursor(-1);
743        assert_eq!(mode.split.right().cursor_line, 0);
744
745        let max = mode.split.right().max_scroll();
746        mode.split.right_mut().cursor_line = max;
747        mode.split.right_mut().move_cursor(1);
748        assert_eq!(mode.split.right().cursor_line, max);
749    }
750
751    #[tokio::test]
752    async fn cursor_replaces_scroll() {
753        let mut mode = make_mode_with_cache();
754        mode.split.focus_right();
755        mode.split.right_mut().cursor_line = 0;
756        send_key(&mut mode, KeyCode::Char('j')).await;
757        assert_eq!(mode.split.right().cursor_line, 1);
758        send_key(&mut mode, KeyCode::Char('k')).await;
759        assert_eq!(mode.split.right().cursor_line, 0);
760    }
761
762    #[tokio::test]
763    async fn mouse_scroll_down_in_file_list_selects_next() {
764        let mut mode = make_mode(make_test_doc());
765        assert_eq!(mode.split.left().selected_file_index(), Some(0));
766        send_mouse(&mut mode, MouseEventKind::ScrollDown).await;
767        assert_eq!(mode.split.left().selected_file_index(), Some(1));
768    }
769
770    #[tokio::test]
771    async fn mouse_scroll_up_in_patch_moves_cursor() {
772        let mut mode = make_mode_with_cache();
773        mode.split.focus_right();
774        mode.split.right_mut().cursor_line = 4;
775        send_mouse(&mut mode, MouseEventKind::ScrollUp).await;
776        assert_eq!(mode.split.right().cursor_line, 1);
777    }
778
779    #[tokio::test]
780    async fn mouse_scroll_during_comment_input_is_noop() {
781        let mut mode = make_mode_with_cache();
782        mode.split.focus_right();
783        mode.split.right_mut().in_comment_mode = true;
784        mode.split.right_mut().cursor_line = 2;
785        let original_cursor = mode.split.right().cursor_line;
786        let original_file = mode.split.left().selected_file_index();
787        send_mouse(&mut mode, MouseEventKind::ScrollDown).await;
788        assert_eq!(mode.split.right().cursor_line, original_cursor);
789        assert_eq!(mode.split.left().selected_file_index(), original_file);
790        assert!(mode.split.right().is_in_comment_mode());
791    }
792
793    #[test]
794    fn hunk_offsets_account_for_soft_wrapped_lines() {
795        use PatchLineKind::*;
796
797        let long_line = "x".repeat(200);
798        let h1 = "@@ -1,2 +1,2 @@";
799        let h2 = "@@ -10,1 +10,1 @@";
800        let doc = GitDiffDocument {
801            repo_root: PathBuf::from("/tmp/test"),
802            files: vec![file_diff(
803                "a.rs",
804                None,
805                FileStatus::Modified,
806                vec![
807                    hunk(
808                        h1,
809                        (1, 2),
810                        (1, 2),
811                        vec![
812                            patch_line(HunkHeader, h1, None, None),
813                            patch_line(Added, &long_line, None, Some(1)),
814                            patch_line(Context, "short", Some(2), Some(2)),
815                        ],
816                    ),
817                    hunk(
818                        h2,
819                        (10, 1),
820                        (10, 1),
821                        vec![patch_line(HunkHeader, h2, None, None), patch_line(Context, "end", Some(10), Some(10))],
822                    ),
823                ],
824            )],
825        };
826
827        let mut mode = make_mode(doc);
828        mode.render_frame(&ViewContext::new((60, 24)));
829
830        let offsets = mode.split.right().hunk_offsets();
831        assert_eq!(offsets.len(), 2, "should find two hunks");
832
833        let second_hunk_ref_pos = mode
834            .split
835            .right()
836            .line_refs
837            .iter()
838            .position(|r| matches!(r, Some(r) if r.hunk_index == 1))
839            .expect("hunk 1 should exist in refs");
840        assert_eq!(offsets[1], second_hunk_ref_pos);
841
842        assert!(
843            offsets[1] > 4,
844            "second hunk offset {} should exceed unwrapped count (4) due to soft-wrapping",
845            offsets[1]
846        );
847
848        mode.split.focus_right();
849        mode.split.right_mut().cursor_line = 0;
850        assert!(mode.split.right_mut().jump_next_hunk());
851        assert_eq!(mode.split.right().cursor_line, second_hunk_ref_pos);
852
853        assert!(mode.split.right_mut().jump_prev_hunk());
854        assert_eq!(mode.split.right().cursor_line, 0);
855    }
856
857    fn simple_hunks() -> Vec<Hunk> {
858        use PatchLineKind::*;
859        let h = "@@ -1,1 +1,1 @@";
860        vec![hunk(
861            h,
862            (1, 1),
863            (1, 1),
864            vec![patch_line(HunkHeader, h, None, None), patch_line(Context, "line", Some(1), Some(1))],
865        )]
866    }
867
868    fn make_tree_doc() -> GitDiffDocument {
869        GitDiffDocument {
870            repo_root: PathBuf::from("/tmp/test"),
871            files: vec![
872                file_diff("src/a.rs", None, FileStatus::Modified, simple_hunks()),
873                file_diff("src/b.rs", None, FileStatus::Added, simple_hunks()),
874                file_diff("lib/c.rs", None, FileStatus::Modified, simple_hunks()),
875            ],
876        }
877    }
878
879    fn make_tree_mode() -> GitDiffMode {
880        make_mode(make_tree_doc())
881    }
882
883    #[tokio::test]
884    async fn h_in_file_list_collapses_directory() {
885        let mut mode = make_tree_mode();
886        mode.split.left_mut().tree_mut().navigate(2);
887        let entries_before = mode.split.left_mut().tree_mut().visible_entries().len();
888        assert_eq!(entries_before, 5);
889
890        send_key(&mut mode, KeyCode::Char('h')).await;
891
892        let entries_after = mode.split.left_mut().tree_mut().visible_entries().len();
893        assert_eq!(entries_after, 3);
894    }
895
896    #[tokio::test]
897    async fn enter_on_directory_expands_it() {
898        let mut mode = make_tree_mode();
899        mode.split.left_mut().tree_mut().navigate(2);
900        mode.split.left_mut().tree_collapse_or_parent();
901        assert_eq!(mode.split.left_mut().tree_mut().visible_entries().len(), 3);
902
903        send_key(&mut mode, KeyCode::Enter).await;
904
905        assert!(mode.split.is_left_focused());
906        assert_eq!(mode.split.left_mut().tree_mut().visible_entries().len(), 5);
907    }
908
909    #[tokio::test]
910    async fn enter_on_file_switches_to_patch() {
911        let mut mode = make_tree_mode();
912        mode.split.left_mut().tree_mut().navigate(1);
913
914        send_key(&mut mode, KeyCode::Enter).await;
915
916        assert!(!mode.split.is_left_focused());
917    }
918
919    #[tokio::test]
920    async fn enter_queues_comment_and_invalidates_cache() {
921        let mut mode = make_mode_with_cache();
922        mode.split.focus_right();
923        mode.split.right_mut().cursor_line = 3;
924
925        send_key(&mut mode, KeyCode::Char('c')).await;
926        for ch in "test comment".chars() {
927            send_key(&mut mode, KeyCode::Char(ch)).await;
928        }
929        send_key(&mut mode, KeyCode::Enter).await;
930
931        assert_eq!(mode.queued_comments.len(), 1);
932        assert_eq!(mode.queued_comments[0].comment, "test comment");
933
934        assert!(mode.split.right().cached_lines.is_empty());
935    }
936
937    #[tokio::test]
938    async fn undo_removes_last_comment_and_invalidates_cache() {
939        let mut mode = make_mode_with_cache();
940        mode.split.focus_right();
941        mode.queued_comments.push(queued_comment("line1", "first", PatchLineKind::Context));
942        mode.queued_comments.push(queued_comment("line2", "second", PatchLineKind::Added));
943
944        send_key(&mut mode, KeyCode::Char('u')).await;
945
946        assert_eq!(mode.queued_comments.len(), 1);
947        assert_eq!(mode.queued_comments[0].comment, "first");
948
949        assert!(mode.split.right().cached_lines.is_empty());
950    }
951
952    #[test]
953    fn cursor_stays_on_logical_line_after_comment_insert() {
954        let mut mode = make_mode_with_cache();
955        mode.split.focus_right();
956        mode.split.right_mut().cursor_line = 3;
957        let original_ref = mode.split.right().line_refs[3];
958
959        mode.queued_comments.push(QueuedComment {
960            file_path: "a.rs".to_string(),
961            patch_ref: original_ref.unwrap(),
962            line_text: "    new();".to_string(),
963            line_number: Some(2),
964            line_kind: PatchLineKind::Added,
965            comment: "review".to_string(),
966        });
967        mode.split.right_mut().invalidate_cache();
968        mode.render_frame(&ViewContext::new((100, 24)));
969
970        let cursor = mode.split.right().cursor_line;
971        let new_ref = mode.split.right().line_refs[cursor];
972        assert_eq!(new_ref, original_ref, "cursor should stay on the same logical line");
973        let line_text = mode.split.right().cached_lines[cursor].plain_text();
974        assert!(line_text.contains("new();"), "cursor should be on the added line, got: {line_text}");
975    }
976
977    #[test]
978    fn cursor_on_comment_row_restores_to_anchored_line() {
979        let mut mode = make_mode_with_cache();
980        mode.split.focus_right();
981        let anchor = PatchLineRef { hunk_index: 0, line_index: 3 };
982        mode.queued_comments.push(QueuedComment {
983            file_path: "a.rs".to_string(),
984            patch_ref: anchor,
985            line_text: "    new();".to_string(),
986            line_number: Some(2),
987            line_kind: PatchLineKind::Added,
988            comment: "review".to_string(),
989        });
990        mode.split.right_mut().invalidate_cache();
991        mode.render_frame(&ViewContext::new((100, 24)));
992
993        let comment_row = mode
994            .split
995            .right()
996            .line_refs
997            .iter()
998            .position(|r: &Option<PatchLineRef>| r.is_none())
999            .expect("should have a comment row");
1000        mode.split.right_mut().cursor_line = comment_row;
1001
1002        mode.split.right_mut().invalidate_cache();
1003        mode.render_frame(&ViewContext::new((100, 24)));
1004
1005        let cursor = mode.split.right().cursor_line;
1006        assert_eq!(
1007            mode.split.right().line_refs[cursor],
1008            Some(anchor),
1009            "cursor should be restored to the anchored diff line"
1010        );
1011    }
1012}