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::{
7    DiffScope, FileDiff, FileStatus, GitDiffDocument, GitDiffError, PatchLineKind, StageState, commit, load_git_diff,
8    stage_all, stage_files, unstage_all, unstage_files,
9};
10use std::collections::HashMap;
11use std::path::{Path, PathBuf};
12use tui::{
13    Component, Cursor, Either, Event, Frame, KeyCode, Line, SplitLayout, SplitPanel, Style, TextField, ViewContext,
14    display_width_text, truncate_line,
15};
16
17pub enum GitDiffViewMessage {
18    Close,
19    Refresh,
20    SubmitPrompt(String),
21}
22
23pub enum GitDiffLoadState {
24    Loading,
25    Ready(GitDiffDocument),
26    Empty,
27    Error { message: String },
28}
29
30#[derive(Debug, Clone)]
31pub(crate) struct GitDiffCommentContext {
32    pub file_path: String,
33    pub line_text: String,
34    pub line_number: Option<usize>,
35    pub line_kind: PatchLineKind,
36}
37
38#[derive(Debug, Clone)]
39pub(crate) struct QueuedComment {
40    pub review: ReviewComment<PatchAnchor>,
41    pub context: GitDiffCommentContext,
42}
43
44pub struct GitDiffMode {
45    working_dir: PathBuf,
46    cached_repo_root: Option<PathBuf>,
47    document_revision: usize,
48    diff_scope: DiffScope,
49    pub load_state: GitDiffLoadState,
50    split: SplitPanel<FileListPanel, GitDiffPanel>,
51    comments: ReviewQueue,
52    pending_focus: Option<FocusSide>,
53    bottom: BottomBar,
54}
55
56impl GitDiffMode {
57    pub fn new(working_dir: PathBuf) -> Self {
58        Self {
59            working_dir,
60            cached_repo_root: None,
61            document_revision: 0,
62            diff_scope: DiffScope::default(),
63            load_state: GitDiffLoadState::Empty,
64            split: SplitPanel::new(FileListPanel::new(), GitDiffPanel::new(), SplitLayout::fraction(1, 3, 20, 28))
65                .with_separator("│", Style::default())
66                .with_resize_keys(),
67            comments: ReviewQueue::default(),
68            pending_focus: None,
69            bottom: BottomBar::Help,
70        }
71    }
72
73    pub(crate) fn begin_open(&mut self) {
74        self.reset(GitDiffLoadState::Loading);
75    }
76
77    pub(crate) fn begin_refresh(&mut self) {
78        self.pending_focus = Some(if self.split.is_left_focused() { FocusSide::Left } else { FocusSide::Right });
79        self.load_state = GitDiffLoadState::Loading;
80        self.split.right_mut().clear_rendered_patches();
81    }
82
83    pub(crate) async fn complete_load(&mut self) {
84        match load_git_diff(&self.working_dir, self.cached_repo_root.as_deref(), self.diff_scope).await {
85            Ok(doc) => {
86                if self.cached_repo_root.is_none() {
87                    self.cached_repo_root = Some(doc.repo_root.clone());
88                }
89                let restore = self.pending_focus.take();
90                self.apply_loaded_document(doc, restore);
91            }
92            Err(error) => {
93                self.pending_focus = None;
94                self.load_state = GitDiffLoadState::Error { message: error.to_string() };
95                self.split.right_mut().clear_rendered_patches();
96            }
97        }
98    }
99
100    pub(crate) fn close(&mut self) {
101        self.reset(GitDiffLoadState::Empty);
102    }
103
104    fn reset(&mut self, load_state: GitDiffLoadState) {
105        self.pending_focus = None;
106        self.bottom = BottomBar::Help;
107        self.load_state = load_state;
108        *self.split.left_mut() = FileListPanel::new();
109        *self.split.right_mut() = GitDiffPanel::new();
110        self.comments.clear();
111        self.split.focus_left();
112    }
113
114    pub fn load_document(&mut self, doc: GitDiffDocument) {
115        self.apply_loaded_document(doc, None);
116    }
117
118    pub fn set_load_state(&mut self, state: GitDiffLoadState) {
119        self.load_state = state;
120    }
121}
122
123impl Component for GitDiffMode {
124    type Message = GitDiffViewMessage;
125
126    async fn on_event(&mut self, event: &Event) -> Option<Vec<GitDiffViewMessage>> {
127        match &self.bottom {
128            BottomBar::Commit(_) => return Some(self.on_commit_composer_event(event).await),
129            BottomBar::ConfirmDiscard(_) => return Some(self.on_discard_confirm_event(event).await),
130            BottomBar::Error(_) if matches!(event, Event::Key(_)) => self.bottom = BottomBar::Help,
131            _ => {}
132        }
133
134        if self.split.right().is_in_comment_mode() {
135            return Some(self.on_comment_mode_event(event).await);
136        }
137
138        if let Event::Key(key) = event {
139            match key.code {
140                KeyCode::Esc => return Some(vec![GitDiffViewMessage::Close]),
141                KeyCode::Char(' ') => return Some(self.toggle_stage_selected().await),
142                KeyCode::Char('a') => return Some(self.stage_all().await),
143                KeyCode::Char('A') => return Some(self.unstage_all().await),
144                KeyCode::Char('C') => return Some(self.begin_commit()),
145                KeyCode::Char('d') => return Some(self.request_discard()),
146                KeyCode::Char('r') => return Some(vec![GitDiffViewMessage::Refresh]),
147                KeyCode::Char('t') => {
148                    self.diff_scope = self.diff_scope.next();
149                    return Some(vec![GitDiffViewMessage::Refresh]);
150                }
151                KeyCode::Char('u') => {
152                    self.comments.pop();
153                    self.sync_comment_state();
154                    self.split.right_mut().invalidate_comment_splices();
155                    return Some(vec![]);
156                }
157                KeyCode::Char('s') if !self.split.is_left_focused() => {
158                    return Some(self.submit_review());
159                }
160                KeyCode::Char('h') | KeyCode::Left if !self.split.is_left_focused() => {
161                    self.split.focus_left();
162                    return Some(vec![]);
163                }
164                _ => {}
165            }
166        }
167
168        self.split.on_event(event).await.map(|msgs| self.handle_split_messages(msgs))
169    }
170
171    fn render(&mut self, context: &ViewContext) -> Frame {
172        let theme = &context.theme;
173        if context.size.width < 10 {
174            return Frame::new(vec![Line::new("Too narrow")]);
175        }
176
177        let bottom = self.render_bottom_bar(theme, context.size.width as usize);
178        let bottom_height = u16::try_from(bottom.lines().len()).unwrap_or(1).max(1);
179        let body_height = context.size.height.saturating_sub(bottom_height);
180        let body_context = context.with_size((context.size.width, body_height));
181
182        let status_msg = match &self.load_state {
183            GitDiffLoadState::Loading => Some("Loading...".to_string()),
184            GitDiffLoadState::Empty => Some("No changes in working tree relative to HEAD".to_string()),
185            GitDiffLoadState::Ready(doc) if doc.files.is_empty() => {
186                Some("No changes in working tree relative to HEAD".to_string())
187            }
188            GitDiffLoadState::Error { message } => Some(format!("Git diff unavailable: {message}")),
189            GitDiffLoadState::Ready(_) => None,
190        };
191
192        let body = if let Some(msg) = status_msg {
193            let height = body_height as usize;
194            let widths = self.split.widths(context.size.width);
195            let left_width = widths.left as usize;
196            let mut rows = Vec::with_capacity(height);
197            for i in 0..height {
198                let mut line = Line::default();
199                line.push_text(" ".repeat(left_width));
200                line.push_with_style("│", Style::fg(theme.muted()));
201                if i == 0 {
202                    line.push_with_style(&msg, Style::fg(theme.text_secondary()));
203                }
204                rows.push(line);
205            }
206            Frame::new(rows)
207        } else {
208            let left_focused = self.split.is_left_focused();
209            self.split.left_mut().set_focused(left_focused);
210            self.split.right_mut().set_focused(!left_focused);
211            self.prepare_right_panel_layers(&body_context);
212            self.split.set_separator_style(Style::fg(theme.muted()));
213            self.split.render(&body_context)
214        };
215
216        Frame::vstack([body, bottom])
217    }
218}
219
220impl GitDiffMode {
221    fn prepare_right_panel_layers(&mut self, context: &ViewContext) {
222        let GitDiffLoadState::Ready(doc) = &self.load_state else {
223            return;
224        };
225
226        let selected = self.split.left().selected_file_index().unwrap_or(0).min(doc.files.len().saturating_sub(1));
227        let file = &doc.files[selected];
228
229        let file_comments = self.comments.for_file(&file.path);
230
231        let right_width = self.split.widths(context.size.width).right;
232        self.split.right_mut().ensure_layers(file, &file_comments, right_width, self.document_revision);
233    }
234
235    fn on_file_selected(&mut self, idx: usize) {
236        self.split.left_mut().select_file_index(idx);
237        self.split.right_mut().reset_for_new_file();
238    }
239
240    async fn on_comment_mode_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
241        if let Some(msgs) = self.split.right_mut().on_event(event).await {
242            return self.handle_right_panel_messages(msgs);
243        }
244        vec![]
245    }
246
247    fn handle_split_messages(
248        &mut self,
249        msgs: Vec<Either<FileListMessage, GitDiffPanelMessage>>,
250    ) -> Vec<GitDiffViewMessage> {
251        let mut right_msgs = Vec::new();
252        for msg in msgs {
253            match msg {
254                Either::Left(FileListMessage::Selected(idx)) => {
255                    self.on_file_selected(idx);
256                }
257                Either::Left(FileListMessage::FileOpened(idx)) => {
258                    self.on_file_selected(idx);
259                    self.split.focus_right();
260                }
261                Either::Right(panel_msg) => right_msgs.push(panel_msg),
262            }
263        }
264        self.handle_right_panel_messages(right_msgs)
265    }
266
267    fn handle_right_panel_messages(&mut self, msgs: Vec<GitDiffPanelMessage>) -> Vec<GitDiffViewMessage> {
268        for msg in msgs {
269            let GitDiffPanelMessage::CommentSubmitted { anchor, text } = msg;
270            self.queue_comment(anchor, &text);
271        }
272        vec![]
273    }
274
275    fn queue_comment(&mut self, anchor: DiffAnchor, text: &str) {
276        let GitDiffLoadState::Ready(doc) = &self.load_state else {
277            return;
278        };
279        let CommentAnchor(PatchAnchor { hunk: hunk_index, line: line_index }) = anchor;
280        let selected = self.split.left().selected_file_index().unwrap_or(0);
281        let Some(file) = doc.files.get(selected) else {
282            return;
283        };
284        let Some(hunk) = file.hunks.get(hunk_index) else {
285            return;
286        };
287        let Some(patch_line) = hunk.lines.get(line_index) else {
288            return;
289        };
290
291        self.comments.push(QueuedComment {
292            review: ReviewComment::new(anchor, text),
293            context: GitDiffCommentContext {
294                file_path: file.path.clone(),
295                line_text: patch_line.text.clone(),
296                line_number: patch_line.new_line_no.or(patch_line.old_line_no),
297                line_kind: patch_line.kind,
298            },
299        });
300        self.sync_comment_state();
301        self.split.right_mut().invalidate_comment_splices();
302    }
303
304    fn sync_comment_state(&mut self) {
305        let counts = match &self.load_state {
306            GitDiffLoadState::Ready(doc) => self.comments.counts_for(&doc.files),
307            _ => vec![],
308        };
309        self.split.left_mut().sync_view_state(self.comments.len(), counts);
310    }
311
312    fn submit_review(&self) -> Vec<GitDiffViewMessage> {
313        if self.comments.is_empty() {
314            return vec![];
315        }
316        vec![GitDiffViewMessage::SubmitPrompt(self.comments.format_prompt())]
317    }
318
319    fn repo_root(&self) -> Option<&Path> {
320        match &self.load_state {
321            GitDiffLoadState::Ready(doc) => Some(&doc.repo_root),
322            _ => self.cached_repo_root.as_deref(),
323        }
324    }
325
326    fn repo_root_buf(&self) -> Option<PathBuf> {
327        self.repo_root().map(Path::to_path_buf)
328    }
329
330    fn selected_file(&self) -> Option<&FileDiff> {
331        let GitDiffLoadState::Ready(doc) = &self.load_state else {
332            return None;
333        };
334        let idx = self.split.left().selected_file_index()?;
335        doc.files.get(idx)
336    }
337
338    fn any_staged(&self) -> bool {
339        matches!(&self.load_state, GitDiffLoadState::Ready(doc)
340            if doc.files.iter().any(|file| matches!(file.staged, StageState::Staged | StageState::PartiallyStaged)))
341    }
342
343    async fn toggle_stage_selected(&mut self) -> Vec<GitDiffViewMessage> {
344        let (Some(root), GitDiffLoadState::Ready(doc)) = (self.repo_root_buf(), &self.load_state) else {
345            return vec![];
346        };
347        let files: Vec<&FileDiff> =
348            self.split.left().selected_file_indices().into_iter().filter_map(|index| doc.files.get(index)).collect();
349        if files.is_empty() {
350            return vec![];
351        }
352        let paths: Vec<&str> = files.iter().map(|file| file.path.as_str()).collect();
353        let result = if files.iter().all(|file| file.staged == StageState::Staged) {
354            unstage_files(&root, &paths).await
355        } else {
356            stage_files(&root, &paths).await
357        };
358        self.apply_action_result(result)
359    }
360
361    async fn stage_all(&mut self) -> Vec<GitDiffViewMessage> {
362        let Some(root) = self.repo_root_buf() else {
363            return vec![];
364        };
365        let result = stage_all(&root).await;
366        self.apply_action_result(result)
367    }
368
369    async fn unstage_all(&mut self) -> Vec<GitDiffViewMessage> {
370        let Some(root) = self.repo_root_buf() else {
371            return vec![];
372        };
373        let result = unstage_all(&root).await;
374        self.apply_action_result(result)
375    }
376
377    fn apply_action_result(&mut self, result: Result<(), GitDiffError>) -> Vec<GitDiffViewMessage> {
378        match result {
379            Ok(()) => vec![GitDiffViewMessage::Refresh],
380            Err(error) => {
381                self.bottom = BottomBar::Error(error.to_string());
382                vec![]
383            }
384        }
385    }
386
387    fn request_discard(&mut self) -> Vec<GitDiffViewMessage> {
388        let Some(file) = self.selected_file() else {
389            return vec![];
390        };
391        self.bottom = BottomBar::ConfirmDiscard(PendingDiscard { path: file.path.clone(), status: file.status });
392        vec![]
393    }
394
395    fn begin_commit(&mut self) -> Vec<GitDiffViewMessage> {
396        if !self.any_staged() {
397            self.bottom = BottomBar::Error("Nothing staged to commit".to_string());
398            return vec![];
399        }
400        self.bottom = BottomBar::Commit(TextField::new(String::new()));
401        vec![]
402    }
403
404    async fn on_commit_composer_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
405        if let Event::Key(key) = event {
406            match key.code {
407                KeyCode::Esc => {
408                    self.bottom = BottomBar::Help;
409                    return vec![];
410                }
411                KeyCode::Enter => {
412                    let message = match &self.bottom {
413                        BottomBar::Commit(field) => field.value.trim().to_string(),
414                        _ => String::new(),
415                    };
416                    if message.is_empty() {
417                        self.bottom = BottomBar::Error("Commit message cannot be empty".to_string());
418                        return vec![];
419                    }
420                    self.bottom = BottomBar::Help;
421                    let Some(root) = self.repo_root_buf() else {
422                        return vec![];
423                    };
424                    let result = commit(&root, &message).await;
425                    return self.apply_action_result(result);
426                }
427                _ => {}
428            }
429        }
430        if let BottomBar::Commit(field) = &mut self.bottom {
431            field.on_event(event).await;
432        }
433        vec![]
434    }
435
436    async fn on_discard_confirm_event(&mut self, event: &Event) -> Vec<GitDiffViewMessage> {
437        let Event::Key(key) = event else {
438            return vec![];
439        };
440        match key.code {
441            KeyCode::Char('y' | 'Y') => {
442                let BottomBar::ConfirmDiscard(PendingDiscard { path, status }) =
443                    std::mem::replace(&mut self.bottom, BottomBar::Help)
444                else {
445                    return vec![];
446                };
447                let Some(root) = self.repo_root_buf() else {
448                    return vec![];
449                };
450                let result = crate::git_diff::discard_file(&root, &path, status).await;
451                self.apply_action_result(result)
452            }
453            KeyCode::Char('n' | 'N') | KeyCode::Esc => {
454                self.bottom = BottomBar::Help;
455                vec![]
456            }
457            _ => vec![],
458        }
459    }
460
461    fn render_bottom_bar(&self, theme: &tui::Theme, width: usize) -> Frame {
462        match &self.bottom {
463            BottomBar::Commit(field) => {
464                let prefix_width = display_width_text("commit › ");
465                let avail = width.saturating_sub(prefix_width + 1).max(1);
466                let (visible, cursor_col) = field.single_line_window(avail);
467
468                let mut input = Line::default();
469                input.push_with_style("commit", Style::fg(theme.accent()).bold());
470                input.push_with_style(" › ", Style::fg(theme.muted()));
471                input.push_with_style(&visible, Style::fg(theme.text_primary()));
472                let hint = truncate_line(&render_key_hints(theme, &COMMIT_HELP_KEYS), width);
473                Frame::new(vec![input, hint]).with_cursor(Cursor::visible(0, prefix_width + cursor_col))
474            }
475            BottomBar::ConfirmDiscard(pending) => {
476                let mut line = Line::default();
477                line.push_with_style("Discard changes to ", Style::fg(theme.warning()));
478                line.push_with_style(&pending.path, Style::fg(theme.warning()).bold());
479                line.push_with_style("?  ", Style::fg(theme.warning()));
480                line.push_with_style("y", Style::fg(theme.accent()));
481                line.push_with_style(" confirm  ", Style::fg(theme.muted()));
482                line.push_with_style("n", Style::fg(theme.accent()));
483                line.push_with_style(" cancel", Style::fg(theme.muted()));
484                Frame::new(vec![truncate_line(&line, width)])
485            }
486            BottomBar::Error(error) => {
487                let mut line = Line::default();
488                line.push_with_style(error, Style::fg(theme.warning()));
489                Frame::new(vec![truncate_line(&line, width)])
490            }
491            BottomBar::Help => {
492                let help_keys: &[(&str, &str)] =
493                    if self.split.is_left_focused() { &LEFT_HELP_KEYS } else { &RIGHT_HELP_KEYS };
494                Frame::new(vec![truncate_line(&render_key_hints(theme, help_keys), width)])
495            }
496        }
497    }
498
499    fn apply_loaded_document(&mut self, doc: GitDiffDocument, restore: Option<FocusSide>) {
500        self.document_revision = self.document_revision.saturating_add(1);
501
502        if doc.files.is_empty() {
503            self.load_state = GitDiffLoadState::Empty;
504            self.split.right_mut().clear_rendered_patches();
505            return;
506        }
507
508        self.split.left_mut().set_diff_scope(self.diff_scope);
509        self.split.left_mut().rebuild_from_files(&doc.files);
510        self.split.right_mut().clear_rendered_patches();
511        self.split.right_mut().set_repo_root(doc.repo_root.clone());
512
513        if let Some(focus) = restore {
514            match focus {
515                FocusSide::Left => self.split.focus_left(),
516                FocusSide::Right => self.split.focus_right(),
517            }
518            self.split.right_mut().reset_scroll();
519        }
520
521        self.load_state = GitDiffLoadState::Ready(doc);
522        self.sync_comment_state();
523    }
524}
525
526const LEFT_HELP_KEYS: [(&str, &str); 7] = [
527    ("j/k", "move"),
528    ("space", "stage"),
529    ("t", "scope"),
530    ("A", "stage all"),
531    ("C", "commit"),
532    ("d", "discard"),
533    ("Esc", "close"),
534];
535
536const COMMIT_HELP_KEYS: [(&str, &str); 2] = [("enter", "commit"), ("esc", "cancel")];
537
538const RIGHT_HELP_KEYS: [(&str, &str); 8] = [
539    ("space", "stage"),
540    ("t", "scope"),
541    ("c", "comment"),
542    ("C", "commit"),
543    ("d", "discard"),
544    ("s", "submit"),
545    ("o", "full file"),
546    ("Esc", "close"),
547];
548
549enum BottomBar {
550    Help,
551    Commit(TextField),
552    ConfirmDiscard(PendingDiscard),
553    Error(String),
554}
555
556struct PendingDiscard {
557    path: String,
558    status: FileStatus,
559}
560
561#[derive(Default)]
562struct ReviewQueue {
563    comments: Vec<QueuedComment>,
564}
565
566impl ReviewQueue {
567    fn is_empty(&self) -> bool {
568        self.comments.is_empty()
569    }
570
571    fn len(&self) -> usize {
572        self.comments.len()
573    }
574
575    fn clear(&mut self) {
576        self.comments.clear();
577    }
578
579    fn push(&mut self, comment: QueuedComment) {
580        self.comments.push(comment);
581    }
582
583    fn pop(&mut self) -> Option<QueuedComment> {
584        self.comments.pop()
585    }
586
587    fn for_file(&self, path: &str) -> Vec<&QueuedComment> {
588        self.comments.iter().filter(|comment| comment.context.file_path == path).collect()
589    }
590
591    fn counts_for(&self, files: &[crate::git_diff::FileDiff]) -> Vec<usize> {
592        files
593            .iter()
594            .map(|file| self.comments.iter().filter(|comment| comment.context.file_path == file.path).count())
595            .collect()
596    }
597
598    fn format_prompt(&self) -> String {
599        use std::fmt::Write;
600
601        let mut prompt = String::from("I'm reviewing the working tree diff. Here are my comments:\n");
602        let mut file_order: Vec<&str> = Vec::new();
603        let mut grouped: HashMap<&str, Vec<&QueuedComment>> = HashMap::new();
604
605        for comment in &self.comments {
606            let path = comment.context.file_path.as_str();
607            if !grouped.contains_key(path) {
608                file_order.push(path);
609            }
610            grouped.entry(path).or_default().push(comment);
611        }
612
613        for file_path in file_order {
614            let file_comments = grouped.get(file_path).expect("group exists for ordered path");
615            write!(prompt, "\n## `{file_path}`\n").unwrap();
616
617            for comment in file_comments {
618                let kind_label = match comment.context.line_kind {
619                    PatchLineKind::Added => "added",
620                    PatchLineKind::Removed => "removed",
621                    PatchLineKind::Context => "context",
622                    PatchLineKind::HunkHeader => "header",
623                    PatchLineKind::Meta => "meta",
624                };
625                let line_ref = match comment.context.line_number {
626                    Some(n) => format!("Line {n} ({kind_label})"),
627                    None => kind_label.to_string(),
628                };
629                write!(prompt, "\n**{line_ref}:** `{}`\n> {}\n", comment.context.line_text, comment.review.body)
630                    .unwrap();
631            }
632        }
633
634        prompt
635    }
636}
637
638enum FocusSide {
639    Left,
640    Right,
641}