1use crate::{
2 DiffReviewCommand, InteractionPhase, 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 deferred_document: Option<Arc<DiffDocument>>,
105 deferred_scope: Option<DiffScope>,
106 pub(crate) hit_layout: HitLayout,
107 pub(crate) visible_rows: Vec<(u16, usize)>,
108 pub(crate) cursor_position: Option<Position>,
109 pub(crate) follow_pending: bool,
110 pub(crate) dirty: bool,
111}
112
113impl DiffReviewState {
114 #[must_use]
116 pub fn new(document: Arc<DiffDocument>) -> Self {
117 Self::with_theme(document, ReviewTheme::default())
118 }
119
120 #[must_use]
122 pub fn with_theme(document: Arc<DiffDocument>, theme: ReviewTheme) -> Self {
123 let drawer = DrawerTree::new(&document);
124 let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
125 let mut state = Self {
126 session: ReviewSession::new(document),
127 scope: DiffScope::Both,
128 options: ReviewOptions::default(),
129 capabilities: ReviewCapabilities::default(),
130 keybindings: default_diff_keybindings(),
131 theme_choices: Arc::from([]),
132 theme,
133 highlighter: SyntaxHighlighter::default(),
134 status: DiffReviewStatus::Ready,
135 focus: FocusPane::Files,
136 drawer,
137 drawer_selected,
138 drawer_scroll: 0,
139 drawer_height: 1,
140 drawer_follow: DrawerFollow::Pending,
141 scroll: 0,
142 last_height: 0,
143 presentation_width: 0,
144 patch_layout: None,
145 help: false,
146 help_scroll: 0,
147 theme_picker: None,
148 repository_prompt: None,
149 repository_status: RepositoryOperationStatus::Idle,
150 background_error: None,
151 deferred_document: None,
152 deferred_scope: None,
153 hit_layout: HitLayout::default(),
154 visible_rows: Vec::new(),
155 cursor_position: None,
156 follow_pending: true,
157 dirty: true,
158 };
159 state.scroll_to_selected_file();
160 state
161 }
162
163 #[must_use]
165 pub fn loading() -> Self {
166 let mut state = Self::new(Arc::new(DiffDocument::empty()));
167 state.status = DiffReviewStatus::Loading;
168 state
169 }
170
171 #[must_use]
172 pub const fn session(&self) -> &ReviewSession {
173 &self.session
174 }
175
176 pub const fn session_mut(&mut self) -> &mut ReviewSession {
177 &mut self.session
178 }
179
180 #[must_use]
181 pub const fn scope(&self) -> DiffScope {
182 self.scope
183 }
184
185 pub fn set_scope(&mut self, scope: DiffScope) {
186 if self.interaction_phase() == InteractionPhase::Browse {
187 self.scope = scope;
188 self.mark_dirty();
189 } else {
190 self.deferred_scope = Some(scope);
191 }
192 }
193
194 #[must_use]
196 pub const fn document(&self) -> &Arc<DiffDocument> {
197 self.session.document()
198 }
199
200 #[must_use]
202 pub const fn review(&self) -> &Review {
203 self.session.review()
204 }
205
206 pub const fn review_mut(&mut self) -> &mut Review {
208 self.session.review_mut()
209 }
210
211 #[must_use]
212 pub const fn presentation(&self) -> &DiffPresentation {
213 self.session.presentation()
214 }
215
216 #[must_use]
218 pub const fn status(&self) -> &DiffReviewStatus {
219 &self.status
220 }
221
222 #[must_use]
224 pub const fn focus(&self) -> FocusPane {
225 self.focus
226 }
227
228 #[must_use]
230 pub fn selected_file(&self) -> Option<usize> {
231 self.session.selected_file()
232 }
233
234 #[must_use]
236 pub fn selected_row(&self) -> Option<usize> {
237 self.session.selected_row()
238 }
239
240 #[must_use]
241 pub const fn selected_side(&self) -> DiffSide {
242 self.session.selected_side()
243 }
244
245 #[must_use]
247 pub const fn theme(&self) -> &ReviewTheme {
248 &self.theme
249 }
250
251 #[must_use]
253 pub const fn highlight_stats(&self) -> HighlightStats {
254 self.highlighter.stats()
255 }
256
257 #[must_use]
259 pub const fn scroll_offset(&self) -> usize {
260 self.scroll
261 }
262
263 #[must_use]
270 pub const fn is_dirty(&self) -> bool {
271 self.dirty
272 }
273
274 pub const fn mark_dirty(&mut self) {
277 self.dirty = true;
278 }
279
280 #[must_use]
282 pub const fn view_mode(&self) -> ViewMode {
283 self.session.view_mode()
284 }
285
286 #[must_use]
287 pub const fn layout(&self) -> Layout {
288 self.session.layout()
289 }
290
291 #[must_use]
295 pub const fn cursor_position(&self) -> Option<Position> {
296 self.cursor_position
297 }
298
299 pub fn set_document(&mut self, document: Arc<DiffDocument>) {
301 if self.interaction_phase() == InteractionPhase::Browse {
302 self.install_document(document);
303 } else {
304 self.deferred_document = Some(document);
305 }
306 }
307
308 pub(crate) fn install_deferred(&mut self) {
309 if self.interaction_phase() != InteractionPhase::Browse {
310 return;
311 }
312 if let Some(document) = self.deferred_document.take() {
313 self.install_document(document);
314 }
315 if let Some(scope) = self.deferred_scope.take() {
316 self.set_scope(scope);
317 }
318 }
319
320 fn install_document(&mut self, document: Arc<DiffDocument>) {
321 self.session.set_document(document);
322 self.status = DiffReviewStatus::Ready;
323 self.cursor_position = None;
324 self.mark_dirty();
325 let document = self.document().clone();
326 self.drawer.rebuild(&document);
327 if let Some(selected) = self.session.selected_file() {
328 self.drawer.expand_file(&document, selected);
329 self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
330 } else {
331 self.drawer_selected = 0;
332 }
333 self.follow_drawer_selection();
334 self.request_follow();
335 }
336
337 pub fn set_loading(&mut self) {
339 self.status = DiffReviewStatus::Loading;
340 self.deferred_document = None;
341 self.deferred_scope = None;
342 self.session.cancel_draft();
343 self.cursor_position = None;
344 self.mark_dirty();
345 }
346
347 pub fn set_error(&mut self, message: impl Into<String>) {
349 self.status = DiffReviewStatus::Error(message.into());
350 self.deferred_document = None;
351 self.deferred_scope = None;
352 self.session.cancel_draft();
353 self.cursor_position = None;
354 self.mark_dirty();
355 }
356
357 pub fn set_repository_pending(&mut self) {
359 self.repository_status = RepositoryOperationStatus::Pending;
360 self.repository_prompt = None;
361 self.mark_dirty();
362 }
363
364 pub fn clear_repository_pending(&mut self) {
366 self.repository_status = RepositoryOperationStatus::Idle;
367 self.mark_dirty();
368 }
369
370 pub fn set_repository_error(&mut self, message: impl Into<String>) {
372 self.repository_status = RepositoryOperationStatus::Error(message.into());
373 self.repository_prompt = None;
374 self.mark_dirty();
375 }
376
377 pub fn set_background_error(&mut self, message: Option<String>) {
379 if self.background_error != message {
380 self.background_error = message;
381 self.mark_dirty();
382 }
383 }
384
385 #[must_use]
387 pub fn repository_pending(&self) -> bool {
388 matches!(self.repository_status, RepositoryOperationStatus::Pending)
389 }
390
391 #[must_use]
393 pub fn repository_error(&self) -> Option<&str> {
394 match &self.repository_status {
395 RepositoryOperationStatus::Error(message) => Some(message),
396 _ => self.background_error.as_deref(),
397 }
398 }
399
400 pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
401 let entry = self.drawer.entry(self.drawer_selected)?;
402 let document = self.document();
403 let state = DrawerTree::stage_state_for_entry(document, entry);
404 let paths = DrawerTree::paths_for_entry(document, entry);
405 if paths.is_empty() {
406 return None;
407 }
408 Some(if state == StageState::Staged {
409 RepositoryAction::UnstagePaths(paths)
410 } else {
411 RepositoryAction::StagePaths(paths)
412 })
413 }
414
415 pub(crate) fn begin_commit(&mut self) {
416 self.repository_prompt = Some(RepositoryPrompt::Commit {
417 message: String::new(),
418 });
419 }
420
421 pub(crate) fn begin_discard(&mut self) {
422 let Some(file) = self
423 .selected_file()
424 .and_then(|index| self.document().files.get(index))
425 else {
426 return;
427 };
428 self.repository_prompt = Some(RepositoryPrompt::Discard {
429 path: file.path.clone(),
430 status: file.status,
431 });
432 }
433
434 pub fn set_theme(&mut self, theme: ReviewTheme) {
435 self.theme_picker = None;
436 self.apply_theme(theme);
437 }
438
439 pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
440 self.theme = theme;
441 self.mark_dirty();
442 }
443
444 #[must_use]
445 pub const fn options(&self) -> &ReviewOptions {
446 &self.options
447 }
448
449 #[must_use]
450 pub fn keybindings(&self) -> &[KeyBinding<DiffReviewCommand>] {
451 &self.keybindings
452 }
453
454 pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<DiffReviewCommand>>>) {
455 self.keybindings = bindings.into();
456 self.help_scroll = 0;
457 self.mark_dirty();
458 }
459
460 pub fn set_options(&mut self, options: ReviewOptions) {
461 if matches!(
462 options.navigation,
463 NavigationPane::Hidden | NavigationPane::Width(0)
464 ) {
465 self.focus = FocusPane::Diff;
466 }
467 self.options = options;
468 self.hit_layout = HitLayout::default();
469 self.request_follow();
470 }
471
472 #[must_use]
473 pub fn theme_choices(&self) -> &[ThemeChoice] {
474 &self.theme_choices
475 }
476
477 pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
478 if let Some(picker) = self.theme_picker.take() {
479 self.set_theme(picker.cancel());
480 }
481 self.theme_choices = themes.into();
482 self.mark_dirty();
483 }
484
485 pub(crate) fn select_file(&mut self, index: usize) -> bool {
486 if !self.session.select_file(index) {
487 return false;
488 }
489 self.drawer.expand_file(self.session.document(), index);
490 self.drawer_selected = self.drawer.position_of_file(index).unwrap_or(0);
491 self.follow_drawer_selection();
492 self.scroll_to_selected_file();
493 true
494 }
495
496 fn finish_projection_change(&mut self, changed: bool) -> bool {
497 if changed {
498 self.request_follow();
499 }
500 changed
501 }
502
503 pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
504 let previous = (
505 self.session.selected_file_range(),
506 self.session.selected_row(),
507 self.session.selected_side(),
508 );
509 self.session.reveal_selected_gap(amount);
510 let changed = previous
511 != (
512 self.session.selected_file_range(),
513 self.session.selected_row(),
514 self.session.selected_side(),
515 );
516 self.finish_projection_change(changed)
517 }
518
519 pub fn toggle_full_file(&mut self) -> bool {
520 let changed = self.session.toggle_full_file();
521 self.finish_projection_change(changed)
522 }
523
524 pub fn set_view_mode(&mut self, mode: ViewMode) {
526 if self.session.set_view_mode(mode) {
527 self.scroll_to_selected_file();
528 }
529 }
530
531 pub fn clear_review(&mut self) {
533 self.session.clear_review();
534 self.cursor_position = None;
535 self.mark_dirty();
536 }
537
538 pub(crate) fn ensure_presentation(&mut self, width: u16) {
539 let width_changed = self.presentation_width != width;
540 self.presentation_width = width;
541 if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
542 self.scroll_to_selected_file();
543 } else if width_changed {
544 self.request_follow();
545 }
546 }
547
548 pub(crate) fn scroll_to_selected_file(&mut self) {
549 self.scroll = 0;
550 self.request_follow();
551 }
552
553 pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
554 let last = self.drawer.entries().len().saturating_sub(1);
555 self.drawer_selected = offset(self.drawer_selected, delta, last);
556 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
557 let index = *index;
558 if self.session.select_file(index) {
559 self.scroll_to_selected_file();
560 }
561 }
562 self.follow_drawer_selection();
563 }
564
565 pub(crate) fn select_drawer_entry(&mut self, index: usize) {
566 if index >= self.drawer.entries().len() {
567 return;
568 }
569 self.drawer_selected = index;
570 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
571 let index = *index;
572 if self.session.select_file(index) {
573 self.scroll_to_selected_file();
574 }
575 }
576 self.follow_drawer_selection();
577 }
578
579 pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
582 match self.drawer.entry(self.drawer_selected).cloned() {
583 Some(DrawerEntry::Directory { path, .. }) => {
584 self.drawer.expand(&path);
585 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
586 self.follow_drawer_selection();
587 self.mark_dirty();
588 true
589 }
590 Some(DrawerEntry::File { index, .. }) => {
591 if self.session.select_file(index) {
592 self.scroll_to_selected_file();
593 }
594 false
595 }
596 None => false,
597 }
598 }
599
600 pub(crate) fn collapse_drawer_entry(&mut self) {
601 let Some(DrawerEntry::Directory { path, .. }) =
602 self.drawer.entry(self.drawer_selected).cloned()
603 else {
604 return;
605 };
606 self.drawer.collapse(&path);
607 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
608 self.follow_drawer_selection();
609 self.mark_dirty();
610 }
611
612 fn follow_drawer_selection(&mut self) {
613 self.drawer_follow = DrawerFollow::Pending;
614 if self.drawer_selected < self.drawer_scroll {
615 self.drawer_scroll = self.drawer_selected;
616 } else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
617 self.drawer_scroll = self
618 .drawer_selected
619 .saturating_sub(self.drawer_height.saturating_sub(1));
620 }
621 }
622
623 pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
624 matches!(
625 std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
626 DrawerFollow::Pending
627 )
628 }
629
630 pub(crate) fn move_row(&mut self, delta: isize) {
631 let selected = self.session.selected_row();
632 let side = self.session.selected_side();
633 self.session.move_row(delta);
634 if self.session.selected_row() != selected || self.session.selected_side() != side {
635 self.request_follow();
636 }
637 }
638
639 pub(crate) fn select_boundary(&mut self, end: bool) {
640 self.session.select_boundary(end);
641 self.request_follow();
642 }
643
644 pub(crate) fn scroll_patch(&mut self, delta: isize) {
647 let Some(layout) = self.patch_visual_layout() else {
648 return;
649 };
650 if layout.is_empty() {
651 return;
652 }
653 let target = if delta.is_negative() {
654 self.scroll.saturating_sub(delta.unsigned_abs())
655 } else {
656 self.scroll.saturating_add(delta.unsigned_abs())
657 };
658 let last = layout.len().saturating_sub(self.last_height.max(1));
659 let clamped = target.min(last);
660 self.follow_pending = false;
661 if clamped != self.scroll {
662 self.scroll = clamped;
663 self.mark_dirty();
664 }
665 }
666
667 pub(crate) fn scroll_drawer(&mut self, delta: isize) {
669 let last = self
670 .drawer
671 .entries()
672 .len()
673 .saturating_sub(self.drawer_height);
674 let target = if delta.is_negative() {
675 self.drawer_scroll.saturating_sub(delta.unsigned_abs())
676 } else {
677 self.drawer_scroll.saturating_add(delta.unsigned_abs())
678 };
679 let clamped = target.min(last);
680 self.drawer_follow = DrawerFollow::Settled;
681 if clamped != self.drawer_scroll {
682 self.drawer_scroll = clamped;
683 self.mark_dirty();
684 }
685 }
686
687 pub(crate) fn page(&mut self, delta: isize) {
688 let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
689 self.scroll_patch(delta.saturating_mul(height));
690 }
691
692 pub(crate) fn request_follow(&mut self) {
697 self.follow_pending = true;
698 self.mark_dirty();
699 self.follow_selection();
700 }
701
702 pub(crate) fn take_follow_request(&mut self) -> bool {
703 std::mem::take(&mut self.follow_pending)
704 }
705
706 pub(crate) fn follow_selection(&mut self) {
707 if self.last_height == 0 {
708 return;
709 }
710 let Some(selected) = self.session.selected_row() else {
711 return;
712 };
713 let draft = self.session.draft().is_some();
714 let Some(layout) = self.patch_visual_layout() else {
715 return;
716 };
717 let Some(target) = layout.focused_visual_row(selected, draft) else {
718 return;
719 };
720 let height = self.last_height.max(1);
721 if target < self.scroll {
722 self.scroll = target;
723 } else if target >= self.scroll.saturating_add(height) {
724 self.scroll = target.saturating_sub(height.saturating_sub(1));
725 }
726 self.scroll = self.scroll.min(layout.len().saturating_sub(height));
727 }
728
729 pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
730 let range = self.session.selected_file_range()?;
731 let key = self.patch_layout_key(&range);
732 let rebuild = self
733 .patch_layout
734 .as_ref()
735 .is_none_or(|cached| cached.key != key);
736 if rebuild {
737 self.patch_layout = Some(CachedPatchLayout {
738 key,
739 layout: Arc::new(PatchVisualLayout::new(
740 &self.session,
741 range,
742 self.presentation_width,
743 )),
744 });
745 }
746 self.patch_layout
747 .as_ref()
748 .map(|cached| cached.layout.clone())
749 }
750
751 fn patch_layout_key(&self, range: &std::ops::Range<usize>) -> u64 {
752 let mut hasher = DefaultHasher::new();
753 (Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
754 self.presentation_width.hash(&mut hasher);
755 self.layout().is_split().hash(&mut hasher);
756 self.session.projection_revision().hash(&mut hasher);
757 range.start.hash(&mut hasher);
758 range.end.hash(&mut hasher);
759 for comment in self.review().comments() {
760 comment.id.hash(&mut hasher);
761 comment.anchor.hash(&mut hasher);
762 comment.body.hash(&mut hasher);
763 comment.outdated.hash(&mut hasher);
764 }
765 if let Some(draft) = self.session.draft() {
766 draft.anchor().hash(&mut hasher);
767 draft.body().hash(&mut hasher);
768 draft.cursor().hash(&mut hasher);
769 }
770 hasher.finish()
771 }
772
773 pub(crate) fn select_clicked_row(&mut self, row: u16) {
774 let clicked = self
775 .visible_rows
776 .iter()
777 .find(|(screen_row, _)| *screen_row == row)
778 .map(|(_, index)| *index);
779 if let Some(index) = clicked
780 && self.session.select_row(index)
781 {
782 if self.session.presentation().gap_info(index).is_some() {
783 self.reveal_selected_gap(RevealAmount::Step);
784 }
785 self.request_follow();
786 }
787 }
788}
789
790fn offset(current: usize, delta: isize, last: usize) -> usize {
791 if delta.is_negative() {
792 current.saturating_sub(delta.unsigned_abs())
793 } else {
794 current.saturating_add(delta.unsigned_abs()).min(last)
795 }
796}
797
798#[cfg(test)]
799mod tests {
800 use super::*;
801 use clankerdiff_core::FileDiff;
802 use clankerdiff_theme::ThemeId;
803
804 #[test]
805 fn new_uses_the_default_sage_theme() {
806 let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
807 assert_eq!(state.theme.id(), &ThemeId::Sage);
808 }
809
810 #[test]
811 fn comments_above_selection_count_toward_viewport_height() {
812 let document = Arc::new(DiffDocument {
813 repo_root: "/repo".into(),
814 files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
815 });
816 let mut state = DiffReviewState::new(document);
817 state.last_height = 3;
818 let anchor = state.session.selected_anchor().unwrap();
819 state.session.review_mut().add_comment(anchor, "note");
820 state.session.move_row(2);
821 state.follow_selection();
822 assert!(state.scroll > 0);
823 }
824}