Skip to main content

wisp/components/app/
git_diff_mode.rs

1use crate::components::common::render_key_hints;
2use crate::components::file_list_panel::{FileListMessage, FileListPanel};
3use crate::components::git_diff::git_diff_panel::{GitDiffPanel, GitDiffPanelMessage};
4use crate::components::git_diff::{DiffAnchor, PatchAnchor};
5use crate::components::review_comments::{CommentAnchor, ReviewComment};
6use crate::git_diff::{GitDiffDocument, PatchLineKind, load_git_diff};
7use std::collections::HashMap;
8use std::path::PathBuf;
9use tui::{Component, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, ViewContext};
10
11pub enum GitDiffViewMessage {
12    Close,
13    Refresh,
14    SubmitPrompt(String),
15}
16
17pub enum GitDiffLoadState {
18    Loading,
19    Ready(GitDiffDocument),
20    Empty,
21    Error { message: String },
22}
23
24#[derive(Debug, Clone)]
25pub(crate) struct GitDiffCommentContext {
26    pub file_path: String,
27    pub line_text: String,
28    pub line_number: Option<usize>,
29    pub line_kind: PatchLineKind,
30}
31
32#[derive(Debug, Clone)]
33pub(crate) struct QueuedComment {
34    pub review: ReviewComment<PatchAnchor>,
35    pub context: GitDiffCommentContext,
36}
37
38pub struct GitDiffMode {
39    working_dir: PathBuf,
40    cached_repo_root: Option<PathBuf>,
41    document_revision: usize,
42    pub load_state: GitDiffLoadState,
43    split: SplitPanel<FileListPanel, GitDiffPanel>,
44    comments: ReviewQueue,
45    pending_restore: Option<RefreshState>,
46}
47
48impl GitDiffMode {
49    pub fn new(working_dir: PathBuf) -> Self {
50        Self {
51            working_dir,
52            cached_repo_root: None,
53            document_revision: 0,
54            load_state: GitDiffLoadState::Empty,
55            split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
56                .with_separator("│", Style::default())
57                .with_resize_keys(),
58            comments: ReviewQueue::default(),
59            pending_restore: None,
60        }
61    }
62
63    pub(crate) fn begin_open(&mut self) {
64        self.reset(GitDiffLoadState::Loading);
65    }
66
67    pub(crate) fn begin_refresh(&mut self) {
68        self.pending_restore = Some(RefreshState {
69            selected_path: self.selected_file_path().map(ToOwned::to_owned),
70            was_right_focused: !self.split.is_left_focused(),
71        });
72        self.load_state = GitDiffLoadState::Loading;
73        self.split.right_mut().clear_rendered_patches();
74    }
75
76    pub(crate) async fn complete_load(&mut self) {
77        match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref()).await {
78            Ok(doc) => {
79                if self.cached_repo_root.is_none() {
80                    self.cached_repo_root = Some(doc.repo_root.clone());
81                }
82                let restore = self.pending_restore.take();
83                self.apply_loaded_document(doc, restore);
84            }
85            Err(error) => {
86                self.pending_restore = None;
87                self.load_state = GitDiffLoadState::Error { message: error.to_string() };
88                self.split.right_mut().clear_rendered_patches();
89            }
90        }
91    }
92
93    pub(crate) fn close(&mut self) {
94        self.reset(GitDiffLoadState::Empty);
95    }
96
97    fn reset(&mut self, load_state: GitDiffLoadState) {
98        self.pending_restore = None;
99        self.load_state = load_state;
100        *self.split.left_mut() = FileListPanel::new();
101        *self.split.right_mut() = GitDiffPanel::new();
102        self.comments.clear();
103        self.split.focus_left();
104    }
105
106    pub fn load_document(&mut self, doc: GitDiffDocument) {
107        self.apply_loaded_document(doc, None);
108    }
109
110    pub fn set_load_state(&mut self, state: GitDiffLoadState) {
111        self.load_state = state;
112    }
113}
114
115impl Component for GitDiffMode {
116    type Message = GitDiffViewMessage;
117
118    async fn on_event(&mut self, event: &Event) -> Option<Vec<GitDiffViewMessage>> {
119        if self.split.right().is_in_comment_mode() {
120            return Some(self.on_comment_mode_event(event).await);
121        }
122
123        if let Event::Key(key) = event {
124            match key.code {
125                KeyCode::Esc => return Some(vec![GitDiffViewMessage::Close]),
126                KeyCode::Char('r') => return Some(vec![GitDiffViewMessage::Refresh]),
127                KeyCode::Char('u') => {
128                    self.comments.pop();
129                    self.sync_comment_state();
130                    self.split.right_mut().invalidate_comment_splices();
131                    return Some(vec![]);
132                }
133                KeyCode::Char('s') if !self.split.is_left_focused() => {
134                    return Some(self.submit_review());
135                }
136                KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
137                    self.split.focus_left();
138                    return Some(vec![]);
139                }
140                _ => {}
141            }
142        }
143
144        self.split.on_event(event).await.map(|msgs| self.handle_split_messages(msgs))
145    }
146
147    fn render(&mut self, context: &ViewContext) -> Frame {
148        let theme = &context.theme;
149        if context.size.width < 10 {
150            return Frame::new(vec![Line::new("Too narrow")]);
151        }
152
153        let body_height = context.size.height.saturating_sub(1);
154        let body_context = context.with_size((context.size.width, body_height));
155
156        let status_msg = match &self.load_state {
157            GitDiffLoadState::Loading => Some("Loading...".to_string()),
158            GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
159            GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
160                Some("No changes in working tree relative to HEAD".to_string())
161            }
162            GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
163            GitDiffLoadState::Ready(_) => None,
164        };
165
166        let body = if let Some(msg) = status_msg {
167            let height = body_height as usize;
168            let widths = self.split.widths(context.size.width);
169            let left_width = widths.left as usize;
170            let mut rows = Vec::with_capacity(height);
171            for i in 0..height {
172                let mut line = Line::default();
173                line.push_text(" ".repeat(left_width));
174                line.push_with_style("│", Style::fg(theme.muted()));
175                if i == 0 {
176                    line.push_with_style(&msg, Style::fg(theme.text_secondary()));
177                }
178                rows.push(line);
179            }
180            Frame::new(rows)
181        } else {
182            let left_focused = self.split.is_left_focused();
183            self.split.left_mut().set_focused(left_focused);
184            self.split.right_mut().set_focused(!left_focused);
185            self.prepare_right_panel_layers(&body_context);
186            self.split.set_separator_style(Style::fg(theme.muted()));
187            self.split.render(&body_context)
188        };
189
190        let help_keys: &[(&str, &str)] = if self.split.is_left_focused() { &LEFT_HELP_KEYS } else { &RIGHT_HELP_KEYS };
191        Frame::vstack([body, Frame::new(vec![render_key_hints(theme, help_keys)])])
192    }
193}
194
195impl GitDiffMode {
196    fn prepare_right_panel_layers(&mut self, context: &ViewContext) {
197        let GitDiffLoadState::Ready(doc) = &self.load_state else {
198            return;
199        };
200
201        let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
202        let file = &doc.files[selected];
203
204        let file_comments = self.comments.for_file(&file.path);
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.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.sync_comment_state();
276        self.split.right_mut().invalidate_comment_splices();
277    }
278
279    fn sync_comment_state(&mut self) {
280        let counts = match &self.load_state {
281            GitDiffLoadState::Ready(doc) => self.comments.counts_for(&doc.files),
282            _ => vec![],
283        };
284        self.split.left_mut().sync_view_state(self.comments.len(), counts);
285    }
286
287    fn submit_review(&self) -> Vec<GitDiffViewMessage> {
288        if self.comments.is_empty() {
289            return vec![];
290        }
291        vec![GitDiffViewMessage::SubmitPrompt(self.comments.format_prompt())]
292    }
293
294    fn selected_file_path(&self) -> Option<&str> {
295        let GitDiffLoadState::Ready(doc) = &self.load_state else {
296            return None;
297        };
298        let idx = self.split.left().selected_file_index()?;
299        doc.files.get(idx).map(|f| f.path.as_str())
300    }
301
302    fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<RefreshState>) {
303        self.document_revision = self.document_revision.saturating_add(1);
304
305        if doc.files.is_empty() {
306            self.load_state = GitDiffLoadState::Empty;
307            self.split.right_mut().clear_rendered_patches();
308            return;
309        }
310
311        self.split.left_mut().rebuild_from_files(&doc.files);
312        self.split.right_mut().clear_rendered_patches();
313        self.split.right_mut().set_repo_root(doc.repo_root.clone());
314
315        if let Some(restore) = restore {
316            if restore.was_right_focused {
317                self.split.focus_right();
318            } else {
319                self.split.focus_left();
320            }
321            self.split.right_mut().reset_scroll();
322            if let Some(path) = &restore.selected_path
323                && let Some(idx) = doc.files.iter().position(|file| file.path == *path)
324            {
325                self.split.left_mut().select_file_index(idx);
326            }
327        }
328
329        self.load_state = GitDiffLoadState::Ready(doc);
330        self.sync_comment_state();
331    }
332}
333
334const LEFT_HELP_KEYS: [(&str, &str); 6] =
335    [("j/k", "move"), ("h/l", "fold/open"), ("enter", "view"), ("u", "undo"), ("r", "refresh"), ("Esc", "close")];
336
337const RIGHT_HELP_KEYS: [(&str, &str); 8] = [
338    ("j/k", "move"),
339    ("h", "back"),
340    ("c", "comment"),
341    ("s", "submit"),
342    ("o", "full file"),
343    ("u", "undo"),
344    ("r", "refresh"),
345    ("Esc", "close"),
346];
347
348#[derive(Default)]
349struct ReviewQueue {
350    comments: Vec<QueuedComment>,
351}
352
353impl ReviewQueue {
354    fn is_empty(&self) -> bool {
355        self.comments.is_empty()
356    }
357
358    fn len(&self) -> usize {
359        self.comments.len()
360    }
361
362    fn clear(&mut self) {
363        self.comments.clear();
364    }
365
366    fn push(&mut self, comment: QueuedComment) {
367        self.comments.push(comment);
368    }
369
370    fn pop(&mut self) -> Option<QueuedComment> {
371        self.comments.pop()
372    }
373
374    fn for_file(&self, path: &str) -> Vec<&QueuedComment> {
375        self.comments.iter().filter(|comment| comment.context.file_path == path).collect()
376    }
377
378    fn counts_for(&self, files: &[crate::git_diff::FileDiff]) -> Vec<usize> {
379        files
380            .iter()
381            .map(|file| self.comments.iter().filter(|comment| comment.context.file_path == file.path).count())
382            .collect()
383    }
384
385    fn format_prompt(&self) -> String {
386        use std::fmt::Write;
387
388        let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
389        let mut file_order: Vec<&str> = Vec::new();
390        let mut grouped: HashMap<&str, Vec<&QueuedComment>> = HashMap::new();
391
392        for comment in &self.comments {
393            let path = comment.context.file_path.as_str();
394            if !grouped.contains_key(path) {
395                file_order.push(path);
396            }
397            grouped.entry(path).or_default().push(comment);
398        }
399
400        for file_path in file_order {
401            let file_comments = grouped.get(file_path).expect("group exists for ordered path");
402            write!(prompt, "\n## `{file_path}`\n").unwrap();
403
404            for comment in file_comments {
405                let kind_label = match comment.context.line_kind {
406                    PatchLineKind::Added => "added",
407                    PatchLineKind::Removed => "removed",
408                    PatchLineKind::Context => "context",
409                    PatchLineKind::HunkHeader => "header",
410                    PatchLineKind::Meta => "meta",
411                };
412                let line_ref = match comment.context.line_number {
413                    Some(n) => format!("Line {n} ({kind_label})"),
414                    None => kind_label.to_string(),
415                };
416                write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.context.line_text, comment.review.body)
417                    .unwrap();
418            }
419        }
420
421        prompt
422    }
423}
424
425struct RefreshState {
426    selected_path: Option<String>,
427    was_right_focused: bool,
428}