Skip to main content

clankerdiff_ratatui/
state.rs

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/// Current host-provided document state.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum DiffReviewStatus {
28    /// A host is loading a snapshot.
29    Loading,
30    /// A document is ready for review.
31    Ready,
32    /// Loading failed with a host-provided message.
33    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/// Status of the most recent repository mutation requested by the UI.
62#[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/// Persistent state for [`crate::DiffReviewWidget`].
83#[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    /// Creates ready state from an immutable document snapshot.
122    #[must_use]
123    pub fn new(document: Arc<DiffDocument>) -> Self {
124        Self::with_theme(document, ReviewTheme::default())
125    }
126
127    /// Creates ready state with a shared neutral theme.
128    #[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    /// Creates loading state with an empty placeholder document.
172    #[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    /// Returns the current immutable snapshot.
203    #[must_use]
204    pub const fn document(&self) -> &Arc<DiffDocument> {
205        self.session.document()
206    }
207
208    /// Returns the structured review.
209    #[must_use]
210    pub const fn review(&self) -> &Review {
211        self.session.review()
212    }
213
214    /// Returns mutable review access for host-driven operations.
215    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    /// Returns the current load status.
225    #[must_use]
226    pub const fn status(&self) -> &DiffReviewStatus {
227        &self.status
228    }
229
230    /// Returns the focused pane.
231    #[must_use]
232    pub const fn focus(&self) -> FocusPane {
233        self.focus
234    }
235
236    /// Returns the selected file index, when the document has files.
237    #[must_use]
238    pub fn selected_file(&self) -> Option<usize> {
239        self.session.selected_file()
240    }
241
242    /// Returns the selected presentation-row index, when rows exist.
243    #[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    /// Returns the active renderer-neutral theme.
254    #[must_use]
255    pub const fn theme(&self) -> &ReviewTheme {
256        &self.theme
257    }
258
259    /// Returns syntax cache and work counters.
260    #[must_use]
261    pub const fn highlight_stats(&self) -> HighlightStats {
262        self.highlighter.stats()
263    }
264
265    /// Returns the first visible rendered-row index in the selected file.
266    #[must_use]
267    pub const fn scroll_offset(&self) -> usize {
268        self.scroll
269    }
270
271    /// Returns whether anything since the last frame changed what a redraw
272    /// would show.
273    ///
274    /// Hosts may skip drawing [`crate::DiffReviewWidget`] while this is false,
275    /// which keeps pointer motion in a terminal reporting all mouse movement
276    /// from costing a frame each. Rendering the widget clears it.
277    #[must_use]
278    pub const fn is_dirty(&self) -> bool {
279        self.dirty
280    }
281
282    /// Marks the state as needing a redraw, for changes the widget cannot
283    /// observe on its own, such as a terminal resize.
284    pub const fn mark_dirty(&mut self) {
285        self.dirty = true;
286    }
287
288    /// Returns the requested view mode.
289    #[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    /// Returns the terminal cursor position for an active comment draft.
300    ///
301    /// Hosts may pass this to `Frame::set_cursor_position` after rendering.
302    #[must_use]
303    pub const fn cursor_position(&self) -> Option<Position> {
304        self.cursor_position
305    }
306
307    /// Replaces the complete document while retaining and reconciling review comments.
308    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    /// Marks the state as waiting for a host snapshot.
346    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    /// Shows a host-provided loading error.
356    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    /// Marks a repository mutation as in flight while retaining the current snapshot.
366    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    /// Clears a pending repository mutation without replacing the snapshot.
373    pub fn clear_repository_pending(&mut self) {
374        self.repository_status = RepositoryOperationStatus::Idle;
375        self.mark_dirty();
376    }
377
378    /// Shows a repository mutation error while retaining the current snapshot.
379    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    /// Reconciles background refresh health independently of command completion.
386    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    /// Whether a host command is still in flight.
394    #[must_use]
395    pub fn repository_pending(&self) -> bool {
396        matches!(self.repository_status, RepositoryOperationStatus::Pending)
397    }
398
399    /// The command error, or otherwise the current background refresh failure.
400    #[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    /// Selects automatic, unified, or split presentation.
533    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    /// Clears all queued comments and any active draft.
540    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    /// Expands the selected directory, or selects the current file. Returns
588    /// whether the selected entry was a directory.
589    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    /// Scrolls the patch viewport without moving the selection. The selection
653    /// may leave the viewport; the next selection move brings it back.
654    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    /// Scrolls the file drawer without moving the selected file.
676    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    /// Brings the selection back into view against the height the last frame
701    /// measured, and asks the next frame to redo it once it knows its own.
702    /// Before any frame has drawn there is no height to work from, so the
703    /// request only carries over.
704    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}