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 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 } 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 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 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 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 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 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; 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; 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 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 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); }
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 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 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 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}