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