1use crate::{
2 DiffReviewCommand, InteractionPhase, KeyBinding, NavigationPane, ReviewOptions, ThemeChoice,
3 default_diff_keybindings,
4 drawer::{DrawerEntry, DrawerTree},
5 patch_layout::{PatchContentLayout, PatchVisualLayout, PatchVisualRow},
6 theme_picker::ThemePicker,
7};
8use clankerdiff_core::{
9 DiffDocument, DiffPresentation, DiffScope, DiffSide, FileStatus, Layout, RepositoryAction,
10 RevealAmount, Review, ReviewCapabilities, ReviewSession, RowId, SourceLocation, StageState,
11 ViewMode,
12};
13use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
14use clankerdiff_theme::ReviewTheme;
15use ratatui::layout::{Position, Rect};
16use std::{
17 collections::hash_map::DefaultHasher,
18 hash::{Hash, Hasher},
19 sync::Arc,
20};
21
22pub(crate) const SPLIT_BREAKPOINT: u16 = 96;
23
24pub use clankerdiff_core::FocusPane;
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum DiffReviewStatus {
29 Loading,
31 Ready,
33 Error(String),
35}
36
37#[derive(Debug, Clone, Copy, Default)]
38pub(crate) struct HitLayout {
39 pub drawer: Rect,
40 pub drawer_stage_column: Option<u16>,
41 pub patch: Rect,
42}
43
44#[derive(Debug, Clone, Copy, Default)]
45enum DrawerFollow {
46 #[default]
47 Pending,
48 Settled,
49}
50
51#[derive(Debug)]
52pub(crate) enum RepositoryPrompt {
53 Commit {
54 message: String,
55 },
56 Discard {
57 path: clankerdiff_core::RepoPath,
58 status: FileStatus,
59 },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
64pub enum RepositoryOperationStatus {
65 #[default]
66 Idle,
67 Pending,
68 Error(String),
69}
70
71#[derive(Debug)]
72struct CachedPatchLayout {
73 key: u64,
74 layout: Arc<PatchVisualLayout>,
75}
76
77#[derive(Debug)]
78struct CachedPatchContent {
79 key: u64,
80 layout: Arc<PatchContentLayout>,
81}
82
83#[derive(Debug)]
84struct DiffViewportBookmark {
85 scroll: usize,
86 row: Option<RowId>,
87 source: Option<SourceLocation>,
88 segment: usize,
89}
90
91#[derive(Debug)]
93pub struct DiffReviewState {
94 pub(crate) session: ReviewSession,
95 pub(crate) scope: DiffScope,
96 pub(crate) options: ReviewOptions,
97 pub(crate) capabilities: ReviewCapabilities,
98 pub(crate) keybindings: Vec<KeyBinding<DiffReviewCommand>>,
99 pub(crate) theme_choices: Arc<[ThemeChoice]>,
100 pub(crate) theme: ReviewTheme,
101 pub(crate) highlighter: SyntaxHighlighter,
102 pub(crate) status: DiffReviewStatus,
103 pub(crate) focus: FocusPane,
104 pub(crate) drawer: DrawerTree,
105 pub(crate) drawer_selected: usize,
106 pub(crate) drawer_scroll: usize,
107 pub(crate) drawer_height: usize,
108 drawer_follow: DrawerFollow,
109 pub(crate) scroll: usize,
110 pub(crate) last_height: usize,
111 pub(crate) presentation_width: u16,
112 patch_content: Option<CachedPatchContent>,
113 patch_layout: Option<CachedPatchLayout>,
114 diff_viewport: Option<DiffViewportBookmark>,
115 pub(crate) help: bool,
116 pub(crate) help_scroll: usize,
117 pub(crate) theme_picker: Option<ThemePicker>,
118 pub(crate) repository_prompt: Option<RepositoryPrompt>,
119 pub(crate) repository_status: RepositoryOperationStatus,
120 pub(crate) background_error: Option<String>,
121 deferred_document: Option<Arc<DiffDocument>>,
122 deferred_scope: Option<DiffScope>,
123 pub(crate) hit_layout: HitLayout,
124 pub(crate) visible_rows: Vec<(u16, usize)>,
125 pub(crate) cursor_position: Option<Position>,
126 pub(crate) follow_pending: bool,
127 pub(crate) dirty: bool,
128}
129
130impl DiffReviewState {
131 #[must_use]
133 pub fn new(document: Arc<DiffDocument>) -> Self {
134 Self::with_theme(document, ReviewTheme::default())
135 }
136
137 #[must_use]
139 pub fn with_theme(document: Arc<DiffDocument>, theme: ReviewTheme) -> Self {
140 let drawer = DrawerTree::new(&document);
141 let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
142 let mut state = Self {
143 session: ReviewSession::new(document),
144 scope: DiffScope::Both,
145 options: ReviewOptions::default(),
146 capabilities: ReviewCapabilities::default(),
147 keybindings: default_diff_keybindings(),
148 theme_choices: Arc::from([]),
149 theme,
150 highlighter: SyntaxHighlighter::default(),
151 status: DiffReviewStatus::Ready,
152 focus: FocusPane::Files,
153 drawer,
154 drawer_selected,
155 drawer_scroll: 0,
156 drawer_height: 1,
157 drawer_follow: DrawerFollow::Pending,
158 scroll: 0,
159 last_height: 0,
160 presentation_width: 0,
161 patch_content: None,
162 patch_layout: None,
163 diff_viewport: None,
164 help: false,
165 help_scroll: 0,
166 theme_picker: None,
167 repository_prompt: None,
168 repository_status: RepositoryOperationStatus::Idle,
169 background_error: None,
170 deferred_document: None,
171 deferred_scope: None,
172 hit_layout: HitLayout::default(),
173 visible_rows: Vec::new(),
174 cursor_position: None,
175 follow_pending: true,
176 dirty: true,
177 };
178 state.scroll_to_selected_file();
179 state
180 }
181
182 #[must_use]
184 pub fn loading() -> Self {
185 let mut state = Self::new(Arc::new(DiffDocument::empty()));
186 state.status = DiffReviewStatus::Loading;
187 state
188 }
189
190 #[must_use]
191 pub const fn session(&self) -> &ReviewSession {
192 &self.session
193 }
194
195 pub const fn session_mut(&mut self) -> &mut ReviewSession {
196 &mut self.session
197 }
198
199 #[must_use]
200 pub const fn scope(&self) -> DiffScope {
201 self.scope
202 }
203
204 pub fn set_scope(&mut self, scope: DiffScope) {
205 if self.interaction_phase() == InteractionPhase::Browse {
206 self.scope = scope;
207 self.mark_dirty();
208 } else {
209 self.deferred_scope = Some(scope);
210 }
211 }
212
213 #[must_use]
215 pub const fn document(&self) -> &Arc<DiffDocument> {
216 self.session.document()
217 }
218
219 #[must_use]
221 pub const fn review(&self) -> &Review {
222 self.session.review()
223 }
224
225 pub const fn review_mut(&mut self) -> &mut Review {
227 self.session.review_mut()
228 }
229
230 #[must_use]
231 pub const fn presentation(&self) -> &DiffPresentation {
232 self.session.presentation()
233 }
234
235 #[must_use]
237 pub const fn status(&self) -> &DiffReviewStatus {
238 &self.status
239 }
240
241 #[must_use]
243 pub const fn focus(&self) -> FocusPane {
244 self.focus
245 }
246
247 #[must_use]
249 pub fn selected_file(&self) -> Option<usize> {
250 self.session.selected_file()
251 }
252
253 #[must_use]
255 pub fn selected_row(&self) -> Option<usize> {
256 self.session.selected_row()
257 }
258
259 #[must_use]
260 pub const fn selected_side(&self) -> DiffSide {
261 self.session.selected_side()
262 }
263
264 #[must_use]
266 pub const fn theme(&self) -> &ReviewTheme {
267 &self.theme
268 }
269
270 #[must_use]
272 pub const fn highlight_stats(&self) -> HighlightStats {
273 self.highlighter.stats()
274 }
275
276 #[must_use]
278 pub const fn scroll_offset(&self) -> usize {
279 self.scroll
280 }
281
282 #[must_use]
289 pub const fn is_dirty(&self) -> bool {
290 self.dirty
291 }
292
293 pub const fn mark_dirty(&mut self) {
296 self.dirty = true;
297 }
298
299 #[must_use]
301 pub const fn view_mode(&self) -> ViewMode {
302 self.session.view_mode()
303 }
304
305 #[must_use]
306 pub const fn layout(&self) -> Layout {
307 self.session.layout()
308 }
309
310 #[must_use]
314 pub const fn cursor_position(&self) -> Option<Position> {
315 self.cursor_position
316 }
317
318 pub fn set_document(&mut self, document: Arc<DiffDocument>) {
320 if self.interaction_phase() == InteractionPhase::Browse {
321 self.install_document(document);
322 } else {
323 self.deferred_document = Some(document);
324 }
325 }
326
327 pub(crate) fn install_deferred(&mut self) {
328 if self.interaction_phase() != InteractionPhase::Browse {
329 return;
330 }
331 if let Some(document) = self.deferred_document.take() {
332 self.install_document(document);
333 }
334 if let Some(scope) = self.deferred_scope.take() {
335 self.set_scope(scope);
336 }
337 }
338
339 fn install_document(&mut self, document: Arc<DiffDocument>) {
340 let revision = self.session.projection_revision();
341 self.session.set_document(document);
342 let follow = revision != self.session.projection_revision()
343 || !matches!(self.status, DiffReviewStatus::Ready);
344 self.status = DiffReviewStatus::Ready;
345 self.cursor_position = None;
346 self.mark_dirty();
347 let document = self.document().clone();
348 self.drawer.rebuild(&document);
349 if let Some(selected) = self.session.selected_file() {
350 self.drawer.expand_file(&document, selected);
351 self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
352 } else {
353 self.drawer_selected = 0;
354 }
355 self.follow_drawer_selection();
356 if follow {
357 self.request_follow();
358 }
359 }
360
361 pub fn set_loading(&mut self) {
363 self.status = DiffReviewStatus::Loading;
364 self.deferred_document = None;
365 self.deferred_scope = None;
366 self.session.cancel_draft();
367 self.cursor_position = None;
368 self.mark_dirty();
369 }
370
371 pub fn set_error(&mut self, message: impl Into<String>) {
373 self.status = DiffReviewStatus::Error(message.into());
374 self.deferred_document = None;
375 self.deferred_scope = None;
376 self.session.cancel_draft();
377 self.cursor_position = None;
378 self.mark_dirty();
379 }
380
381 pub fn set_repository_pending(&mut self) {
383 self.repository_status = RepositoryOperationStatus::Pending;
384 self.repository_prompt = None;
385 self.mark_dirty();
386 }
387
388 pub fn clear_repository_pending(&mut self) {
390 self.repository_status = RepositoryOperationStatus::Idle;
391 self.mark_dirty();
392 }
393
394 pub fn set_repository_error(&mut self, message: impl Into<String>) {
396 self.repository_status = RepositoryOperationStatus::Error(message.into());
397 self.repository_prompt = None;
398 self.mark_dirty();
399 }
400
401 pub fn set_background_error(&mut self, message: Option<String>) {
403 if self.background_error != message {
404 self.background_error = message;
405 self.mark_dirty();
406 }
407 }
408
409 #[must_use]
411 pub fn repository_pending(&self) -> bool {
412 matches!(self.repository_status, RepositoryOperationStatus::Pending)
413 }
414
415 #[must_use]
417 pub fn repository_error(&self) -> Option<&str> {
418 match &self.repository_status {
419 RepositoryOperationStatus::Error(message) => Some(message),
420 _ => self.background_error.as_deref(),
421 }
422 }
423
424 pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
425 let entry = self.drawer.entry(self.drawer_selected)?;
426 let document = self.document();
427 let state = DrawerTree::stage_state_for_entry(document, entry);
428 let paths = DrawerTree::paths_for_entry(document, entry);
429 if paths.is_empty() {
430 return None;
431 }
432 Some(if state == StageState::Staged {
433 RepositoryAction::UnstagePaths(paths)
434 } else {
435 RepositoryAction::StagePaths(paths)
436 })
437 }
438
439 pub(crate) fn begin_commit(&mut self) {
440 self.repository_prompt = Some(RepositoryPrompt::Commit {
441 message: String::new(),
442 });
443 }
444
445 pub(crate) fn begin_discard(&mut self) {
446 let Some(file) = self
447 .selected_file()
448 .and_then(|index| self.document().files.get(index))
449 else {
450 return;
451 };
452 self.repository_prompt = Some(RepositoryPrompt::Discard {
453 path: file.path.clone(),
454 status: file.status,
455 });
456 }
457
458 pub fn set_theme(&mut self, theme: ReviewTheme) {
459 self.theme_picker = None;
460 self.apply_theme(theme);
461 }
462
463 pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
464 self.theme = theme;
465 self.mark_dirty();
466 }
467
468 #[must_use]
469 pub const fn options(&self) -> &ReviewOptions {
470 &self.options
471 }
472
473 #[must_use]
474 pub fn keybindings(&self) -> &[KeyBinding<DiffReviewCommand>] {
475 &self.keybindings
476 }
477
478 pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<DiffReviewCommand>>>) {
479 self.keybindings = bindings.into();
480 self.help_scroll = 0;
481 self.mark_dirty();
482 }
483
484 pub fn set_options(&mut self, options: ReviewOptions) {
485 if matches!(
486 options.navigation,
487 NavigationPane::Hidden | NavigationPane::Width(0)
488 ) {
489 self.focus = FocusPane::Diff;
490 }
491 self.options = options;
492 self.hit_layout = HitLayout::default();
493 self.request_follow();
494 }
495
496 #[must_use]
497 pub fn theme_choices(&self) -> &[ThemeChoice] {
498 &self.theme_choices
499 }
500
501 pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
502 if let Some(picker) = self.theme_picker.take() {
503 self.set_theme(picker.cancel());
504 }
505 self.theme_choices = themes.into();
506 self.mark_dirty();
507 }
508
509 pub(crate) fn select_file(&mut self, index: usize) -> bool {
510 if !self.session.select_file(index) {
511 return false;
512 }
513 self.drawer.expand_file(self.session.document(), index);
514 self.drawer_selected = self.drawer.position_of_file(index).unwrap_or(0);
515 self.follow_drawer_selection();
516 self.scroll_to_selected_file();
517 true
518 }
519
520 fn finish_projection_change(&mut self, changed: bool) -> bool {
521 if changed {
522 self.request_follow();
523 }
524 changed
525 }
526
527 pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
528 let previous = (
529 self.session.selected_file_range(),
530 self.session.selected_row(),
531 self.session.selected_side(),
532 );
533 self.session.reveal_selected_gap(amount);
534 let changed = previous
535 != (
536 self.session.selected_file_range(),
537 self.session.selected_row(),
538 self.session.selected_side(),
539 );
540 self.finish_projection_change(changed)
541 }
542
543 pub fn toggle_source_view(&mut self) -> bool {
544 let entering = self.session.source_view().is_none();
545 let bookmark = if entering {
546 let layout = self.patch_visual_layout();
547 let index = match layout.as_ref().and_then(|layout| layout.row(self.scroll)) {
548 Some(PatchVisualRow::Source { index, .. }) => Some(index),
549 Some(PatchVisualRow::Annotation { source, .. }) => Some(source),
550 None => None,
551 };
552 let segment = index
553 .zip(layout.as_ref())
554 .and_then(|(index, layout)| layout.focused_visual_row(index, false))
555 .map_or(0, |first| self.scroll.saturating_sub(first));
556 let row = index.and_then(|index| self.presentation().row(index));
557 Some(DiffViewportBookmark {
558 scroll: self.scroll,
559 row: row.map(|row| row.id),
560 source: row.and_then(|row| {
561 self.presentation()
562 .source_location(row, row.primary_cell()?)
563 }),
564 segment,
565 })
566 } else {
567 None
568 };
569 if !self.session.toggle_source_view() {
570 return false;
571 }
572 if entering {
573 self.diff_viewport = bookmark;
574 self.scroll = 0;
575 self.request_follow();
576 } else if let Some(bookmark) = self.diff_viewport.take() {
577 self.scroll = bookmark.scroll;
578 let row = bookmark
579 .row
580 .and_then(|id| self.presentation().row_with_id(id))
581 .or_else(|| {
582 bookmark
583 .source
584 .as_ref()
585 .and_then(|source| self.presentation().row_showing_source(source))
586 });
587 if let (Some(row), Some(layout)) = (row, self.patch_visual_layout()) {
588 self.scroll = layout
589 .focused_visual_row(row, false)
590 .unwrap_or(0)
591 .saturating_add(bookmark.segment);
592 } else {
593 self.follow_selection();
594 }
595 if let Some(layout) = self.patch_visual_layout() {
596 self.scroll = self
597 .scroll
598 .min(layout.len().saturating_sub(self.last_height));
599 }
600 self.follow_pending = false;
601 self.mark_dirty();
602 }
603 true
604 }
605
606 pub fn toggle_full_file(&mut self) -> bool {
607 let changed = self.session.toggle_full_file();
608 self.finish_projection_change(changed)
609 }
610
611 pub fn set_view_mode(&mut self, mode: ViewMode) {
613 if self.session.set_view_mode(mode) {
614 self.scroll_to_selected_file();
615 }
616 }
617
618 pub fn clear_review(&mut self) {
620 self.session.clear_review();
621 self.cursor_position = None;
622 self.mark_dirty();
623 }
624
625 pub(crate) fn ensure_presentation(&mut self, width: u16) {
626 let width_changed = self.presentation_width != width;
627 self.presentation_width = width;
628 if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
629 self.scroll_to_selected_file();
630 } else if width_changed {
631 self.request_follow();
632 }
633 }
634
635 pub(crate) fn scroll_to_selected_file(&mut self) {
636 self.scroll = 0;
637 self.request_follow();
638 }
639
640 pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
641 let last = self.drawer.entries().len().saturating_sub(1);
642 self.drawer_selected = offset(self.drawer_selected, delta, last);
643 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
644 let index = *index;
645 if self.session.select_file(index) {
646 self.scroll_to_selected_file();
647 }
648 }
649 self.follow_drawer_selection();
650 }
651
652 pub(crate) fn select_drawer_entry(&mut self, index: usize) {
653 if index >= self.drawer.entries().len() {
654 return;
655 }
656 self.drawer_selected = index;
657 if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
658 let index = *index;
659 if self.session.select_file(index) {
660 self.scroll_to_selected_file();
661 }
662 }
663 self.follow_drawer_selection();
664 }
665
666 pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
669 match self.drawer.entry(self.drawer_selected).cloned() {
670 Some(DrawerEntry::Directory { path, .. }) => {
671 self.drawer.expand(&path);
672 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
673 self.follow_drawer_selection();
674 self.mark_dirty();
675 true
676 }
677 Some(DrawerEntry::File { index, .. }) => {
678 if self.session.select_file(index) {
679 self.scroll_to_selected_file();
680 }
681 false
682 }
683 None => false,
684 }
685 }
686
687 pub(crate) fn collapse_drawer_entry(&mut self) {
688 let Some(DrawerEntry::Directory { path, .. }) =
689 self.drawer.entry(self.drawer_selected).cloned()
690 else {
691 return;
692 };
693 self.drawer.collapse(&path);
694 self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
695 self.follow_drawer_selection();
696 self.mark_dirty();
697 }
698
699 fn follow_drawer_selection(&mut self) {
700 self.drawer_follow = DrawerFollow::Pending;
701 if self.drawer_selected < self.drawer_scroll {
702 self.drawer_scroll = self.drawer_selected;
703 } else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
704 self.drawer_scroll = self
705 .drawer_selected
706 .saturating_sub(self.drawer_height.saturating_sub(1));
707 }
708 }
709
710 pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
711 matches!(
712 std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
713 DrawerFollow::Pending
714 )
715 }
716
717 pub(crate) fn move_row(&mut self, delta: isize) {
718 let selected = self.session.selected_row();
719 let side = self.session.selected_side();
720 self.session.move_row(delta);
721 if self.session.selected_row() != selected || self.session.selected_side() != side {
722 self.request_follow();
723 }
724 }
725
726 pub(crate) fn select_boundary(&mut self, end: bool) {
727 self.session.select_boundary(end);
728 self.request_follow();
729 }
730
731 pub(crate) fn scroll_patch(&mut self, delta: isize) {
734 let Some(layout) = self.patch_visual_layout() else {
735 return;
736 };
737 if layout.is_empty() {
738 return;
739 }
740 let target = if delta.is_negative() {
741 self.scroll.saturating_sub(delta.unsigned_abs())
742 } else {
743 self.scroll.saturating_add(delta.unsigned_abs())
744 };
745 let last = layout.len().saturating_sub(self.last_height.max(1));
746 let clamped = target.min(last);
747 self.follow_pending = false;
748 if clamped != self.scroll {
749 self.scroll = clamped;
750 self.mark_dirty();
751 }
752 }
753
754 pub(crate) fn scroll_drawer(&mut self, delta: isize) {
756 let last = self
757 .drawer
758 .entries()
759 .len()
760 .saturating_sub(self.drawer_height);
761 let target = if delta.is_negative() {
762 self.drawer_scroll.saturating_sub(delta.unsigned_abs())
763 } else {
764 self.drawer_scroll.saturating_add(delta.unsigned_abs())
765 };
766 let clamped = target.min(last);
767 self.drawer_follow = DrawerFollow::Settled;
768 if clamped != self.drawer_scroll {
769 self.drawer_scroll = clamped;
770 self.mark_dirty();
771 }
772 }
773
774 pub(crate) fn page(&mut self, delta: isize) {
775 let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
776 self.scroll_patch(delta.saturating_mul(height));
777 }
778
779 pub(crate) fn request_follow(&mut self) {
784 self.follow_pending = true;
785 self.mark_dirty();
786 self.follow_selection();
787 }
788
789 pub(crate) fn take_follow_request(&mut self) -> bool {
790 std::mem::take(&mut self.follow_pending)
791 }
792
793 pub(crate) fn follow_selection(&mut self) {
794 if self.last_height == 0 {
795 return;
796 }
797 let Some(selected) = self.session.selected_row() else {
798 return;
799 };
800 let draft = self.session.draft().is_some();
801 let Some(layout) = self.patch_visual_layout() else {
802 return;
803 };
804 let Some(target) = layout.focused_visual_row(selected, draft) else {
805 return;
806 };
807 let height = self.last_height.max(1);
808 if target < self.scroll {
809 self.scroll = target;
810 } else if target >= self.scroll.saturating_add(height) {
811 self.scroll = target.saturating_sub(height.saturating_sub(1));
812 }
813 self.scroll = self.scroll.min(layout.len().saturating_sub(height));
814 }
815
816 pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
817 let range = self.session.selected_file_range()?;
818 let content_key = self.patch_content_key(&range);
819 if self
820 .patch_content
821 .as_ref()
822 .is_none_or(|cached| cached.key != content_key)
823 {
824 self.patch_content = Some(CachedPatchContent {
825 key: content_key,
826 layout: Arc::new(PatchContentLayout::new(
827 self.session.presentation(),
828 range,
829 self.presentation_width,
830 self.options.tab_width,
831 )),
832 });
833 }
834 let content = self.patch_content.as_ref()?.layout.clone();
835 let key = self.patch_annotation_key(content_key);
836 if self
837 .patch_layout
838 .as_ref()
839 .is_none_or(|cached| cached.key != key)
840 {
841 self.patch_layout = Some(CachedPatchLayout {
842 key,
843 layout: Arc::new(PatchVisualLayout::new(
844 &self.session,
845 content,
846 self.presentation_width,
847 )),
848 });
849 }
850 self.patch_layout
851 .as_ref()
852 .map(|cached| cached.layout.clone())
853 }
854
855 fn patch_content_key(&self, range: &std::ops::Range<usize>) -> u64 {
856 let mut hasher = DefaultHasher::new();
857 (Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
858 self.presentation_width.hash(&mut hasher);
859 self.layout().is_split().hash(&mut hasher);
860 self.options.tab_width.hash(&mut hasher);
861 self.session.projection_revision().hash(&mut hasher);
862 range.start.hash(&mut hasher);
863 range.end.hash(&mut hasher);
864 hasher.finish()
865 }
866
867 fn patch_annotation_key(&self, content_key: u64) -> u64 {
868 let mut hasher = DefaultHasher::new();
869 content_key.hash(&mut hasher);
870 for comment in self.review().comments() {
871 comment.id.hash(&mut hasher);
872 comment.anchor.hash(&mut hasher);
873 comment.body.hash(&mut hasher);
874 comment.outdated.hash(&mut hasher);
875 }
876 if let Some(draft) = self.session.draft() {
877 draft.anchor().hash(&mut hasher);
878 draft.body().hash(&mut hasher);
879 draft.cursor().hash(&mut hasher);
880 }
881 hasher.finish()
882 }
883
884 pub(crate) fn select_clicked_row(&mut self, row: u16) -> bool {
885 let clicked = self
886 .visible_rows
887 .iter()
888 .find(|(screen_row, _)| *screen_row == row)
889 .map(|(_, index)| *index);
890 if let Some(index) = clicked
891 && self.session.select_row(index)
892 {
893 if self.session.presentation().gap_info(index).is_some() {
894 self.reveal_selected_gap(RevealAmount::Step);
895 } else {
896 self.mark_dirty();
897 self.follow_pending = false;
898 return true;
899 }
900 }
901 false
902 }
903}
904
905fn offset(current: usize, delta: isize, last: usize) -> usize {
906 if delta.is_negative() {
907 current.saturating_sub(delta.unsigned_abs())
908 } else {
909 current.saturating_add(delta.unsigned_abs()).min(last)
910 }
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916 use clankerdiff_core::FileDiff;
917 use clankerdiff_theme::ThemeId;
918
919 #[test]
920 fn new_uses_the_default_sage_theme() {
921 let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
922 assert_eq!(state.theme.id(), &ThemeId::Sage);
923 }
924
925 #[test]
926 fn comments_above_selection_count_toward_viewport_height() {
927 let document = Arc::new(DiffDocument {
928 repo_root: "/repo".into(),
929 files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
930 });
931 let mut state = DiffReviewState::new(document);
932 state.last_height = 3;
933 let anchor = state.session.selected_anchor().unwrap();
934 state.session.review_mut().add_comment(anchor, "note");
935 state.session.move_row(2);
936 state.follow_selection();
937 assert!(state.scroll > 0);
938 }
939}