Skip to main content

wisp/components/app/
git_diff_mode.rs

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}