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