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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub enum FocusPane {
24 #[default]
26 Files,
27 Diff,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum DiffReviewStatus {
34 Loading,
36 Ready,
38 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#[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#[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 #[must_use]
115 pub fn new(document: Arc<DiffDocument>) -> Self {
116 Self::with_theme(document, DiffTheme::default())
117 }
118
119 #[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 #[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 #[must_use]
184 pub const fn document(&self) -> &Arc<DiffDocument> {
185 self.session.document()
186 }
187
188 #[must_use]
190 pub const fn review(&self) -> &Review {
191 self.session.review()
192 }
193
194 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 #[must_use]
206 pub const fn status(&self) -> &DiffReviewStatus {
207 &self.status
208 }
209
210 #[must_use]
212 pub const fn focus(&self) -> FocusPane {
213 self.focus
214 }
215
216 #[must_use]
218 pub fn selected_file(&self) -> Option<usize> {
219 self.session.selected_file()
220 }
221
222 #[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 #[must_use]
235 pub const fn theme(&self) -> &DiffTheme {
236 &self.theme
237 }
238
239 #[must_use]
241 pub const fn highlight_stats(&self) -> HighlightStats {
242 self.highlighter.stats()
243 }
244
245 #[must_use]
247 pub const fn scroll_offset(&self) -> usize {
248 self.scroll
249 }
250
251 #[must_use]
258 pub const fn is_dirty(&self) -> bool {
259 self.dirty
260 }
261
262 pub const fn mark_dirty(&mut self) {
265 self.dirty = true;
266 }
267
268 #[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 #[must_use]
283 pub const fn cursor_position(&self) -> Option<Position> {
284 self.cursor_position
285 }
286
287 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 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 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 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 pub fn clear_repository_pending(&mut self) {
330 self.repository_status = RepositoryOperationStatus::Idle;
331 self.mark_dirty();
332 }
333
334 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 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 #[must_use]
351 pub fn repository_pending(&self) -> bool {
352 matches!(self.repository_status, RepositoryOperationStatus::Pending)
353 }
354
355 #[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 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 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 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 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 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 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 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}