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
62const DRAWER_MIN_COLUMNS: u16 = 16;
64
65type 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 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 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 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 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
377fn 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}