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}