1use crate::components::file_tree::FileTree;
2use crate::components::git_diff_view::{
3 GitDiffView, GitDiffViewMessage, build_patch_lines, diff_layout, should_use_split_patch,
4};
5use crate::components::split_patch_renderer::build_split_patch_lines;
6use crate::git_diff::{FileDiff, GitDiffDocument, PatchLineKind};
7use std::path::PathBuf;
8use tui::{Component, Event, Line, ViewContext};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum ScreenMode {
12 Conversation,
13 GitDiff,
14}
15
16pub enum GitDiffLoadState {
17 Loading,
18 Ready(GitDiffDocument),
19 Empty,
20 Error { message: String },
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum PatchFocus {
25 FileList,
26 Patch,
27 CommentInput,
28}
29
30#[derive(Debug, Clone)]
31pub struct PatchLineRef {
32 pub hunk_index: usize,
33 pub line_index: usize,
34}
35
36#[derive(Debug, Clone)]
37pub struct QueuedComment {
38 pub file_path: String,
39 pub hunk_index: usize,
40 pub hunk_text: String,
41 pub line_text: String,
42 pub line_number: Option<usize>,
43 pub line_kind: PatchLineKind,
44 pub comment: String,
45}
46
47pub struct GitDiffViewState {
48 pub(crate) load_state: GitDiffLoadState,
49 pub(crate) focus: PatchFocus,
50 pub(crate) selected_file: usize,
51 pub(crate) patch_scroll: usize,
52 pub(crate) cached_patch_lines: Vec<Line>,
53 pub(crate) cached_patch_line_refs: Vec<Option<PatchLineRef>>,
54 pub(crate) cursor_line: usize,
55 pub(crate) comment_buffer: String,
56 pub(crate) comment_cursor: usize,
57 pub(crate) queued_comments: Vec<QueuedComment>,
58 pub(crate) file_tree: Option<FileTree>,
59 pub(crate) file_list_scroll: usize,
60 pub(crate) sidebar_width_delta: i16,
61 cached_for_file: Option<usize>,
62 cached_for_width: Option<u16>,
63}
64
65impl GitDiffViewState {
66 pub fn new(load_state: GitDiffLoadState) -> Self {
67 Self {
68 load_state,
69 focus: PatchFocus::FileList,
70 selected_file: 0,
71 patch_scroll: 0,
72 cached_patch_lines: Vec::new(),
73 cached_patch_line_refs: Vec::new(),
74 cursor_line: 0,
75 comment_buffer: String::new(),
76 comment_cursor: 0,
77 queued_comments: Vec::new(),
78 file_tree: None,
79 file_list_scroll: 0,
80 sidebar_width_delta: 0,
81 cached_for_file: None,
82 cached_for_width: None,
83 }
84 }
85
86 pub(crate) fn invalidate_patch_cache(&mut self) {
87 self.cached_for_file = None;
88 self.cached_for_width = None;
89 self.cached_patch_lines.clear();
90 self.cached_patch_line_refs.clear();
91 }
92
93 pub(crate) fn selected_file(&self) -> Option<&FileDiff> {
94 let GitDiffLoadState::Ready(doc) = &self.load_state else {
95 return None;
96 };
97 doc.files
98 .get(self.selected_file.min(doc.files.len().saturating_sub(1)))
99 }
100
101 pub(crate) fn selected_file_path(&self) -> Option<&str> {
102 self.selected_file().map(|file| file.path.as_str())
103 }
104
105 pub(crate) fn file_count(&self) -> usize {
106 match &self.load_state {
107 GitDiffLoadState::Ready(doc) => doc.files.len(),
108 _ => 0,
109 }
110 }
111
112 pub(crate) fn max_patch_scroll(&self) -> usize {
113 if let Some(file) = self.selected_file() {
114 let total_lines = self.cached_patch_lines.len().max(
115 file.hunks.iter().map(|h| h.lines.len()).sum::<usize>()
116 + file.hunks.len().saturating_sub(1),
117 );
118 return total_lines.saturating_sub(1);
119 }
120 0
121 }
122
123 pub(crate) fn selected_hunk_offsets(&self) -> Vec<usize> {
124 let Some(file) = self.selected_file() else {
125 return Vec::new();
126 };
127 let mut offsets = Vec::with_capacity(file.hunks.len());
128 let mut offset = 0;
129 for hunk in &file.hunks {
130 offsets.push(offset);
131 offset += hunk.lines.len() + 1;
132 }
133 offsets
134 }
135
136 pub(crate) fn set_focus(&mut self, focus: PatchFocus) -> bool {
137 if self.focus == focus {
138 return false;
139 }
140 self.focus = focus;
141 if focus == PatchFocus::Patch {
142 self.cursor_line = 0;
143 self.patch_scroll = 0;
144 }
145 true
146 }
147
148 pub(crate) fn select_relative(&mut self, delta: isize) -> bool {
149 if self.focus != PatchFocus::FileList {
150 return false;
151 }
152
153 if let Some(tree) = &mut self.file_tree {
154 let prev_file = tree.selected_file_index();
155 tree.navigate(delta);
156 let new_file = tree.selected_file_index();
157 if let Some(idx) = new_file
158 && Some(idx) != prev_file.or(Some(self.selected_file))
159 {
160 self.selected_file = idx;
161 self.cursor_line = 0;
162 self.patch_scroll = 0;
163 self.invalidate_patch_cache();
164 return true;
165 }
166 return prev_file != new_file;
167 }
168
169 let file_count = self.file_count();
170 if file_count == 0 {
171 return false;
172 }
173
174 let previous = self.selected_file;
175 crate::components::wrap_selection(&mut self.selected_file, file_count, delta);
176 let changed = self.selected_file != previous;
177 if changed {
178 self.cursor_line = 0;
179 self.patch_scroll = 0;
180 }
181 changed
182 }
183
184 pub(crate) fn tree_collapse_or_parent(&mut self) {
185 if let Some(tree) = &mut self.file_tree {
186 tree.collapse_or_parent();
187 }
188 }
189
190 pub(crate) fn tree_expand_or_enter(&mut self) -> bool {
191 let Some(tree) = &mut self.file_tree else {
192 return true;
193 };
194 let is_file = tree.expand_or_enter();
195 if is_file
196 && let Some(idx) = tree.selected_file_index()
197 && idx != self.selected_file
198 {
199 self.selected_file = idx;
200 self.cursor_line = 0;
201 self.patch_scroll = 0;
202 self.invalidate_patch_cache();
203 }
204 is_file
205 }
206
207 pub(crate) fn ensure_file_list_visible(&mut self, viewport_height: usize) {
208 let Some(tree) = &self.file_tree else {
209 return;
210 };
211 let selected = tree.selected_visible();
212 if selected < self.file_list_scroll {
213 self.file_list_scroll = selected;
214 } else if selected >= self.file_list_scroll + viewport_height {
215 self.file_list_scroll = selected.saturating_sub(viewport_height - 1);
216 }
217 }
218
219 pub(crate) fn move_cursor(&mut self, delta: isize) -> bool {
220 if self.focus != PatchFocus::Patch {
221 return false;
222 }
223 let max = self.max_patch_scroll();
224 let next = if delta.is_negative() {
225 self.cursor_line.saturating_sub(delta.unsigned_abs())
226 } else {
227 (self.cursor_line + delta.unsigned_abs()).min(max)
228 };
229 let changed = next != self.cursor_line;
230 self.cursor_line = next;
231 changed
232 }
233
234 pub(crate) fn move_cursor_to_start(&mut self) -> bool {
235 if self.focus != PatchFocus::Patch {
236 return false;
237 }
238 let changed = self.cursor_line != 0;
239 self.cursor_line = 0;
240 changed
241 }
242
243 pub(crate) fn move_cursor_to_end(&mut self) -> bool {
244 if self.focus != PatchFocus::Patch {
245 return false;
246 }
247 let next = self.max_patch_scroll();
248 let changed = next != self.cursor_line;
249 self.cursor_line = next;
250 changed
251 }
252
253 pub(crate) fn jump_next_hunk(&mut self) -> bool {
254 if self.focus != PatchFocus::Patch {
255 return false;
256 }
257 let current = self.cursor_line;
258 if let Some(&next) = self.selected_hunk_offsets().iter().find(|&&o| o > current) {
259 let next = next.min(self.max_patch_scroll());
260 let changed = next != self.cursor_line;
261 self.cursor_line = next;
262 return changed;
263 }
264 false
265 }
266
267 pub(crate) fn jump_prev_hunk(&mut self) -> bool {
268 if self.focus != PatchFocus::Patch {
269 return false;
270 }
271 let current = self.cursor_line;
272 if let Some(&prev) = self
273 .selected_hunk_offsets()
274 .iter()
275 .rev()
276 .find(|&&o| o < current)
277 {
278 let changed = prev != self.cursor_line;
279 self.cursor_line = prev;
280 return changed;
281 }
282 false
283 }
284
285 pub(crate) fn ensure_cursor_visible(&mut self, viewport_height: usize) {
286 if viewport_height == 0 {
287 return;
288 }
289 if self.cursor_line < self.patch_scroll {
290 self.patch_scroll = self.cursor_line;
291 } else if self.cursor_line >= self.patch_scroll + viewport_height {
292 self.patch_scroll = self.cursor_line.saturating_sub(viewport_height - 1);
293 }
294 }
295
296 pub(crate) fn ensure_patch_cache(&mut self, context: &ViewContext) {
297 let width = context.size.width;
298 if self.cached_for_file == Some(self.selected_file) && self.cached_for_width == Some(width)
299 {
300 return;
301 }
302
303 let Some(file) = self.selected_file() else {
304 return;
305 };
306
307 if file.binary {
308 self.cached_patch_lines = Vec::new();
309 self.cached_patch_line_refs = Vec::new();
310 } else {
311 let use_split_patch =
312 should_use_split_patch(width as usize, self.sidebar_width_delta, file);
313 let (_left_width, right_width) = diff_layout(width as usize, self.sidebar_width_delta);
314
315 if use_split_patch {
316 let (lines, refs) = build_split_patch_lines(file, right_width, context);
317 self.cached_patch_lines = lines;
318 self.cached_patch_line_refs = refs;
319 } else {
320 let (lines, refs) = build_patch_lines(file, right_width, context);
321 self.cached_patch_lines = lines;
322 self.cached_patch_line_refs = refs;
323 }
324 }
325 self.cached_for_file = Some(self.selected_file);
326 self.cached_for_width = Some(width);
327 }
328
329 #[allow(dead_code)]
330 fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
331 if doc.files.is_empty() {
332 self.load_state = GitDiffLoadState::Empty;
333 self.invalidate_patch_cache();
334 return;
335 }
336
337 let file_count = doc.files.len();
338 self.file_tree = Some(FileTree::from_files(&doc.files));
339 self.file_list_scroll = 0;
340 self.load_state = GitDiffLoadState::Ready(doc);
341 self.selected_file = self.selected_file.min(file_count.saturating_sub(1));
342 self.invalidate_patch_cache();
343
344 if let Some(restore) = restore {
345 self.focus = restore.focus;
346 self.patch_scroll = 0;
347 if let (Some(path), GitDiffLoadState::Ready(doc)) =
348 (&restore.selected_path, &self.load_state)
349 {
350 self.selected_file = doc
351 .files
352 .iter()
353 .position(|file| file.path == *path)
354 .unwrap_or(0);
355 }
356 }
357 }
358}
359
360#[allow(dead_code)]
361struct RefreshState {
362 selected_path: Option<String>,
363 focus: PatchFocus,
364}
365
366#[allow(dead_code)]
367pub struct GitDiffMode {
368 working_dir: PathBuf,
369 cached_repo_root: Option<PathBuf>,
370 state: GitDiffViewState,
371 pending_restore: Option<RefreshState>,
372}
373
374impl GitDiffMode {
375 pub fn new(working_dir: PathBuf) -> Self {
376 Self {
377 working_dir,
378 cached_repo_root: None,
379 state: GitDiffViewState::new(GitDiffLoadState::Empty),
380 pending_restore: None,
381 }
382 }
383
384 pub(crate) fn begin_open(&mut self) {
385 self.pending_restore = None;
386 self.state = GitDiffViewState::new(GitDiffLoadState::Loading);
387 }
388
389 pub(crate) fn begin_refresh(&mut self) {
390 self.pending_restore = Some(RefreshState {
391 selected_path: self.state.selected_file_path().map(ToOwned::to_owned),
392 focus: self.state.focus,
393 });
394 self.state.load_state = GitDiffLoadState::Loading;
395 self.state.invalidate_patch_cache();
396 }
397
398 #[allow(dead_code)]
399 pub(crate) async fn complete_load(&mut self) {
400 match crate::git_diff::load_git_diff(&self.working_dir, self.cached_repo_root.as_deref())
401 .await
402 {
403 Ok(doc) => {
404 if self.cached_repo_root.is_none() {
405 self.cached_repo_root = Some(doc.repo_root.clone());
406 }
407 self.state
408 .apply_loaded_document(doc, self.pending_restore.take());
409 }
410 Err(error) => {
411 self.pending_restore = None;
412 self.state.load_state = GitDiffLoadState::Error {
413 message: error.to_string(),
414 };
415 self.state.invalidate_patch_cache();
416 }
417 }
418 }
419
420 pub(crate) fn close(&mut self) {
421 self.pending_restore = None;
422 self.state = GitDiffViewState::new(GitDiffLoadState::Empty);
423 }
424
425 pub(crate) async fn on_key_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
426 let mut view = GitDiffView {
427 state: &mut self.state,
428 };
429 let outcome = view.on_event(event).await;
430 outcome.unwrap_or_default()
431 }
432
433 pub(crate) fn render_lines(&self, context: &ViewContext) -> Vec<Line> {
434 GitDiffView::render_from_state(&self.state, context)
435 }
436
437 pub(crate) fn refresh_caches(&mut self, context: &ViewContext) {
438 self.state.ensure_patch_cache(context);
439 if let Some(tree) = &mut self.state.file_tree {
440 tree.ensure_cache();
441 }
442 let viewport_height = (context.size.height as usize).saturating_sub(2);
443 self.state.ensure_cursor_visible(viewport_height);
444 self.state.ensure_file_list_visible(viewport_height);
445 }
446
447 pub(crate) fn is_comment_input(&self) -> bool {
448 self.state.focus == PatchFocus::CommentInput
449 }
450
451 pub(crate) fn comment_cursor_col(&self) -> usize {
452 self.state.comment_cursor
453 }
454}
455
456pub(crate) fn format_review_prompt(comments: &[QueuedComment]) -> String {
457 use std::fmt::Write;
458
459 let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
460
461 let mut file_groups: Vec<(&str, Vec<&QueuedComment>)> = Vec::new();
462 for comment in comments {
463 if let Some(group) = file_groups
464 .iter_mut()
465 .find(|(path, _)| *path == comment.file_path)
466 {
467 group.1.push(comment);
468 } else {
469 file_groups.push((&comment.file_path, vec![comment]));
470 }
471 }
472
473 for (file_path, file_comments) in &file_groups {
474 write!(prompt, "\n## `{file_path}`\n").unwrap();
475
476 let mut hunk_groups: Vec<(usize, &str, Vec<&QueuedComment>)> = Vec::new();
477 for comment in file_comments {
478 if let Some(group) = hunk_groups
479 .iter_mut()
480 .find(|(idx, _, _)| *idx == comment.hunk_index)
481 {
482 group.2.push(comment);
483 } else {
484 hunk_groups.push((comment.hunk_index, &comment.hunk_text, vec![comment]));
485 }
486 }
487
488 for (_, hunk_text, hunk_comments) in &hunk_groups {
489 write!(prompt, "\n```diff\n{hunk_text}\n```\n").unwrap();
490
491 for comment in hunk_comments {
492 let kind_label = match comment.line_kind {
493 PatchLineKind::Added => "added",
494 PatchLineKind::Removed => "removed",
495 PatchLineKind::Context => "context",
496 PatchLineKind::HunkHeader => "header",
497 PatchLineKind::Meta => "meta",
498 };
499 let line_ref = match comment.line_number {
500 Some(n) => format!("Line {n} ({kind_label})"),
501 None => kind_label.to_string(),
502 };
503 write!(
504 prompt,
505 "\n**{line_ref}:** `{}`\n> {}\n",
506 comment.line_text, comment.comment
507 )
508 .unwrap();
509 }
510 }
511 }
512
513 prompt
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::git_diff::{FileStatus, Hunk, PatchLine, PatchLineKind};
520
521 fn make_doc(paths: &[&str]) -> GitDiffDocument {
522 GitDiffDocument {
523 repo_root: PathBuf::from("/tmp/repo"),
524 files: paths
525 .iter()
526 .map(|path| FileDiff {
527 old_path: None,
528 path: (*path).to_string(),
529 status: FileStatus::Modified,
530 hunks: vec![Hunk {
531 header: "@@ -1 +1 @@".to_string(),
532 old_start: 1,
533 old_count: 1,
534 new_start: 1,
535 new_count: 2,
536 lines: vec![
537 PatchLine {
538 kind: PatchLineKind::HunkHeader,
539 text: "@@ -1 +1 @@".to_string(),
540 old_line_no: None,
541 new_line_no: None,
542 },
543 PatchLine {
544 kind: PatchLineKind::Context,
545 text: "line one".to_string(),
546 old_line_no: Some(1),
547 new_line_no: Some(1),
548 },
549 PatchLine {
550 kind: PatchLineKind::Added,
551 text: "line two".to_string(),
552 old_line_no: None,
553 new_line_no: Some(2),
554 },
555 ],
556 }],
557 binary: false,
558 })
559 .collect(),
560 }
561 }
562
563 fn mode_with(paths: &[&str]) -> GitDiffMode {
564 let mut mode = GitDiffMode::new(PathBuf::from("."));
565 mode.state.load_state = GitDiffLoadState::Ready(make_doc(paths));
566 mode
567 }
568
569 fn comment(
570 file: &str,
571 hunk_text: &str,
572 line_text: &str,
573 line_number: usize,
574 kind: PatchLineKind,
575 comment: &str,
576 ) -> QueuedComment {
577 QueuedComment {
578 file_path: file.to_string(),
579 hunk_index: 0,
580 hunk_text: hunk_text.to_string(),
581 line_text: line_text.to_string(),
582 line_number: Some(line_number),
583 line_kind: kind,
584 comment: comment.to_string(),
585 }
586 }
587
588 #[test]
589 fn begin_refresh_preserves_selected_path_and_focus_after_load() {
590 let mut mode = mode_with(&["a.rs", "b.rs"]);
591 mode.state.selected_file = 1;
592 mode.state.focus = PatchFocus::Patch;
593 mode.begin_refresh();
594
595 mode.state
596 .apply_loaded_document(make_doc(&["c.rs", "b.rs"]), mode.pending_restore.take());
597
598 assert_eq!(mode.state.selected_file_path(), Some("b.rs"));
599 assert_eq!(mode.state.focus, PatchFocus::Patch);
600 assert_eq!(mode.state.patch_scroll, 0);
601 }
602
603 #[test]
604 fn format_review_prompt_groups_by_file() {
605 let hunk = "@@ -1,3 +1,3 @@\n fn main() {\n- old();\n+ new();\n }";
606 let comments = vec![
607 comment(
608 "src/foo.rs",
609 hunk,
610 " new();",
611 2,
612 PatchLineKind::Added,
613 "Looks risky",
614 ),
615 comment(
616 "src/foo.rs",
617 hunk,
618 " old();",
619 2,
620 PatchLineKind::Removed,
621 "Why remove this?",
622 ),
623 comment(
624 "src/bar.rs",
625 "@@ -1 +1 @@\n+new_line",
626 "new_line",
627 1,
628 PatchLineKind::Added,
629 "Needs a test",
630 ),
631 ];
632
633 let prompt = format_review_prompt(&comments);
634 assert!(
635 prompt.contains("## `src/foo.rs`"),
636 "should have foo.rs header"
637 );
638 assert!(
639 prompt.contains("## `src/bar.rs`"),
640 "should have bar.rs header"
641 );
642 assert_eq!(
643 prompt.matches("```diff").count(),
644 2,
645 "one hunk per file group"
646 );
647 for expected in [
648 "Looks risky",
649 "Why remove this?",
650 "Needs a test",
651 "Line 2 (added)",
652 "Line 2 (removed)",
653 "Line 1 (added)",
654 ] {
655 assert!(prompt.contains(expected), "missing: {expected}");
656 }
657 }
658}