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