1use crate::{
2 DiffReviewCommand, KeyBinding, NavigationPane, ReviewOptions, ThemeChoice,
3 default_diff_keybindings,
4 drawer::{DrawerEntry, DrawerTree},
5 patch_layout::PatchVisualLayout,
6 theme_picker::ThemePicker,
7};
8use clankerdiff_core::{
9 DiffDocument, DiffPresentation, DiffScope, DiffSide, FileStatus, Layout, RepositoryAction,
10 RevealAmount, Review, ReviewCapabilities, ReviewSession, StageState, ViewMode,
11};
12use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
13use clankerdiff_theme::ReviewTheme;
14use ratatui::layout::{Position, Rect};
15use std::{
16 collections::hash_map::DefaultHasher,
17 hash::{Hash, Hasher},
18 sync::Arc,
19};
20
21pub(crate) const SPLIT_BREAKPOINT: u16 = 96;
22
23pub use clankerdiff_core::FocusPane;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DiffReviewStatus {
28 Loading,
30 Ready,
32 Error(String),
34}
35
36#[derive(Debug, Clone, Copy, Default)]
37pub(crate) struct HitLayout {
38 pub drawer: Rect,
39 pub drawer_stage_column: Option<u16>,
40 pub patch: Rect,
41}
42
43#[derive(Debug, Clone, Copy, Default)]
44enum DrawerFollow {
45 #[default]
46 Pending,
47 Settled,
48}
49
50#[derive(Debug)]
51pub(crate) enum RepositoryPrompt {
52 Commit {
53 message: String,
54 },
55 Discard {
56 path: clankerdiff_core::RepoPath,
57 status: FileStatus,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub enum RepositoryOperationStatus {
64 #[default]
65 Idle,
66 Pending,
67 Error(String),
68}
69
70#[derive(Debug)]
71struct CachedPatchLayout {
72 key: u64,
73 layout: Arc<PatchVisualLayout>,
74}
75
76#[derive(Debug)]
78pub struct DiffReviewState {
79 pub(crate) session: ReviewSession,
80 pub(crate) scope: DiffScope,
81 pub(crate) options: ReviewOptions,
82 pub(crate) capabilities: ReviewCapabilities,
83 pub(crate) keybindings: Vec<KeyBinding<DiffReviewCommand>>,
84 pub(crate) theme_choices: Arc<[ThemeChoice]>,
85 pub(crate) theme: ReviewTheme,
86 pub(crate) highlighter: SyntaxHighlighter,
87 pub(crate) status: DiffReviewStatus,
88 pub(crate) focus: FocusPane,
89 pub(crate) drawer: DrawerTree,
90 pub(crate) drawer_selected: usize,
91 pub(crate) drawer_scroll: usize,
92 pub(crate) drawer_height: usize,
93 drawer_follow: DrawerFollow,
94 pub(crate) scroll: usize,
95 pub(crate) last_height: usize,
96 pub(crate) presentation_width: u16,
97 patch_layout: Option<CachedPatchLayout>,
98 pub(crate) help: bool,
99 pub(crate) help_scroll: usize,
100 pub(crate) theme_picker: Option<ThemePicker>,
101 pub(crate) repository_prompt: Option<RepositoryPrompt>,
102 pub(crate) repository_status: RepositoryOperationStatus,
103 pub(crate) background_error: Option<String>,
104 pub(crate) hit_layout: HitLayout,
105 pub(crate) visible_rows: Vec<(u16, usize)>,
106 pub(crate) cursor_position: Option<Position>,
107 pub(crate) follow_pending: bool,
108 pub(crate) dirty: bool,
109}
110
111impl DiffReviewState {
112 #[must_use]
114 pub fn new(document: Arc<DiffDocument>) -> Self {
115 Self::with_theme(document, ReviewTheme::default())
116 }
117
118 #[must_use]
120 pub fn with_theme(document: Arc<DiffDocument>, theme: ReviewTheme) -> Self {
121 let drawer = DrawerTree::new(&document);
122 let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
123 let mut state = Self {
124 session: ReviewSession::new(document),
125 scope: DiffScope::Both,
126 options: ReviewOptions::default(),
127 capabilities: ReviewCapabilities::default(),
128 keybindings: default_diff_keybindings(),
129 theme_choices: Arc::from([]),
130 theme,
131 highlighter: SyntaxHighlighter::default(),
132 status: DiffReviewStatus::Ready,
133 focus: FocusPane::Files,
134 drawer,
135 drawer_selected,
136 drawer_scroll: 0,
137 drawer_height: 1,
138 drawer_follow: DrawerFollow::Pending,
139 scroll: 0,
140 last_height: 0,
141 presentation_width: 0,
142 patch_layout: None,
143 help: false,
144 help_scroll: 0,
145 theme_picker: None,
146 repository_prompt: None,
147 repository_status: RepositoryOperationStatus::Idle,
148 background_error: None,
149 hit_layout: HitLayout::default(),
150 visible_rows: Vec::new(),
151 cursor_position: None,
152 follow_pending: true,
153 dirty: true,
154 };
155 state.scroll_to_selected_file();
156 state
157 }
158
159 #[must_use]
161 pub fn loading() -> Self {
162 let mut state = Self::new(Arc::new(DiffDocument::empty()));
163 state.status = DiffReviewStatus::Loading;
164 state
165 }
166
167 #[must_use]
168 pub const fn session(&self) -> &ReviewSession {
169 &self.session
170 }
171
172 pub const fn session_mut(&mut self) -> &mut ReviewSession {
173 &mut self.session
174 }
175
176 #[must_use]
177 pub const fn scope(&self) -> DiffScope {
178 self.scope
179 }
180
181 pub fn set_scope(&mut self, scope: DiffScope) {
182 self.scope = scope;
183 self.mark_dirty();
184 }
185
186 #[must_use]
188 pub const fn document(&self) -> &Arc<DiffDocument> {
189 self.session.document()
190 }
191
192 #[must_use]
194 pub const fn review(&self) -> &Review {
195 self.session.review()
196 }
197
198 pub const fn review_mut(&mut self) -> &mut Review {
200 self.session.review_mut()
201 }
202
203 #[must_use]
204 pub const fn presentation(&self) -> &DiffPresentation {
205 self.session.presentation()
206 }
207
208 #[must_use]
210 pub const fn status(&self) -> &DiffReviewStatus {
211 &self.status
212 }
213
214 #[must_use]
216 pub const fn focus(&self) -> FocusPane {
217 self.focus
218 }
219
220 #[must_use]
222 pub fn selected_file(&self) -> Option<usize> {
223 self.session.selected_file()
224 }
225
226 #[must_use]
228 pub fn selected_row(&self) -> Option<usize> {
229 self.session.selected_row()
230 }
231
232 #[must_use]
233 pub const fn selected_side(&self) -> DiffSide {
234 self.session.selected_side()
235 }
236
237 #[must_use]
239 pub const fn theme(&self) -> &ReviewTheme {
240 &self.theme
241 }
242
243 #[must_use]
245 pub const fn highlight_stats(&self) -> HighlightStats {
246 self.highlighter.stats()
247 }
248
249 #[must_use]
251 pub const fn scroll_offset(&self) -> usize {
252 self.scroll
253 }
254
255 #[must_use]
262 pub const fn is_dirty(&self) -> bool {
263 self.dirty
264 }
265
266 pub const fn mark_dirty(&mut self) {
269 self.dirty = true;
270 }
271
272 #[must_use]
274 pub const fn view_mode(&self) -> ViewMode {
275 self.session.view_mode()
276 }
277
278 #[must_use]
279 pub const fn layout(&self) -> Layout {
280 self.session.layout()
281 }
282
283 #[must_use]
287 pub const fn cursor_position(&self) -> Option<Position> {
288 self.cursor_position
289 }
290
291 pub fn set_document(&mut self, document: Arc<DiffDocument>) {
293 self.session.set_document(document);
294 self.status = DiffReviewStatus::Ready;
295 self.cursor_position = None;
296 self.mark_dirty();
297 let document = self.document().clone();
298 self.drawer.rebuild(&document);
299 if let Some(selected) = self.session.selected_file() {
300 self.drawer.expand_file(&document, selected);
301 self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
302 } else {
303 self.drawer_selected = 0;
304 }
305 self.follow_drawer_selection();
306 self.request_follow();
307 }
308
309 pub fn set_loading(&mut self) {
311 self.status = DiffReviewStatus::Loading;
312 self.session.cancel_draft();
313 self.cursor_position = None;
314 self.mark_dirty();
315 }
316
317 pub fn set_error(&mut self, message: impl Into<String>) {
319 self.status = DiffReviewStatus::Error(message.into());
320 self.session.cancel_draft();
321 self.cursor_position = None;
322 self.mark_dirty();
323 }
324
325 pub fn set_repository_pending(&mut self) {
327 self.repository_status = RepositoryOperationStatus::Pending;
328 self.repository_prompt = None;
329 self.mark_dirty();
330 }
331
332 pub fn clear_repository_pending(&mut self) {
334 self.repository_status = RepositoryOperationStatus::Idle;
335 self.mark_dirty();
336 }
337
338 pub fn set_repository_error(&mut self, message: impl Into<String>) {
340 self.repository_status = RepositoryOperationStatus::Error(message.into());
341 self.repository_prompt = None;
342 self.mark_dirty();
343 }
344
345 pub fn set_background_error(&mut self, message: Option<String>) {
347 if self.background_error != message {
348 self.background_error = message;
349 self.mark_dirty();
350 }
351 }
352
353 #[must_use]
355 pub fn repository_pending(&self) -> bool {
356 matches!(self.repository_status, RepositoryOperationStatus::Pending)
357 }
358
359 #[must_use]
361 pub fn repository_error(&self) -> Option<&str> {
362 match &self.repository_status {
363 RepositoryOperationStatus::Error(message) => Some(message),
364 _ => self.background_error.as_deref(),
365 }
366 }
367
368 pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
369 let entry = self.drawer.entry(self.drawer_selected)?;
370 let document = self.document();
371 let state = DrawerTree::stage_state_for_entry(document, entry);
372 let paths = DrawerTree::paths_for_entry(document, entry);
373 if paths.is_empty() {
374 return None;
375 }
376 Some(if state == StageState::Staged {
377 RepositoryAction::UnstagePaths(paths)
378 } else {
379 RepositoryAction::StagePaths(paths)
380 })
381 }
382
383 pub(crate) fn begin_commit(&mut self) {
384 self.repository_prompt = Some(RepositoryPrompt::Commit {
385 message: String::new(),
386 });
387 }
388
389 pub(crate) fn begin_discard(&mut self) {
390 let Some(file) = self
391 .selected_file()
392 .and_then(|index| self.document().files.get(index))
393 else {
394 return;
395 };
396 self.repository_prompt = Some(RepositoryPrompt::Discard {
397 path: file.path.clone(),
398 status: file.status,
399 });
400 }
401
402 pub fn set_theme(&mut self, theme: ReviewTheme) {
404 self.theme_picker = None;
405 self.apply_theme(theme);
406 }
407
408 pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
409 self.theme = theme;
410 self.highlighter.clear_cache();
411 self.mark_dirty();
412 }
413
414 #[must_use]
415 pub const fn options(&self) -> &ReviewOptions {
416 &self.options
417 }
418
419 #[must_use]
420 pub fn keybindings(&self) -> &[KeyBinding<DiffReviewCommand>] {
421 &self.keybindings
422 }
423
424 pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<DiffReviewCommand>>>) {
425 self.keybindings = bindings.into();
426 self.help_scroll = 0;
427 self.mark_dirty();
428 }
429
430 pub fn set_options(&mut self, options: ReviewOptions) {
431 if matches!(
432 options.navigation,
433 NavigationPane::Hidden | NavigationPane::Width(0)
434 ) {
435 self.focus = FocusPane::Diff;
436 }
437 self.options = options;
438 self.hit_layout = HitLayout::default();
439 self.request_follow();
440 }
441
442 #[must_use]
443 pub fn theme_choices(&self) -> &[ThemeChoice] {
444 &self.theme_choices
445 }
446
447 pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
448 if let Some(picker) = self.theme_picker.take() {
449 self.set_theme(picker.cancel());
450 }
451 self.theme_choices = themes.into();
452 self.mark_dirty();
453 }
454
455 pub(crate) fn select_file(&mut self, index: usize) -> bool {
456 if !self.session.select_file(index) {
457 return false;
458 }
459 self.drawer.expand_file(self.session.document(), index);
460 self.drawer_selected = self.drawer.position_of_file(index).unwrap_or(0);
461 self.follow_drawer_selection();
462 self.scroll_to_selected_file();
463 true
464 }
465
466 fn finish_projection_change(&mut self, changed: bool) -> bool {
467 if changed {
468 self.request_follow();
469 }
470 changed
471 }
472
473 pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
474 let previous = (
475 self.session.selected_file_range(),
476 self.session.selected_row(),
477 self.session.selected_side(),
478 );
479 self.session.reveal_selected_gap(amount);
480 let changed = previous
481 != (
482 self.session.selected_file_range(),
483 self.session.selected_row(),
484 self.session.selected_side(),
485 );
486 self.finish_projection_change(changed)
487 }
488
489 pub fn toggle_full_file(&mut self) -> bool {
490 let changed = self.session.toggle_full_file();
491 self.finish_projection_change(changed)
492 }
493
494 pub fn set_view_mode(&mut self, mode: ViewMode) {
496 if self.session.set_view_mode(mode) {
497 self.scroll_to_selected_file();
498 }
499 }
500
501 pub fn clear_review(&mut self) {
503 self.session.clear_review();
504 self.cursor_position = None;
505 self.mark_dirty();
506 }
507
508 pub(crate) fn ensure_presentation(&mut self, width: u16) {
509 let width_changed = self.presentation_width != width;
510 self.presentation_width = width;
511 if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
512 self.scroll_to_selected_file();
513 } else if width_changed {
514 self.request_follow();
515 }
516 }
517
518 pub(crate) fn scroll_to_selected_file(&mut self) {
519 self.scroll = 0;
520 self.request_follow();
521 }
522
523 pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
524 let last = self.drawer.entries().len().saturating_sub(1);
525 self.drawer_selected = offset(self.drawer_selected, delta, last);
526 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
527 let index = *index;
528 if self.session.select_file(index) {
529 self.scroll_to_selected_file();
530 }
531 }
532 self.follow_drawer_selection();
533 }
534
535 pub(crate) fn select_drawer_entry(&mut self, index: usize) {
536 if index >= self.drawer.entries().len() {
537 return;
538 }
539 self.drawer_selected = index;
540 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
541 let index = *index;
542 if self.session.select_file(index) {
543 self.scroll_to_selected_file();
544 }
545 }
546 self.follow_drawer_selection();
547 }
548
549 pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
552 match self.drawer.entry(self.drawer_selected).cloned() {
553 Some(DrawerEntry::Directory { path, .. }) => {
554 self.drawer.expand(&path);
555 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
556 self.follow_drawer_selection();
557 self.mark_dirty();
558 true
559 }
560 Some(DrawerEntry::File { index, .. }) => {
561 if self.session.select_file(index) {
562 self.scroll_to_selected_file();
563 }
564 false
565 }
566 None => false,
567 }
568 }
569
570 pub(crate) fn collapse_drawer_entry(&mut self) {
571 let Some(DrawerEntry::Directory { path, .. }) =
572 self.drawer.entry(self.drawer_selected).cloned()
573 else {
574 return;
575 };
576 self.drawer.collapse(&path);
577 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
578 self.follow_drawer_selection();
579 self.mark_dirty();
580 }
581
582 fn follow_drawer_selection(&mut self) {
583 self.drawer_follow = DrawerFollow::Pending;
584 if self.drawer_selected < self.drawer_scroll {
585 self.drawer_scroll = self.drawer_selected;
586 } else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
587 self.drawer_scroll = self
588 .drawer_selected
589 .saturating_sub(self.drawer_height.saturating_sub(1));
590 }
591 }
592
593 pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
594 matches!(
595 std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
596 DrawerFollow::Pending
597 )
598 }
599
600 pub(crate) fn move_row(&mut self, delta: isize) {
601 let selected = self.session.selected_row();
602 let side = self.session.selected_side();
603 self.session.move_row(delta);
604 if self.session.selected_row() != selected || self.session.selected_side() != side {
605 self.request_follow();
606 }
607 }
608
609 pub(crate) fn select_boundary(&mut self, end: bool) {
610 self.session.select_boundary(end);
611 self.request_follow();
612 }
613
614 pub(crate) fn scroll_patch(&mut self, delta: isize) {
617 let Some(layout) = self.patch_visual_layout() else {
618 return;
619 };
620 if layout.is_empty() {
621 return;
622 }
623 let target = if delta.is_negative() {
624 self.scroll.saturating_sub(delta.unsigned_abs())
625 } else {
626 self.scroll.saturating_add(delta.unsigned_abs())
627 };
628 let last = layout.len().saturating_sub(self.last_height.max(1));
629 let clamped = target.min(last);
630 self.follow_pending = false;
631 if clamped != self.scroll {
632 self.scroll = clamped;
633 self.mark_dirty();
634 }
635 }
636
637 pub(crate) fn scroll_drawer(&mut self, delta: isize) {
639 let last = self
640 .drawer
641 .entries()
642 .len()
643 .saturating_sub(self.drawer_height);
644 let target = if delta.is_negative() {
645 self.drawer_scroll.saturating_sub(delta.unsigned_abs())
646 } else {
647 self.drawer_scroll.saturating_add(delta.unsigned_abs())
648 };
649 let clamped = target.min(last);
650 self.drawer_follow = DrawerFollow::Settled;
651 if clamped != self.drawer_scroll {
652 self.drawer_scroll = clamped;
653 self.mark_dirty();
654 }
655 }
656
657 pub(crate) fn page(&mut self, delta: isize) {
658 let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
659 self.scroll_patch(delta.saturating_mul(height));
660 }
661
662 pub(crate) fn request_follow(&mut self) {
667 self.follow_pending = true;
668 self.mark_dirty();
669 self.follow_selection();
670 }
671
672 pub(crate) fn take_follow_request(&mut self) -> bool {
673 std::mem::take(&mut self.follow_pending)
674 }
675
676 pub(crate) fn follow_selection(&mut self) {
677 if self.last_height == 0 {
678 return;
679 }
680 let Some(selected) = self.session.selected_row() else {
681 return;
682 };
683 let draft = self.session.draft().is_some();
684 let Some(layout) = self.patch_visual_layout() else {
685 return;
686 };
687 let Some(target) = layout.focused_visual_row(selected, draft) else {
688 return;
689 };
690 let height = self.last_height.max(1);
691 if target < self.scroll {
692 self.scroll = target;
693 } else if target >= self.scroll.saturating_add(height) {
694 self.scroll = target.saturating_sub(height.saturating_sub(1));
695 }
696 self.scroll = self.scroll.min(layout.len().saturating_sub(height));
697 }
698
699 pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
700 let range = self.session.selected_file_range()?;
701 let key = self.patch_layout_key(&range);
702 let rebuild = self
703 .patch_layout
704 .as_ref()
705 .is_none_or(|cached| cached.key != key);
706 if rebuild {
707 self.patch_layout = Some(CachedPatchLayout {
708 key,
709 layout: Arc::new(PatchVisualLayout::new(
710 &self.session,
711 range,
712 self.presentation_width,
713 )),
714 });
715 }
716 self.patch_layout
717 .as_ref()
718 .map(|cached| cached.layout.clone())
719 }
720
721 fn patch_layout_key(&self, range: &std::ops::Range<usize>) -> u64 {
722 let mut hasher = DefaultHasher::new();
723 (Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
724 self.presentation_width.hash(&mut hasher);
725 self.layout().is_split().hash(&mut hasher);
726 self.session.projection_revision().hash(&mut hasher);
727 range.start.hash(&mut hasher);
728 range.end.hash(&mut hasher);
729 for comment in self.review().comments() {
730 comment.id.hash(&mut hasher);
731 comment.anchor.hash(&mut hasher);
732 comment.body.hash(&mut hasher);
733 comment.outdated.hash(&mut hasher);
734 }
735 if let Some(draft) = self.session.draft() {
736 draft.anchor().hash(&mut hasher);
737 draft.body().hash(&mut hasher);
738 draft.cursor().hash(&mut hasher);
739 }
740 hasher.finish()
741 }
742
743 pub(crate) fn select_clicked_row(&mut self, row: u16) {
744 let clicked = self
745 .visible_rows
746 .iter()
747 .find(|(screen_row, _)| *screen_row == row)
748 .map(|(_, index)| *index);
749 if let Some(index) = clicked
750 && self.session.select_row(index)
751 {
752 if self.session.presentation().gap_info(index).is_some() {
753 self.reveal_selected_gap(RevealAmount::Step);
754 }
755 self.request_follow();
756 }
757 }
758}
759
760fn offset(current: usize, delta: isize, last: usize) -> usize {
761 if delta.is_negative() {
762 current.saturating_sub(delta.unsigned_abs())
763 } else {
764 current.saturating_add(delta.unsigned_abs()).min(last)
765 }
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use clankerdiff_core::FileDiff;
772 use clankerdiff_theme::ThemeId;
773
774 #[test]
775 fn new_uses_the_default_sage_theme() {
776 let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
777 assert_eq!(state.theme.id(), &ThemeId::Sage);
778 }
779
780 #[test]
781 fn comments_above_selection_count_toward_viewport_height() {
782 let document = Arc::new(DiffDocument {
783 repo_root: "/repo".into(),
784 files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
785 });
786 let mut state = DiffReviewState::new(document);
787 state.last_height = 3;
788 let anchor = state.session.selected_anchor().unwrap();
789 state.session.review_mut().add_comment(anchor, "note");
790 state.session.move_row(2);
791 state.follow_selection();
792 assert!(state.scroll > 0);
793 }
794}