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