Skip to main content

wisp/screens/git_diff/
rendering.rs

1use ratatui::buffer::Buffer;
2use ratatui::layout::{Constraint, Layout, Position, Rect};
3use ratatui::style::{Modifier, Style};
4use ratatui::text::{Line, Span};
5use ratatui::widgets::{Block, Clear, Paragraph, StatefulWidget, Widget};
6use std::collections::HashSet;
7
8use crate::renderer::DrawContext;
9use crate::screens::annotation::{AnnotatedRows, Row, comment_body_width, comment_box, draft_body};
10use crate::git_review::{FileDiff, FileStatus, GitDiffEvent, PatchAnchor, PatchLineKind, StageState};
11use crate::screens::review::{
12    Pane, ShortcutGroup, body_and_footer, focused_title, render_command_bar, render_shortcut_help,
13};
14use crate::surfaces::input::{GitReviewOutput, MouseAction, UiEvent, is_press};
15use crate::view::diff::{DiffRowKind, DiffTone, diff_line, diff_rows};
16use crate::view::list_view::ListView;
17use crate::view::syntax::SyntaxHighlighter;
18use crate::theme::Theme;
19use crate::view::widgets::TextInput;
20use crate::view::wrap::{fit_line, wrap_text_char};
21
22use super::GitDiffScreen;
23use super::state::{BottomBar, DrawerEntry, FullFileView, GitDiffLoadState, PatchCursor, PatchRow};
24
25pub(super) const DRAWER_MIN_WIDTH: u16 = 72;
26
27const GIT_SHORTCUTS: &[ShortcutGroup] = &[
28    ShortcutGroup {
29        title: "Navigation",
30        hints: &[
31            ("j / k", "move or scroll"),
32            ("h / l", "change pane"),
33            ("Enter", "open file"),
34            ("PgUp / PgDn", "scroll a page"),
35        ],
36    },
37    ShortcutGroup {
38        title: "Review",
39        hints: &[("c", "add comment"), ("s", "submit review"), ("u", "undo comment")],
40    },
41    ShortcutGroup {
42        title: "Git",
43        hints: &[
44            ("Space", "stage file"),
45            ("a / A", "stage / unstage all"),
46            ("C", "Commit"),
47            ("d", "discard file"),
48        ],
49    },
50    ShortcutGroup {
51        title: "View",
52        hints: &[
53            ("t", "change scope"),
54            ("o", "show full file"),
55            ("r", "refresh"),
56            ("< / >", "resize file list"),
57            ("Esc / Ctrl-G", "close review"),
58        ],
59    },
60];
61
62/// Narrowest the drawer can be resized to before it stops being a usable file list.
63const DRAWER_MIN_COLUMNS: u16 = 16;
64
65/// The patch as rendered rows, with review comments woven in.
66type PatchRowSet = AnnotatedRows<PatchCursor>;
67
68impl GitDiffScreen {
69    pub(super) fn render_screen(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
70        let theme = cx.theme;
71        Clear.render(area, buf);
72        let block = Block::bordered()
73            .title(format!(" Git Diff · {} ", self.scope.label()))
74            .border_style(Style::new().fg(theme.accent).add_modifier(Modifier::BOLD));
75        let inner = block.inner(area);
76        block.render(area, buf);
77
78        let (body, footer) = body_and_footer(inner);
79        let cursor = match &self.state {
80            GitDiffLoadState::Loading { .. } => {
81                notice("Loading changes…", theme.muted).render(body, buf);
82                None
83            }
84            GitDiffLoadState::Error(message) => {
85                notice(format!("Git diff unavailable: {message}"), theme.error).render(body, buf);
86                None
87            }
88            GitDiffLoadState::Ready(document) if document.files.is_empty() => {
89                notice("No changes in working tree for this scope", theme.muted).render(body, buf);
90                None
91            }
92            GitDiffLoadState::Ready(_) => self.render_document(body, buf, cx),
93        };
94
95        let footer_cursor = self.render_footer(footer, buf, theme);
96        if self.shortcuts_open {
97            render_shortcut_help(area, buf, theme, GIT_SHORTCUTS);
98            None
99        } else {
100            footer_cursor.or(cursor)
101        }
102    }
103
104    fn render_document(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
105        if area.width < DRAWER_MIN_WIDTH {
106            return self.render_patch(area, buf, cx);
107        }
108        let [drawer, separator, patch] = Layout::horizontal([
109            Constraint::Length(self.drawer_width(area.width)),
110            Constraint::Length(1),
111            Constraint::Min(1),
112        ])
113        .areas(area);
114        self.render_drawer(drawer, buf, cx.theme);
115        Paragraph::new("│").style(Style::new().fg(cx.theme.muted)).render(separator, buf);
116        self.render_patch(patch, buf, cx)
117    }
118
119    fn render_drawer(&mut self, area: Rect, buf: &mut Buffer, theme: &Theme) {
120        let rows: Vec<Line<'static>> =
121            self.drawer_entries().iter().map(|entry| self.drawer_line(entry, theme)).collect();
122        let view = ListView::new(rows, theme)
123            .highlight_style(Style::new().fg(theme.background).bg(theme.accent).add_modifier(Modifier::BOLD))
124            .scrollbar();
125        StatefulWidget::render(view, area, buf, &mut self.drawer_selection);
126    }
127
128    fn drawer_line(&self, entry: &DrawerEntry, theme: &Theme) -> Line<'static> {
129        match entry {
130            DrawerEntry::Directory { path, depth } => {
131                let name = path.rsplit('/').next().unwrap_or(path);
132                let marker = if self.collapsed.contains(path) { "▸" } else { "▾" };
133                Line::from(vec![
134                    Span::raw(format!("{}{} ", "  ".repeat(*depth), marker)),
135                    Span::styled(format!("{name}/"), Style::new().fg(theme.info)),
136                ])
137            }
138            DrawerEntry::File { index, depth } => {
139                let Some(file) = self.file_at(*index) else {
140                    return Line::default();
141                };
142                let name = file.path.rsplit('/').next().unwrap_or(&file.path);
143                let stage = match file.staged {
144                    StageState::Unstaged => "☐",
145                    StageState::Staged => "☑",
146                    StageState::PartiallyStaged => "◩",
147                };
148                Line::from(vec![
149                    Span::raw(format!("{}{} ", "  ".repeat(*depth), stage)),
150                    Span::styled(
151                        file.status.marker().to_string(),
152                        Style::new().fg(match file.status {
153                            FileStatus::Modified => theme.warning,
154                            FileStatus::Added | FileStatus::Untracked => theme.diff_added_fg,
155                            FileStatus::Deleted => theme.diff_removed_fg,
156                            FileStatus::Renamed => theme.info,
157                        }),
158                    ),
159                    Span::raw(format!(" {name}")),
160                    Span::styled(format!(" +{} -{}", file.additions(), file.deletions()), Style::new().fg(theme.muted)),
161                ])
162            }
163        }
164    }
165
166    /// Drawer width for a body of `total` columns: a third of it, moved by
167    /// however far the reviewer has resized the drawer. The offset is re-anchored
168    /// to the width actually granted, so the resize keys bite on the first press
169    /// after the drawer has hit an edge. Only valid for a body at least
170    /// [`DRAWER_MIN_WIDTH`] wide, which is the only width that draws a drawer.
171    fn drawer_width(&self, total: u16) -> u16 {
172        let natural = (total / 3).clamp(24, 36);
173        natural.saturating_add_signed(self.drawer_offset).clamp(DRAWER_MIN_COLUMNS, total / 2)
174    }
175
176    fn render_patch(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
177        let theme = cx.theme;
178        let file = self.selected_file().cloned()?;
179        let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area);
180        Paragraph::new(self.patch_header(&file, theme)).render(header_area, buf);
181
182        if let Some(message) = self.patch_placeholder(&file) {
183            notice(message, theme.muted).render(content_area, buf);
184            return None;
185        }
186
187        let content_width = PatchRowSet::content_width(content_area.width);
188        let source = if self.full_file.is_on() {
189            self.build_full_file_rows(&file, content_width, theme, cx.highlighter)
190        } else {
191            build_patch_rows(&file, content_width, theme, cx.highlighter)
192        };
193        let mut rows = AnnotatedRows::default();
194        for row in &source {
195            rows.push_row(row);
196            if let Some(cursor) = row.anchor() {
197                self.push_annotations(
198                    &mut rows,
199                    PatchAnchor { file_index: self.selected_file, hunk: cursor.hunk, line: cursor.line },
200                    content_width,
201                    theme,
202                );
203            }
204        }
205
206        let mark_cursor = self.focus == Pane::Document && self.review.draft.is_none();
207        self.patch.document.render_rows(rows, content_area, buf, theme, mark_cursor);
208        self.patch.document.draft_cursor_position()
209    }
210
211    fn patch_header(&self, file: &FileDiff, theme: &Theme) -> Line<'static> {
212        let header_style = focused_title(self.focus == Pane::Document, theme);
213        let mut spans = vec![
214            Span::styled(format!(" {}  {}", file.path, file.status.label()), header_style),
215            Span::styled(format!("  +{} -{}", file.additions(), file.deletions()), Style::new().fg(theme.muted)),
216        ];
217
218        let comments = self.review.queue.comments_for_file(&file.path).count();
219        if self.full_file.is_on() {
220            spans.push(Span::styled("  [full file]", Style::new().fg(theme.info)));
221        } else if comments > 0 {
222            spans.push(Span::styled(format!("  {}", plural(comments, "comment")), Style::new().fg(theme.info)));
223        }
224        Line::from(spans)
225    }
226
227    /// Message to show instead of a patch, when there is nothing to diff.
228    fn patch_placeholder(&self, file: &FileDiff) -> Option<&'static str> {
229        match &self.full_file {
230            FullFileView::Off => file.binary.then_some("Binary file"),
231            _ if file.status == FileStatus::Deleted => Some("File has been deleted"),
232            _ if file.binary => Some("Binary file — cannot display contents"),
233            FullFileView::Loading => Some("Loading file…"),
234            FullFileView::Loaded(_) => None,
235        }
236    }
237
238    fn build_full_file_rows(
239        &self,
240        file: &FileDiff,
241        width: u16,
242        theme: &Theme,
243        highlighter: &mut SyntaxHighlighter,
244    ) -> Vec<PatchRow> {
245        let FullFileView::Loaded(content) = &self.full_file else {
246            return vec![Row::inert(Line::styled("Loading file…", Style::new().fg(theme.muted)))];
247        };
248        let language = file.language();
249        // Mark lines that the diff reports as added so the full-file view highlights them
250        // instead of rendering every line identically.
251        let added_lines: HashSet<usize> = file
252            .hunks
253            .iter()
254            .flat_map(|hunk| hunk.lines.iter())
255            .filter_map(|line| if line.kind == PatchLineKind::Added { line.new_line_no } else { None })
256            .collect();
257        content
258            .lines()
259            .enumerate()
260            .map(|(index, text)| {
261                let line_no = index + 1;
262                let tone = if added_lines.contains(&line_no) { DiffTone::Added } else { DiffTone::Context };
263                let rendered = diff_line(&format!("{line_no:>4} "), text, language, tone, theme, highlighter);
264                Row::at(
265                    fit_line(rendered.line, usize::from(width), rendered.fill),
266                    PatchCursor { hunk: 0, line: index },
267                )
268            })
269            .collect()
270    }
271
272    /// Appends the submitted comments and the in-progress draft anchored to the
273    /// patch line just pushed.
274    fn push_annotations(&self, rows: &mut PatchRowSet, anchor: PatchAnchor, width: u16, theme: &Theme) {
275        for comment in self.review.queue.comments().iter().filter(|comment| comment.anchor == anchor) {
276            rows.push_annotation(comment_box(
277                "┌─ Comment ─",
278                &wrap_text_char(&comment.body, comment_body_width(width)),
279                theme.info,
280                width,
281                theme,
282            ));
283        }
284
285        let Some(draft) = self.review.draft.as_ref().filter(|draft| draft.anchor == anchor) else {
286            return;
287        };
288        let (body, cursor) = draft_body(draft, comment_body_width(width));
289        rows.push_draft(comment_box("┌ Draft ─", &body, theme.accent, width, theme), cursor);
290    }
291
292    fn render_footer(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Option<Position> {
293        match &self.bottom_bar {
294            BottomBar::CommitEditor { buffer } => {
295                let input = TextInput::new(buffer)
296                    .prefix("commit › ")
297                    .prefix_style(Style::new().fg(theme.accent).add_modifier(Modifier::BOLD))
298                    .style(Style::new().fg(theme.text_primary));
299                let cursor = input.cursor_position(area);
300                input.render(area, buf);
301                Some(Position::new(cursor.x.min(area.right().saturating_sub(1)), cursor.y))
302            }
303            BottomBar::DiscardConfirmation { path, status } => {
304                Paragraph::new(Line::from(vec![
305                    Span::styled("Discard changes to ", Style::new().fg(theme.warning)),
306                    Span::styled(path.clone(), Style::new().fg(theme.warning).add_modifier(Modifier::BOLD)),
307                    Span::styled(format!(" ({})?  ", status.label()), Style::new().fg(theme.warning)),
308                    Span::styled("y", Style::new().fg(theme.accent)),
309                    Span::styled(" confirm  ", Style::new().fg(theme.muted)),
310                    Span::styled("n", Style::new().fg(theme.accent)),
311                    Span::styled(" cancel", Style::new().fg(theme.muted)),
312                ]))
313                .render(area, buf);
314                None
315            }
316            BottomBar::Error(error) => {
317                notice(error.clone(), theme.error).render(area, buf);
318                None
319            }
320            BottomBar::Help => {
321                if self.review.draft.is_some() {
322                    render_command_bar(
323                        area,
324                        buf,
325                        theme,
326                        &[("Enter", "add comment"), ("Esc", "cancel")],
327                        None,
328                        false,
329                    );
330                    return None;
331                }
332
333                let actions = if self.focus == Pane::Nav {
334                    &[("Enter", "open"), ("Space", "stage"), ("l", "diff")][..]
335                } else {
336                    &[("c", "comment"), ("s", "submit"), ("h", "files")][..]
337                };
338                let queued = self.review.queue.len();
339                let status = (queued > 0).then(|| plural(queued, "comment"));
340                render_command_bar(area, buf, theme, actions, status.as_deref(), true);
341                None
342            }
343        }
344    }
345}
346
347impl GitDiffScreen {
348    pub(crate) fn on_ui_event(&mut self, event: UiEvent) -> Vec<GitReviewOutput> {
349        match event {
350            UiEvent::Key(key) if is_press(key) => self.handle_key(key),
351            UiEvent::Key(_) => Vec::new(),
352            UiEvent::Paste(text) => {
353                self.handle_paste(&text);
354                Vec::new()
355            }
356            UiEvent::Mouse(action, (column, row)) => {
357                self.handle_mouse(action, row, column);
358                Vec::new()
359            }
360        }
361    }
362
363    pub fn on_event(&mut self, event: GitDiffEvent) -> Vec<GitReviewOutput> {
364        self.handle_event(event)
365    }
366
367    pub fn on_mouse(&mut self, action: MouseAction, row: u16, column: u16) -> Vec<GitReviewOutput> {
368        self.handle_mouse(action, row, column);
369        Vec::new()
370    }
371
372    pub fn render(&mut self, area: Rect, buf: &mut Buffer, cx: &mut DrawContext<'_>) -> Option<Position> {
373        self.render_screen(area, buf, cx)
374    }
375}
376
377/// The full-screen patch: every canonical diff row, cursor-navigable except for
378/// metadata rows, which anchor without taking the cursor.
379fn build_patch_rows(file: &FileDiff, width: u16, theme: &Theme, highlighter: &mut SyntaxHighlighter) -> Vec<PatchRow> {
380    diff_rows(file, width, theme, highlighter)
381        .into_iter()
382        .map(|row| {
383            let cursor = PatchCursor { hunk: row.hunk, line: row.index };
384            match row.kind {
385                DiffRowKind::Content | DiffRowKind::HunkHeader => Row::at(row.line, cursor),
386                DiffRowKind::Meta => Row::anchored(row.line, cursor),
387            }
388        })
389        .collect()
390}
391
392fn notice(text: impl Into<String>, color: ratatui::style::Color) -> Paragraph<'static> {
393    Paragraph::new(Line::styled(text.into(), Style::new().fg(color)))
394}
395
396pub(super) fn plural(count: usize, noun: &str) -> String {
397    format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
398}