Skip to main content

clankerdiff_ratatui/
state.rs

1use crate::{
2    drawer::{DrawerEntry, DrawerTree},
3    patch_layout::PatchVisualLayout,
4    theme_picker::ThemePicker,
5};
6use clankerdiff_core::{
7    DiffDocument, DiffPresentation, DiffScope, DiffSide, FileStatus, Layout, RepositoryAction,
8    RevealAmount, Review, ReviewSession, StageState, ViewMode,
9};
10use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
11use clankerdiff_theme::DiffTheme;
12use ratatui::layout::{Position, Rect};
13use std::{
14    collections::hash_map::DefaultHasher,
15    hash::{Hash, Hasher},
16    sync::Arc,
17};
18
19pub(crate) const SPLIT_BREAKPOINT: u16 = 96;
20
21/// Which pane receives navigation input.
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub enum FocusPane {
24    /// File navigation drawer.
25    #[default]
26    Files,
27    /// Diff document.
28    Diff,
29}
30
31/// Current host-provided document state.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum DiffReviewStatus {
34    /// A host is loading a snapshot.
35    Loading,
36    /// A document is ready for review.
37    Ready,
38    /// Loading failed with a host-provided message.
39    Error(String),
40}
41
42#[derive(Debug, Clone, Copy, Default)]
43pub(crate) struct HitLayout {
44    pub drawer: Rect,
45    pub drawer_stage_column: Option<u16>,
46    pub patch: Rect,
47}
48
49#[derive(Debug, Clone, Copy, Default)]
50enum DrawerFollow {
51    #[default]
52    Pending,
53    Settled,
54}
55
56#[derive(Debug)]
57pub(crate) enum RepositoryPrompt {
58    Commit {
59        message: String,
60    },
61    Discard {
62        path: clankerdiff_core::RepoPath,
63        status: FileStatus,
64    },
65}
66
67/// Status of the most recent repository mutation requested by the UI.
68#[derive(Debug, Clone, PartialEq, Eq, Default)]
69pub enum RepositoryOperationStatus {
70    #[default]
71    Idle,
72    Pending,
73    Error(String),
74}
75
76#[derive(Debug)]
77struct CachedPatchLayout {
78    key: u64,
79    layout: Arc<PatchVisualLayout>,
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) theme: DiffTheme,
88    pub(crate) highlighter: SyntaxHighlighter,
89    pub(crate) status: DiffReviewStatus,
90    pub(crate) focus: FocusPane,
91    pub(crate) drawer: DrawerTree,
92    pub(crate) drawer_selected: usize,
93    pub(crate) drawer_scroll: usize,
94    pub(crate) drawer_height: usize,
95    drawer_follow: DrawerFollow,
96    pub(crate) scroll: usize,
97    pub(crate) last_height: usize,
98    pub(crate) presentation_width: u16,
99    patch_layout: Option<CachedPatchLayout>,
100    pub(crate) help: bool,
101    pub(crate) theme_picker: Option<ThemePicker>,
102    pub(crate) repository_prompt: Option<RepositoryPrompt>,
103    pub(crate) repository_status: RepositoryOperationStatus,
104    pub(crate) background_error: Option<String>,
105    pub(crate) hit_layout: HitLayout,
106    pub(crate) visible_rows: Vec<(u16, usize)>,
107    pub(crate) cursor_position: Option<Position>,
108    pub(crate) follow_pending: bool,
109    pub(crate) dirty: bool,
110}
111
112impl DiffReviewState {
113    /// Creates ready state from an immutable document snapshot.
114    #[must_use]
115    pub fn new(document: Arc<DiffDocument>) -> Self {
116        Self::with_theme(document, DiffTheme::default())
117    }
118
119    /// Creates ready state with a shared neutral theme.
120    #[must_use]
121    pub fn with_theme(document: Arc<DiffDocument>, theme: DiffTheme) -> Self {
122        let drawer = DrawerTree::new(&document);
123        let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
124        let mut state = Self {
125            session: ReviewSession::new(document),
126            scope: DiffScope::Both,
127            theme,
128            highlighter: SyntaxHighlighter::default(),
129            status: DiffReviewStatus::Ready,
130            focus: FocusPane::Files,
131            drawer,
132            drawer_selected,
133            drawer_scroll: 0,
134            drawer_height: 1,
135            drawer_follow: DrawerFollow::Pending,
136            scroll: 0,
137            last_height: 0,
138            presentation_width: 0,
139            patch_layout: None,
140            help: false,
141            theme_picker: None,
142            repository_prompt: None,
143            repository_status: RepositoryOperationStatus::Idle,
144            background_error: None,
145            hit_layout: HitLayout::default(),
146            visible_rows: Vec::new(),
147            cursor_position: None,
148            follow_pending: true,
149            dirty: true,
150        };
151        state.scroll_to_selected_file();
152        state
153    }
154
155    /// Creates loading state with an empty placeholder document.
156    #[must_use]
157    pub fn loading() -> Self {
158        let mut state = Self::new(Arc::new(DiffDocument::empty()));
159        state.status = DiffReviewStatus::Loading;
160        state
161    }
162
163    #[must_use]
164    pub const fn session(&self) -> &ReviewSession {
165        &self.session
166    }
167
168    pub const fn session_mut(&mut self) -> &mut ReviewSession {
169        &mut self.session
170    }
171
172    #[must_use]
173    pub const fn scope(&self) -> DiffScope {
174        self.scope
175    }
176
177    pub fn set_scope(&mut self, scope: DiffScope) {
178        self.scope = scope;
179        self.mark_dirty();
180    }
181
182    /// Returns the current immutable snapshot.
183    #[must_use]
184    pub const fn document(&self) -> &Arc<DiffDocument> {
185        self.session.document()
186    }
187
188    /// Returns the structured review.
189    #[must_use]
190    pub const fn review(&self) -> &Review {
191        self.session.review()
192    }
193
194    /// Returns mutable review access for host-driven operations.
195    pub const fn review_mut(&mut self) -> &mut Review {
196        self.session.review_mut()
197    }
198
199    #[must_use]
200    pub const fn presentation(&self) -> &DiffPresentation {
201        self.session.presentation()
202    }
203
204    /// Returns the current load status.
205    #[must_use]
206    pub const fn status(&self) -> &DiffReviewStatus {
207        &self.status
208    }
209
210    /// Returns the focused pane.
211    #[must_use]
212    pub const fn focus(&self) -> FocusPane {
213        self.focus
214    }
215
216    /// Returns the selected file index, when the document has files.
217    #[must_use]
218    pub fn selected_file(&self) -> Option<usize> {
219        self.session.selected_file()
220    }
221
222    /// Returns the selected presentation-row index, when rows exist.
223    #[must_use]
224    pub fn selected_row(&self) -> Option<usize> {
225        self.session.selected_row()
226    }
227
228    #[must_use]
229    pub const fn selected_side(&self) -> DiffSide {
230        self.session.selected_side()
231    }
232
233    /// Returns the active renderer-neutral theme.
234    #[must_use]
235    pub const fn theme(&self) -> &DiffTheme {
236        &self.theme
237    }
238
239    /// Returns syntax cache and work counters.
240    #[must_use]
241    pub const fn highlight_stats(&self) -> HighlightStats {
242        self.highlighter.stats()
243    }
244
245    /// Returns the first visible rendered-row index in the selected file.
246    #[must_use]
247    pub const fn scroll_offset(&self) -> usize {
248        self.scroll
249    }
250
251    /// Returns whether anything since the last frame changed what a redraw
252    /// would show.
253    ///
254    /// Hosts may skip drawing [`crate::DiffReviewWidget`] while this is false,
255    /// which keeps pointer motion in a terminal reporting all mouse movement
256    /// from costing a frame each. Rendering the widget clears it.
257    #[must_use]
258    pub const fn is_dirty(&self) -> bool {
259        self.dirty
260    }
261
262    /// Marks the state as needing a redraw, for changes the widget cannot
263    /// observe on its own, such as a terminal resize.
264    pub const fn mark_dirty(&mut self) {
265        self.dirty = true;
266    }
267
268    /// Returns the requested view mode.
269    #[must_use]
270    pub const fn view_mode(&self) -> ViewMode {
271        self.session.view_mode()
272    }
273
274    #[must_use]
275    pub const fn layout(&self) -> Layout {
276        self.session.layout()
277    }
278
279    /// Returns the terminal cursor position for an active comment draft.
280    ///
281    /// Hosts may pass this to `Frame::set_cursor_position` after rendering.
282    #[must_use]
283    pub const fn cursor_position(&self) -> Option<Position> {
284        self.cursor_position
285    }
286
287    /// Replaces the complete document while retaining and reconciling review comments.
288    pub fn set_document(&mut self, document: Arc<DiffDocument>) {
289        self.session.set_document(document);
290        self.status = DiffReviewStatus::Ready;
291        self.cursor_position = None;
292        self.mark_dirty();
293        let document = self.document().clone();
294        self.drawer.rebuild(&document);
295        if let Some(selected) = self.session.selected_file() {
296            self.drawer.expand_file(&document, selected);
297            self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
298        } else {
299            self.drawer_selected = 0;
300        }
301        self.follow_drawer_selection();
302        self.request_follow();
303    }
304
305    /// Marks the state as waiting for a host snapshot.
306    pub fn set_loading(&mut self) {
307        self.status = DiffReviewStatus::Loading;
308        self.session.cancel_draft();
309        self.cursor_position = None;
310        self.mark_dirty();
311    }
312
313    /// Shows a host-provided loading error.
314    pub fn set_error(&mut self, message: impl Into<String>) {
315        self.status = DiffReviewStatus::Error(message.into());
316        self.session.cancel_draft();
317        self.cursor_position = None;
318        self.mark_dirty();
319    }
320
321    /// Marks a repository mutation as in flight while retaining the current snapshot.
322    pub fn set_repository_pending(&mut self) {
323        self.repository_status = RepositoryOperationStatus::Pending;
324        self.repository_prompt = None;
325        self.mark_dirty();
326    }
327
328    /// Clears a pending repository mutation without replacing the snapshot.
329    pub fn clear_repository_pending(&mut self) {
330        self.repository_status = RepositoryOperationStatus::Idle;
331        self.mark_dirty();
332    }
333
334    /// Shows a repository mutation error while retaining the current snapshot.
335    pub fn set_repository_error(&mut self, message: impl Into<String>) {
336        self.repository_status = RepositoryOperationStatus::Error(message.into());
337        self.repository_prompt = None;
338        self.mark_dirty();
339    }
340
341    /// Reconciles background refresh health independently of command completion.
342    pub fn set_background_error(&mut self, message: Option<String>) {
343        if self.background_error != message {
344            self.background_error = message;
345            self.mark_dirty();
346        }
347    }
348
349    /// Whether a host command is still in flight.
350    #[must_use]
351    pub fn repository_pending(&self) -> bool {
352        matches!(self.repository_status, RepositoryOperationStatus::Pending)
353    }
354
355    /// The command error, or otherwise the current background refresh failure.
356    #[must_use]
357    pub fn repository_error(&self) -> Option<&str> {
358        match &self.repository_status {
359            RepositoryOperationStatus::Error(message) => Some(message),
360            _ => self.background_error.as_deref(),
361        }
362    }
363
364    pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
365        let entry = self.drawer.entry(self.drawer_selected)?;
366        let document = self.document();
367        let state = DrawerTree::stage_state_for_entry(document, entry);
368        let paths = DrawerTree::paths_for_entry(document, entry);
369        if paths.is_empty() {
370            return None;
371        }
372        Some(if state == StageState::Staged {
373            RepositoryAction::UnstagePaths(paths)
374        } else {
375            RepositoryAction::StagePaths(paths)
376        })
377    }
378
379    pub(crate) fn begin_commit(&mut self) {
380        self.repository_prompt = Some(RepositoryPrompt::Commit {
381            message: String::new(),
382        });
383    }
384
385    pub(crate) fn begin_discard(&mut self) {
386        let Some(file) = self
387            .selected_file()
388            .and_then(|index| self.document().files.get(index))
389        else {
390            return;
391        };
392        self.repository_prompt = Some(RepositoryPrompt::Discard {
393            path: file.path.clone(),
394            status: file.status,
395        });
396    }
397
398    /// Changes the neutral theme and clears cached syntax spans.
399    pub fn set_theme(&mut self, theme: DiffTheme) {
400        self.theme = theme;
401        self.highlighter.clear_cache();
402        self.mark_dirty();
403    }
404
405    fn finish_projection_change(&mut self, changed: bool) -> bool {
406        if changed {
407            self.request_follow();
408        }
409        changed
410    }
411
412    pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
413        let changed = self.session.reveal_selected_gap(amount);
414        self.finish_projection_change(changed)
415    }
416
417    pub fn toggle_full_file(&mut self) -> bool {
418        let changed = self.session.toggle_full_file();
419        self.finish_projection_change(changed)
420    }
421
422    /// Selects automatic, unified, or split presentation.
423    pub fn set_view_mode(&mut self, mode: ViewMode) {
424        self.mark_dirty();
425        if self.session.set_view_mode(mode) {
426            self.scroll_to_selected_file();
427        }
428    }
429
430    /// Clears all queued comments and any active draft.
431    pub fn clear_review(&mut self) {
432        self.session.clear_review();
433        self.cursor_position = None;
434        self.mark_dirty();
435    }
436
437    pub(crate) fn ensure_presentation(&mut self, width: u16) {
438        let width_changed = self.presentation_width != width;
439        self.presentation_width = width;
440        if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
441            self.scroll_to_selected_file();
442        } else if width_changed {
443            self.request_follow();
444        }
445    }
446
447    pub(crate) fn scroll_to_selected_file(&mut self) {
448        self.scroll = 0;
449        self.request_follow();
450    }
451
452    pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
453        let last = self.drawer.entries().len().saturating_sub(1);
454        self.drawer_selected = offset(self.drawer_selected, delta, last);
455        if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
456            let index = *index;
457            if self.session.select_file(index) {
458                self.scroll_to_selected_file();
459            }
460        }
461        self.follow_drawer_selection();
462    }
463
464    pub(crate) fn select_drawer_entry(&mut self, index: usize) {
465        if index >= self.drawer.entries().len() {
466            return;
467        }
468        self.drawer_selected = index;
469        if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
470            let index = *index;
471            if self.session.select_file(index) {
472                self.scroll_to_selected_file();
473            }
474        }
475        self.follow_drawer_selection();
476    }
477
478    /// Expands the selected directory, or selects the current file. Returns
479    /// whether the selected entry was a directory.
480    pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
481        match self.drawer.entry(self.drawer_selected).cloned() {
482            Some(DrawerEntry::Directory { path, .. }) => {
483                self.drawer.expand(&path);
484                self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
485                self.follow_drawer_selection();
486                self.mark_dirty();
487                true
488            }
489            Some(DrawerEntry::File { index, .. }) => {
490                if self.session.select_file(index) {
491                    self.scroll_to_selected_file();
492                }
493                false
494            }
495            None => false,
496        }
497    }
498
499    pub(crate) fn collapse_drawer_entry(&mut self) {
500        let Some(DrawerEntry::Directory { path, .. }) =
501            self.drawer.entry(self.drawer_selected).cloned()
502        else {
503            return;
504        };
505        self.drawer.collapse(&path);
506        self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
507        self.follow_drawer_selection();
508        self.mark_dirty();
509    }
510
511    fn follow_drawer_selection(&mut self) {
512        self.drawer_follow = DrawerFollow::Pending;
513        if self.drawer_selected < self.drawer_scroll {
514            self.drawer_scroll = self.drawer_selected;
515        } else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
516            self.drawer_scroll = self
517                .drawer_selected
518                .saturating_sub(self.drawer_height.saturating_sub(1));
519        }
520    }
521
522    pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
523        matches!(
524            std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
525            DrawerFollow::Pending
526        )
527    }
528
529    pub(crate) fn move_row(&mut self, delta: isize) {
530        let selected = self.session.selected_row();
531        let side = self.session.selected_side();
532        self.session.move_row(delta);
533        if self.session.selected_row() != selected || self.session.selected_side() != side {
534            self.request_follow();
535        }
536    }
537
538    pub(crate) fn select_boundary(&mut self, end: bool) {
539        self.session.select_boundary(end);
540        self.request_follow();
541    }
542
543    /// Scrolls the patch viewport without moving the selection. The selection
544    /// may leave the viewport; the next selection move brings it back.
545    pub(crate) fn scroll_patch(&mut self, delta: isize) {
546        let Some(layout) = self.patch_visual_layout() else {
547            return;
548        };
549        if layout.is_empty() {
550            return;
551        }
552        let target = if delta.is_negative() {
553            self.scroll.saturating_sub(delta.unsigned_abs())
554        } else {
555            self.scroll.saturating_add(delta.unsigned_abs())
556        };
557        let last = layout.len().saturating_sub(self.last_height.max(1));
558        let clamped = target.min(last);
559        if clamped != self.scroll {
560            self.scroll = clamped;
561            self.mark_dirty();
562        }
563    }
564
565    /// Scrolls the file drawer without moving the selected file.
566    pub(crate) fn scroll_drawer(&mut self, delta: isize) {
567        let last = self
568            .drawer
569            .entries()
570            .len()
571            .saturating_sub(self.drawer_height);
572        let target = if delta.is_negative() {
573            self.drawer_scroll.saturating_sub(delta.unsigned_abs())
574        } else {
575            self.drawer_scroll.saturating_add(delta.unsigned_abs())
576        };
577        let clamped = target.min(last);
578        if clamped != self.drawer_scroll {
579            self.drawer_scroll = clamped;
580            self.mark_dirty();
581        }
582    }
583
584    pub(crate) fn page(&mut self, delta: isize) {
585        let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
586        self.scroll_patch(delta.saturating_mul(height));
587    }
588
589    /// Brings the selection back into view against the height the last frame
590    /// measured, and asks the next frame to redo it once it knows its own.
591    /// Before any frame has drawn there is no height to work from, so the
592    /// request only carries over.
593    pub(crate) fn request_follow(&mut self) {
594        self.follow_pending = true;
595        self.mark_dirty();
596        self.follow_selection();
597    }
598
599    pub(crate) fn take_follow_request(&mut self) -> bool {
600        std::mem::take(&mut self.follow_pending)
601    }
602
603    pub(crate) fn follow_selection(&mut self) {
604        if self.last_height == 0 {
605            return;
606        }
607        let Some(selected) = self.session.selected_row() else {
608            return;
609        };
610        let draft = self.session.draft().is_some();
611        let Some(layout) = self.patch_visual_layout() else {
612            return;
613        };
614        let Some(target) = layout.focused_visual_row(selected, draft) else {
615            return;
616        };
617        let height = self.last_height.max(1);
618        if target < self.scroll {
619            self.scroll = target;
620        } else if target >= self.scroll.saturating_add(height) {
621            self.scroll = target.saturating_sub(height.saturating_sub(1));
622        }
623        self.scroll = self.scroll.min(layout.len().saturating_sub(height));
624    }
625
626    pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
627        let range = self.session.selected_file_range()?;
628        let key = self.patch_layout_key(&range);
629        let rebuild = self
630            .patch_layout
631            .as_ref()
632            .is_none_or(|cached| cached.key != key);
633        if rebuild {
634            self.patch_layout = Some(CachedPatchLayout {
635                key,
636                layout: Arc::new(PatchVisualLayout::new(
637                    &self.session,
638                    range,
639                    self.presentation_width,
640                )),
641            });
642        }
643        self.patch_layout
644            .as_ref()
645            .map(|cached| cached.layout.clone())
646    }
647
648    fn patch_layout_key(&self, range: &std::ops::Range<usize>) -> u64 {
649        let mut hasher = DefaultHasher::new();
650        (Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
651        self.presentation_width.hash(&mut hasher);
652        self.layout().is_split().hash(&mut hasher);
653        self.session.projection_revision().hash(&mut hasher);
654        range.start.hash(&mut hasher);
655        range.end.hash(&mut hasher);
656        for comment in self.review().comments() {
657            comment.id.hash(&mut hasher);
658            comment.anchor.hash(&mut hasher);
659            comment.body.hash(&mut hasher);
660            comment.outdated.hash(&mut hasher);
661        }
662        if let Some(draft) = self.session.draft() {
663            draft.anchor().hash(&mut hasher);
664            draft.body().hash(&mut hasher);
665            draft.cursor().hash(&mut hasher);
666        }
667        hasher.finish()
668    }
669
670    pub(crate) fn select_clicked_row(&mut self, row: u16) {
671        let clicked = self
672            .visible_rows
673            .iter()
674            .find(|(screen_row, _)| *screen_row == row)
675            .map(|(_, index)| *index);
676        if let Some(index) = clicked
677            && self.session.select_row(index)
678        {
679            if self.session.presentation().gap_info(index).is_some() {
680                self.reveal_selected_gap(RevealAmount::Step);
681            }
682            self.request_follow();
683        }
684    }
685}
686
687fn offset(current: usize, delta: isize, last: usize) -> usize {
688    if delta.is_negative() {
689        current.saturating_sub(delta.unsigned_abs())
690    } else {
691        current.saturating_add(delta.unsigned_abs()).min(last)
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use clankerdiff_core::FileDiff;
699    use clankerdiff_theme::ThemeId;
700
701    #[test]
702    fn new_uses_the_default_sage_theme() {
703        let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
704        assert_eq!(state.theme.id(), &ThemeId::Sage);
705    }
706
707    #[test]
708    fn comments_above_selection_count_toward_viewport_height() {
709        let document = Arc::new(DiffDocument {
710            repo_root: "/repo".into(),
711            files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
712        });
713        let mut state = DiffReviewState::new(document);
714        state.last_height = 3;
715        let anchor = state.session.selected_anchor().unwrap();
716        state.session.review_mut().add_comment(anchor, "note");
717        state.session.move_row(2);
718        state.follow_selection();
719        assert!(state.scroll > 0);
720    }
721}