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
25const DRAWER_MIN_COLUMNS: u16 = 16;
27
28type 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 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 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 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 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
344fn 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}