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