Skip to main content

wisp/components/app/
git_diff_mode.rs

1use crate::components::file_list_panel::{FileListMessage, FileListPanel};
2use crate::components::git_diff::git_diff_panel::{GitDiffPanel, GitDiffPanelMessage};
3use crate::components::git_diff::{DiffAnchor, PatchAnchor};
4use crate::components::review_comments::{CommentAnchor, ReviewComment};
5use crate::git_diff::{GitDiffDocument, PatchLineKind, load_git_diff};
6use std::path::PathBuf;
7use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
8
9pub enum GitDiffViewMessage {
10    Close,
11    Refresh,
12    SubmitPrompt(String),
13}
14
15pub enum GitDiffLoadState {
16    Loading,
17    Ready(GitDiffDocument),
18    Empty,
19    Error { message: String },
20}
21
22#[derive(Debug, Clone)]
23pub(crate) struct GitDiffCommentContext {
24    pub file_path: String,
25    pub line_text: String,
26    pub line_number: Option<usize>,
27    pub line_kind: PatchLineKind,
28}
29
30#[derive(Debug, Clone)]
31pub(crate) struct QueuedComment {
32    pub review: ReviewComment<PatchAnchor>,
33    pub context: GitDiffCommentContext,
34}
35
36pub struct GitDiffMode {
37    working_dir: PathBuf,
38    cached_repo_root: Option<PathBuf>,
39    document_revision: usize,
40    pub load_state: GitDiffLoadState,
41    split: SplitPanel<FileListPanel, GitDiffPanel>,
42    queued_comments: Vec<QueuedComment>,
43    pending_restore: Option<RefreshState>,
44}
45
46impl GitDiffMode {
47    pub fn new(working_dir: PathBuf) -> Self {
48        Self {
49            working_dir,
50            cached_repo_root: None,
51            document_revision: 0,
52            load_state: GitDiffLoadState::Empty,
53            split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
54                .with_separator(" ", Style::default())
55                .with_resize_keys(),
56            queued_comments: Vec::new(),
57            pending_restore: None,
58        }
59    }
60
61    pub(crate) fn begin_open(&mut self) {
62        self.reset(GitDiffLoadState::Loading);
63    }
64
65    pub(crate) fn begin_refresh(&mut self) {
66        self.pending_restore = Some(RefreshState {
67            selected_path: self.selected_file_path().map(ToOwned::to_owned),
68            was_right_focused: !self.split.is_left_focused(),
69        });
70        self.load_state = GitDiffLoadState::Loading;
71        self.split.right_mut().invalidate_diff_layer();
72    }
73
74    pub(crate) async fn complete_load(&mut self) {
75        match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref()).await {
76            Ok(doc) => {
77                if self.cached_repo_root.is_none() {
78                    self.cached_repo_root = Some(doc.repo_root.clone());
79                }
80                let restore = self.pending_restore.take();
81                self.apply_loaded_document(doc, restore);
82            }
83            Err(error) => {
84                self.pending_restore = None;
85                self.load_state = GitDiffLoadState::Error { message: error.to_string() };
86                self.split.right_mut().invalidate_diff_layer();
87            }
88        }
89    }
90
91    pub(crate) fn close(&mut self) {
92        self.reset(GitDiffLoadState::Empty);
93    }
94
95    fn reset(&mut self, load_state: GitDiffLoadState) {
96        self.pending_restore = None;
97        self.load_state = load_state;
98        *self.split.left_mut() = FileListPanel::new();
99        *self.split.right_mut() = GitDiffPanel::new();
100        self.queued_comments.clear();
101        self.split.focus_left();
102    }
103
104    pub fn load_document(&mut self, doc: GitDiffDocument) {
105        self.apply_loaded_document(doc, None);
106    }
107
108    pub fn set_load_state(&mut self, state: GitDiffLoadState) {
109        self.load_state = state;
110    }
111}
112
113impl Component for GitDiffMode {
114    type Message = GitDiffViewMessage;
115
116    async fn on_event(&mut self, event: &Event) -> Option<Vec<GitDiffViewMessage>> {
117        if self.split.right().is_in_comment_mode() {
118            return Some(self.on_comment_mode_event(event).await);
119        }
120
121        if let Event::Key(key) = event {
122            match key.code {
123                KeyCode::Esc => return Some(vec![GitDiffViewMessage::Close]),
124                KeyCode::Char('r') => return Some(vec![GitDiffViewMessage::Refresh]),
125                KeyCode::Char('u') => {
126                    self.queued_comments.pop();
127                    self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
128                    self.split.right_mut().invalidate_submitted_comments_layer();
129                    return Some(vec![]);
130                }
131                KeyCode::Char('s') if !self.split.is_left_focused() => {
132                    return Some(self.submit_review());
133                }
134                KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
135                    self.split.focus_left();
136                    return Some(vec![]);
137                }
138                _ => {}
139            }
140        }
141
142        self.split.on_event(event).await.map(|msgs| self.handle_split_messages(msgs))
143    }
144
145    fn render(&mut self, context: &ViewContext) -> Frame {
146        let theme = &context.theme;
147        if context.size.width < 10 {
148            return Frame::new(vec![Line::new("Too narrow")]);
149        }
150
151        let body_height = context.size.height.saturating_sub(1);
152        let body_context = context.with_size((context.size.width, body_height));
153
154        let status_msg = match &self.load_state {
155            GitDiffLoadState::Loading => Some("Loading...".to_string()),
156            GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
157            GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
158                Some("No changes in working tree relative to HEAD".to_string())
159            }
160            GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
161            GitDiffLoadState::Ready(_) => None,
162        };
163
164        let body = if let Some(msg) = status_msg {
165            let height = body_height as usize;
166            let widths = self.split.widths(context.size.width);
167            let left_width = widths.left as usize;
168            let mut rows = Vec::with_capacity(height);
169            for i in 0..height {
170                let mut line = Line::default();
171                line.push_with_style(" ".repeat(left_width), Style::default().bg_color(theme.sidebar_bg()));
172                line.push_with_style(" ", Style::default().bg_color(theme.background()));
173                if i == 0 {
174                    line.push_with_style(&msg, Style::fg(theme.text_secondary()));
175                }
176                rows.push(line);
177            }
178            Frame::new(rows)
179        } else {
180            self.prepare_right_panel_layers(&body_context);
181            self.split.set_separator_style(Style::default().bg_color(theme.background()));
182            self.split.render(&body_context)
183        };
184
185        let mut help = Line::default();
186        help.push_with_style(
187            "j/k:move  n/p:hunk  h/l:focus  c:comment  s:submit  u:undo  r:refresh  Esc:close",
188            Style::fg(theme.muted()),
189        );
190        Frame::vstack([body, Frame::new(vec![help])])
191    }
192}
193
194impl GitDiffMode {
195    fn prepare_right_panel_layers(&mut self, context: &ViewContext) {
196        let GitDiffLoadState::Ready(doc) = &self.load_state else {
197            return;
198        };
199
200        let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
201        let file = &doc.files[selected];
202
203        let file_comments =
204            self.queued_comments.iter().filter(|comment| comment.context.file_path == file.path).collect::<Vec<_>>();
205
206        let right_width = self.split.widths(context.size.width).right;
207        self.split.right_mut().ensure_layers(file, &file_comments, right_width, self.document_revision);
208    }
209
210    fn on_file_selected(&mut self, idx: usize) {
211        self.split.left_mut().select_file_index(idx);
212        self.split.right_mut().reset_for_new_file();
213    }
214
215    async fn on_comment_mode_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
216        if let Some(msgs) = self.split.right_mut().on_event(event).await {
217            return self.handle_right_panel_messages(msgs);
218        }
219        vec![]
220    }
221
222    fn handle_split_messages(
223        &mut self,
224        msgs: Vec<Either<FileListMessage, GitDiffPanelMessage>>,
225    ) -> Vec<GitDiffViewMessage> {
226        let mut right_msgs = Vec::new();
227        for msg in msgs {
228            match msg {
229                Either::Left(FileListMessage::Selected(idx)) => {
230                    self.on_file_selected(idx);
231                }
232                Either::Left(FileListMessage::FileOpened(idx)) => {
233                    self.on_file_selected(idx);
234                    self.split.focus_right();
235                }
236                Either::Right(panel_msg) => right_msgs.push(panel_msg),
237            }
238        }
239        self.handle_right_panel_messages(right_msgs)
240    }
241
242    fn handle_right_panel_messages(&mut self, msgs: Vec<GitDiffPanelMessage>) -> Vec<GitDiffViewMessage> {
243        for msg in msgs {
244            let GitDiffPanelMessage::CommentSubmitted { anchor, text } = msg;
245            self.queue_comment(anchor, &text);
246        }
247        vec![]
248    }
249
250    fn queue_comment(&mut self, anchor: DiffAnchor, text: &str) {
251        let GitDiffLoadState::Ready(doc) = &self.load_state else {
252            return;
253        };
254        let CommentAnchor(PatchAnchor { hunk: hunk_index, line: line_index }) = anchor;
255        let selected = self.split.left().selected_file_index().unwrap_or(0);
256        let Some(file) = doc.files.get(selected) else {
257            return;
258        };
259        let Some(hunk) = file.hunks.get(hunk_index) else {
260            return;
261        };
262        let Some(patch_line) = hunk.lines.get(line_index) else {
263            return;
264        };
265
266        self.queued_comments.push(QueuedComment {
267            review: ReviewComment::new(anchor, text),
268            context: GitDiffCommentContext {
269                file_path: file.path.clone(),
270                line_text: patch_line.text.clone(),
271                line_number: patch_line.new_line_no.or(patch_line.old_line_no),
272                line_kind: patch_line.kind,
273            },
274        });
275        self.split.left_mut().set_queued_comment_count(self.queued_comments.len());
276        self.split.right_mut().invalidate_submitted_comments_layer();
277    }
278
279    fn submit_review(&self) -> Vec<GitDiffViewMessage> {
280        if self.queued_comments.is_empty() {
281            return vec![];
282        }
283        let prompt = format_review_prompt(&self.queued_comments);
284        vec![GitDiffViewMessage::SubmitPrompt(prompt)]
285    }
286
287    fn selected_file_path(&self) -> Option<&str> {
288        let GitDiffLoadState::Ready(doc) = &self.load_state else {
289            return None;
290        };
291        let idx = self.split.left().selected_file_index()?;
292        doc.files.get(idx).map(|f| f.path.as_str())
293    }
294
295    fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
296        self.document_revision = self.document_revision.saturating_add(1);
297
298        if doc.files.is_empty() {
299            self.load_state = GitDiffLoadState::Empty;
300            self.split.right_mut().invalidate_diff_layer();
301            return;
302        }
303
304        self.split.left_mut().rebuild_from_files(&doc.files);
305        self.split.right_mut().invalidate_diff_layer();
306
307        if let Some(restore) = restore {
308            if restore.was_right_focused {
309                self.split.focus_right();
310            } else {
311                self.split.focus_left();
312            }
313            self.split.right_mut().reset_scroll();
314            if let Some(path) = &restore.selected_path
315                && let Some(idx) = doc.files.iter().position(|file| file.path == *path)
316            {
317                self.split.left_mut().select_file_index(idx);
318            }
319        }
320
321        self.load_state = GitDiffLoadState::Ready(doc);
322    }
323}
324
325struct RefreshState {
326    selected_path: Option<String>,
327    was_right_focused: bool,
328}
329
330pub(crate) fn format_review_prompt(comments: &[QueuedComment]) -> String {
331    use std::fmt::Write;
332
333    let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
334
335    let mut file_groups: Vec<(&str, Vec<&QueuedComment>)> = Vec::new();
336    for comment in comments {
337        if let Some(group) = file_groups.iter_mut().find(|(path, _)| *path == comment.context.file_path) {
338            group.1.push(comment);
339        } else {
340            file_groups.push((&comment.context.file_path, vec![comment]));
341        }
342    }
343
344    for (file_path, file_comments) in &file_groups {
345        write!(prompt, "\n## `{file_path}`\n").unwrap();
346
347        for comment in file_comments {
348            let kind_label = match comment.context.line_kind {
349                PatchLineKind::Added => "added",
350                PatchLineKind::Removed => "removed",
351                PatchLineKind::Context => "context",
352                PatchLineKind::HunkHeader => "header",
353                PatchLineKind::Meta => "meta",
354            };
355            let line_ref = match comment.context.line_number {
356                Some(n) => format!("Line {n} ({kind_label})"),
357                None => kind_label.to_string(),
358            };
359            write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.context.line_text, comment.review.body).unwrap();
360        }
361    }
362
363    prompt
364}