1use crate::{
4 DiffReviewState, DiffReviewStatus, FocusPane, RatatuiTheme, RepositoryOperationStatus,
5 annotation::render_annotation_line,
6 drawer::{DrawerEntry, DrawerTree},
7 patch_layout::PatchVisualRow,
8 state::{HitLayout, RepositoryPrompt},
9 style::syntax_style,
10 theme_picker::render_theme_picker,
11 ui::{ActionBar, AppFrame, EmptyState, Modal, ModalSize, NoticeTone, render_modal_text},
12 widgets::{render_vertical_scrollbar, rows_and_track},
13};
14use clankerdiff_core::{DiffTone, PresentedCell, PresentedRow, RowKind};
15use clankerdiff_syntax::{HighlightSpan, SyntaxHighlighter};
16use clankerdiff_theme::ReviewTheme;
17use ratatui::{
18 buffer::Buffer,
19 layout::{Constraint, Layout, Position, Rect},
20 style::{Modifier, Style},
21 text::{Line, Span},
22 widgets::{Paragraph, StatefulWidget, Widget},
23};
24
25const DRAWER_BREAKPOINT: u16 = 72;
26const DRAWER_MIN_WIDTH: u16 = 20;
27const DRAWER_MAX_WIDTH: u16 = 36;
28const GUTTER_WIDTH: u16 = 6;
29
30#[derive(Debug, Clone)]
32pub struct DiffReviewWidget {
33 title: String,
34 borders: bool,
35}
36
37impl Default for DiffReviewWidget {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl DiffReviewWidget {
44 #[must_use]
46 pub fn new() -> Self {
47 Self {
48 title: "Diff Review".to_owned(),
49 borders: true,
50 }
51 }
52
53 #[must_use]
55 pub fn title(mut self, title: impl Into<String>) -> Self {
56 self.title = title.into();
57 self
58 }
59
60 #[must_use]
62 pub const fn borders(mut self, borders: bool) -> Self {
63 self.borders = borders;
64 self
65 }
66}
67
68impl StatefulWidget for DiffReviewWidget {
69 type State = DiffReviewState;
70
71 fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
72 state.cursor_position = None;
73 state.hit_layout = HitLayout::default();
74 state.visible_rows.clear();
75 let theme = RatatuiTheme::from(&state.theme);
76 let regions = AppFrame::new(&self.title, self.borders, &theme)
77 .footer(state.options.footer || state.repository_prompt.is_some())
78 .render(area, buffer);
79 let body = regions.body;
80 let footer = regions.footer;
81 render_body(body, buffer, state, &theme);
82 render_footer(footer, buffer, state, &theme);
83 if state.help {
84 render_help(area, buffer, state, &theme);
85 }
86 if let Some(picker) = &state.theme_picker {
87 render_theme_picker(area, buffer, picker, &theme);
88 }
89 state.dirty = false;
90 }
91}
92
93fn render_body(area: Rect, buffer: &mut Buffer, state: &mut DiffReviewState, theme: &RatatuiTheme) {
94 let notice = match &state.status {
95 DiffReviewStatus::Loading => Some(("Loading diff…".to_owned(), NoticeTone::Info)),
96 DiffReviewStatus::Error(message) => {
97 Some((format!("Diff unavailable: {message}"), NoticeTone::Error))
98 }
99 DiffReviewStatus::Ready if state.document().files.is_empty() => Some((
100 format!("No changes (scope: {})", state.scope()),
101 NoticeTone::Info,
102 )),
103 DiffReviewStatus::Ready => None,
104 };
105 match notice {
106 Some((text, tone)) => EmptyState::new(&text, tone, theme).render(area, buffer),
107 None => render_document(area, buffer, state, theme),
108 }
109}
110
111fn render_document(
112 area: Rect,
113 buffer: &mut Buffer,
114 state: &mut DiffReviewState,
115 theme: &RatatuiTheme,
116) {
117 let drawer_width = state.options.navigation.width(
118 area.width,
119 DRAWER_BREAKPOINT,
120 (area.width / 3).clamp(DRAWER_MIN_WIDTH, DRAWER_MAX_WIDTH),
121 );
122 let (drawer, patch) = if drawer_width > 0 {
123 let [drawer, separator, patch] = Layout::horizontal([
124 Constraint::Length(drawer_width),
125 Constraint::Length(1),
126 Constraint::Min(1),
127 ])
128 .areas(area);
129 render_separator(separator, buffer, theme);
130 let drawer_hit = render_drawer(drawer, buffer, state, theme);
131 (drawer_hit, patch)
132 } else {
133 (Rect::default(), area)
134 };
135 state.hit_layout.drawer = drawer;
136 if drawer.is_empty() {
137 state.hit_layout.drawer_stage_column = None;
138 }
139 state.hit_layout.patch = patch;
140 let (patch_rows, patch_track) = rows_and_track(patch, true);
141 state.ensure_presentation(patch_rows.width);
142 render_patch(patch_rows, patch_track, buffer, state, theme);
143}
144
145fn render_separator(area: Rect, buffer: &mut Buffer, theme: &RatatuiTheme) {
146 Paragraph::new("│")
147 .style(Style::new().fg(theme.ui.border).bg(theme.ui.canvas))
148 .render(area, buffer);
149}
150
151#[expect(clippy::too_many_lines, reason = "tree row rendering is kept together")]
152fn render_drawer(
153 area: Rect,
154 buffer: &mut Buffer,
155 state: &mut DiffReviewState,
156 theme: &RatatuiTheme,
157) -> Rect {
158 let (rows, track) = rows_and_track(area, true);
159 state.hit_layout.drawer_stage_column = rows
160 .width
161 .checked_sub(1)
162 .map(|offset| rows.x.saturating_add(offset));
163 state.drawer_height = usize::from(rows.height).max(1);
164 let entry_count = state.drawer.entries().len();
165 state.drawer_scroll = state
166 .drawer_scroll
167 .min(entry_count.saturating_sub(state.drawer_height));
168 if state.take_drawer_follow_request() {
169 if state.drawer_selected < state.drawer_scroll {
170 state.drawer_scroll = state.drawer_selected;
171 } else if state.drawer_selected >= state.drawer_scroll.saturating_add(state.drawer_height) {
172 state.drawer_scroll = state
173 .drawer_selected
174 .saturating_sub(state.drawer_height.saturating_sub(1));
175 }
176 }
177 for (offset, entry) in state
178 .drawer
179 .entries()
180 .iter()
181 .skip(state.drawer_scroll)
182 .take(state.drawer_height)
183 .enumerate()
184 {
185 let y = rows
186 .y
187 .saturating_add(u16::try_from(offset).unwrap_or(u16::MAX));
188 let index = state.drawer_scroll.saturating_add(offset);
189 let row = Rect::new(rows.x, y, rows.width, 1);
190 let [content, stage_area] =
191 Layout::horizontal([Constraint::Min(0), Constraint::Length(2_u16.min(row.width))])
192 .areas(row);
193 let checkbox = Rect::new(
194 stage_area
195 .x
196 .saturating_add(stage_area.width.saturating_sub(1)),
197 y,
198 stage_area.width.min(1),
199 1,
200 );
201 let entry_stage = match entry {
202 DrawerEntry::Directory {
203 name,
204 depth,
205 expanded,
206 ..
207 } => {
208 let marker = if *expanded { "▾" } else { "▸" };
209 Paragraph::new(Line::from(vec![
210 Span::raw(format!("{}{} ", " ".repeat(*depth), marker)),
211 Span::styled(format!("{name}/"), Style::new().fg(theme.ui.accent)),
212 ]))
213 .render(content, buffer);
214 DrawerTree::stage_state_for_entry(state.document(), entry)
215 }
216 DrawerEntry::File {
217 index: file_index,
218 name,
219 depth,
220 } => {
221 let Some(file) = state.document().files.get(*file_index) else {
222 continue;
223 };
224 let status_color = match file.status {
225 clankerdiff_core::FileStatus::Added
226 | clankerdiff_core::FileStatus::Untracked => theme.addition,
227 clankerdiff_core::FileStatus::Deleted => theme.deletion,
228 clankerdiff_core::FileStatus::Modified
229 | clankerdiff_core::FileStatus::Renamed
230 | clankerdiff_core::FileStatus::Copied => theme.ui.accent,
231 };
232 Paragraph::new(Line::from(vec![
233 Span::raw(" ".repeat(*depth)),
234 Span::styled(
235 file.status.code().to_string(),
236 Style::new().fg(status_color),
237 ),
238 Span::raw(format!(" {name}")),
239 Span::styled(
240 format!(" +{} -{}", file.additions(), file.deletions()),
241 Style::new().fg(theme.ui.text_muted),
242 ),
243 ]))
244 .render(content, buffer);
245 file.staged
246 }
247 };
248 Paragraph::new(stage_marker(entry_stage)).render(checkbox, buffer);
249
250 if state.drawer_selected == index {
251 buffer.set_style(
252 row,
253 Style::new()
254 .fg(theme.ui.canvas)
255 .bg(theme.ui.accent)
256 .add_modifier(if state.focus == FocusPane::Files {
257 Modifier::BOLD
258 } else {
259 Modifier::default()
260 }),
261 );
262 }
263 }
264
265 render_vertical_scrollbar(
266 track,
267 buffer,
268 entry_count,
269 usize::from(rows.height),
270 state.drawer_scroll,
271 );
272 rows
273}
274
275const fn stage_marker(state: clankerdiff_core::StageState) -> &'static str {
276 match state {
277 clankerdiff_core::StageState::Unstaged => "☐",
278 clankerdiff_core::StageState::Staged => "☑",
279 clankerdiff_core::StageState::PartiallyStaged => "◩",
280 }
281}
282
283fn render_patch(
284 area: Rect,
285 track: Rect,
286 buffer: &mut Buffer,
287 state: &mut DiffReviewState,
288 theme: &RatatuiTheme,
289) {
290 if area.is_empty() {
291 return;
292 }
293 state.last_height = usize::from(area.height).max(1);
294 if state.take_follow_request() {
295 state.follow_selection();
296 }
297 let Some(visual_layout) = state.patch_visual_layout() else {
298 return;
299 };
300 let last_scroll = visual_layout.len().saturating_sub(state.last_height);
301 state.scroll = state.scroll.min(last_scroll);
302
303 let DiffReviewState {
304 session,
305 theme: diff_theme,
306 highlighter,
307 focus,
308 scroll,
309 visible_rows,
310 cursor_position,
311 ..
312 } = state;
313 visible_rows.clear();
314 let presentation = session.presentation();
315 let selected_row = session.selected_row();
316 let selected_side = session.selected_side();
317 let layout = session.layout();
318
319 for (drawn, visual_index) in (*scroll..visual_layout.len()).enumerate() {
320 let y = area
321 .y
322 .saturating_add(u16::try_from(drawn).unwrap_or(u16::MAX));
323 if y >= area.bottom() {
324 break;
325 }
326 let row_area = Rect::new(area.x, y, area.width, 1);
327 match visual_layout.row(visual_index) {
328 Some(PatchVisualRow::Source(index)) => {
329 let Some(row) = presentation.row(index) else {
330 continue;
331 };
332 let mut context = CellContext {
333 theme,
334 diff_theme,
335 highlighter,
336 presentation,
337 row,
338 };
339 let selected = selected_row == Some(index) && *focus == FocusPane::Diff;
340 render_row(
341 row_area,
342 buffer,
343 &mut context,
344 index,
345 row,
346 &RowStyle {
347 selected,
348 selected_side,
349 layout,
350 file_stats: file_stats(session, row),
351 },
352 );
353 visible_rows.push((y, index));
354 }
355 Some(PatchVisualRow::Annotation {
356 source,
357 annotation,
358 line,
359 }) => {
360 render_annotation_line(row_area, buffer, theme, annotation, line);
361 visible_rows.push((y, source));
362 if let Some(column) = annotation.cursor_column(line) {
363 *cursor_position = Some(Position::new(
364 area.x
365 .saturating_add(column)
366 .min(area.right().saturating_sub(1)),
367 y,
368 ));
369 }
370 }
371 None => break,
372 }
373 }
374 render_vertical_scrollbar(
375 track,
376 buffer,
377 visual_layout.len(),
378 usize::from(area.height),
379 *scroll,
380 );
381}
382
383fn file_stats(
384 session: &clankerdiff_core::ReviewSession,
385 row: &PresentedRow,
386) -> Option<(usize, usize)> {
387 (row.kind == RowKind::FileHeader)
388 .then(|| session.document().files.get(row.file_index))
389 .flatten()
390 .map(|file| (file.additions(), file.deletions()))
391}
392
393struct RowStyle {
394 selected: bool,
395 selected_side: clankerdiff_core::DiffSide,
396 layout: clankerdiff_core::Layout,
397 file_stats: Option<(usize, usize)>,
398}
399
400struct CellContext<'a> {
401 theme: &'a RatatuiTheme,
402 diff_theme: &'a ReviewTheme,
403 highlighter: &'a mut SyntaxHighlighter,
404 presentation: &'a clankerdiff_core::DiffPresentation,
405 row: &'a PresentedRow,
406}
407
408fn render_row(
409 area: Rect,
410 buffer: &mut Buffer,
411 context: &mut CellContext<'_>,
412 row_index: usize,
413 row: &PresentedRow,
414 style: &RowStyle,
415) {
416 match row.kind {
417 RowKind::FileHeader => {
418 let text = row.primary_cell().map_or("", |cell| cell.text.as_ref());
419 let (additions, deletions) = style.file_stats.unwrap_or_default();
420 Paragraph::new(Line::from(vec![
421 Span::styled(
422 format!(" {text} "),
423 Style::new()
424 .fg(context.theme.ui.accent)
425 .add_modifier(Modifier::BOLD),
426 ),
427 Span::styled(
428 format!("+{additions} -{deletions}"),
429 Style::new().fg(context.theme.ui.text_muted),
430 ),
431 ]))
432 .render(area, buffer);
433 }
434 RowKind::HunkHeader | RowKind::Meta => {
435 Paragraph::new(row.primary_cell().map_or("", |cell| cell.text.as_ref()))
436 .style(
437 Style::new()
438 .fg(context.theme.ui.text_muted)
439 .bg(context.theme.ui.canvas),
440 )
441 .render(area, buffer);
442 }
443 RowKind::ExpandGap => {
444 let text = context.presentation.gap_info(row_index).map_or_else(
445 || " ⋯ unchanged lines".to_owned(),
446 |info| {
447 let message = if info.unavailable.is_none() {
448 format!("{} — o expand · O expand all", info.message())
449 } else {
450 info.message()
451 };
452 format!(" {message}")
453 },
454 );
455 Paragraph::new(text)
456 .style(
457 Style::new()
458 .fg(context.theme.ui.text_muted)
459 .bg(if style.selected {
460 context.theme.ui.surface_selected
461 } else {
462 context.theme.ui.canvas
463 }),
464 )
465 .render(area, buffer);
466 }
467 RowKind::Code | RowKind::ExpandedContext if style.layout.is_split() => {
468 let left_width = area.width.saturating_sub(1) / 2;
469 let right_width = area.width.saturating_sub(left_width + 1);
470 let [left, separator, right] = Layout::horizontal([
471 Constraint::Length(left_width),
472 Constraint::Length(1),
473 Constraint::Length(right_width),
474 ])
475 .areas(area);
476 let focused = |side| style.selected && style.selected_side == side;
477 render_cell(
478 left,
479 buffer,
480 context,
481 row.left.as_ref(),
482 focused(clankerdiff_core::DiffSide::Old),
483 );
484 render_separator(separator, buffer, context.theme);
485 render_cell(
486 right,
487 buffer,
488 context,
489 row.right.as_ref(),
490 focused(clankerdiff_core::DiffSide::New),
491 );
492 }
493 RowKind::Code | RowKind::ExpandedContext => {
494 render_cell(area, buffer, context, row.primary_cell(), style.selected);
495 }
496 }
497}
498
499fn render_cell(
500 area: Rect,
501 buffer: &mut Buffer,
502 context: &mut CellContext<'_>,
503 cell: Option<&PresentedCell>,
504 selected: bool,
505) {
506 let tone = cell.map_or(DiffTone::Context, |cell| cell.tone);
507 let (foreground, tone_background) = context.theme.tone(tone);
508 let background = if selected {
509 context.theme.ui.surface_selected
510 } else {
511 tone_background
512 };
513 buffer.set_style(area, Style::new().fg(foreground).bg(background));
514 let Some(cell) = cell else {
515 return;
516 };
517 let number = cell
518 .line_number()
519 .map_or_else(String::new, |number| number.to_string());
520 let indicator = match tone {
521 DiffTone::Added | DiffTone::Removed => '▌',
522 DiffTone::Context | DiffTone::Meta => ' ',
523 };
524 let gutter = format!(
525 "{indicator}{number:>width$} ",
526 width = usize::from(GUTTER_WIDTH) - 2,
527 );
528 let highlights = crate::diff_preview::cell_highlights(
529 context.highlighter,
530 &context.diff_theme.syntax,
531 context.presentation,
532 context.row,
533 cell,
534 );
535 let gutter_foreground = match tone {
536 DiffTone::Added => context.theme.addition,
537 DiffTone::Removed => context.theme.deletion,
538 DiffTone::Context | DiffTone::Meta => context.theme.gutter,
539 };
540 let mut spans = vec![Span::styled(
541 gutter,
542 Style::new().fg(gutter_foreground).bg(background),
543 )];
544 spans.extend(highlighted_spans(&cell.text, &highlights, background));
545 Paragraph::new(Line::from(spans)).render(area, buffer);
546}
547
548fn highlighted_spans<'a>(
549 source: &'a str,
550 highlights: &[HighlightSpan],
551 background: ratatui::style::Color,
552) -> Vec<Span<'a>> {
553 if highlights.is_empty() {
554 return vec![Span::styled(source, Style::new().bg(background))];
555 }
556 let plain = Style::new().bg(background);
557 let mut spans = Vec::new();
558 let mut offset = 0;
559 for highlight in highlights {
560 let start = highlight.range.start.min(source.len());
561 let end = highlight.range.end.min(source.len());
562 if start > offset && source.is_char_boundary(offset) && source.is_char_boundary(start) {
563 spans.push(Span::styled(&source[offset..start], plain));
564 }
565 if end > start && source.is_char_boundary(start) && source.is_char_boundary(end) {
566 spans.push(Span::styled(
567 &source[start..end],
568 syntax_style(highlight.foreground, highlight.font_style, background),
569 ));
570 offset = end;
571 }
572 }
573 if offset < source.len() && source.is_char_boundary(offset) {
574 spans.push(Span::styled(&source[offset..], plain));
575 }
576 spans
577}
578
579fn render_footer(
580 area: Rect,
581 buffer: &mut Buffer,
582 state: &mut DiffReviewState,
583 theme: &RatatuiTheme,
584) {
585 if area.is_empty() {
586 return;
587 }
588 if let Some(prompt) = &state.repository_prompt {
589 match prompt {
590 RepositoryPrompt::Commit { message } => {
591 let prefix = "commit › ";
592 Paragraph::new(format!("{prefix}{message}"))
593 .style(Style::new().fg(theme.ui.text))
594 .render(area, buffer);
595 let x = area
596 .x
597 .saturating_add(u16::try_from(prefix.len() + message.len()).unwrap_or(u16::MAX))
598 .min(area.right().saturating_sub(1));
599 state.cursor_position = Some(Position::new(x, area.y));
600 }
601 RepositoryPrompt::Discard { path, status } => {
602 Paragraph::new(format!(
603 "Discard all staged and unstaged changes to {path} ({status:?})? [y/N]"
604 ))
605 .style(Style::new().fg(theme.deletion))
606 .render(area, buffer);
607 }
608 }
609 return;
610 }
611 if let Some(message) = state.repository_error() {
612 EmptyState::new(message, NoticeTone::Error, theme).render(area, buffer);
613 return;
614 }
615 let review = state.review();
616 let outdated = review.outdated_count();
617 let status = format!(
618 " {} comment{}{}",
619 review.len(),
620 if review.len() == 1 { "" } else { "s" },
621 if outdated == 0 {
622 String::new()
623 } else {
624 format!(" ({outdated} outdated)")
625 }
626 );
627 let hint = if matches!(state.repository_status, RepositoryOperationStatus::Pending) {
628 "Git operation in progress…".to_owned()
629 } else if state.session.draft().is_some() {
630 "[Enter] save [Shift-Enter] newline [Esc] cancel".to_owned()
631 } else {
632 state.footer_hint(usize::from(area.width).saturating_sub(status.len()))
633 };
634 ActionBar::new(
635 Line::from(vec![
636 Span::styled(hint, Style::new().fg(theme.ui.text_muted)),
637 Span::styled(status, Style::new().fg(theme.ui.accent)),
638 ]),
639 theme,
640 )
641 .render(area, buffer);
642}
643
644fn render_help(area: Rect, buffer: &mut Buffer, state: &DiffReviewState, theme: &RatatuiTheme) {
645 let content = Modal::new("Review shortcuts", ModalSize::Medium, theme)
646 .hint("j/k scroll · ? / Esc close")
647 .render(area, buffer);
648 render_modal_text(
649 content,
650 buffer,
651 state
652 .help_bindings()
653 .skip(state.help_scroll)
654 .map(|binding| format!("{} ({:?})", binding.hint(), binding.scope))
655 .collect::<Vec<_>>()
656 .join("\n"),
657 theme,
658 );
659}