use crate::{
DiffReviewCommand, KeyBinding, NavigationPane, ReviewOptions, ThemeChoice,
default_diff_keybindings,
drawer::{DrawerEntry, DrawerTree},
patch_layout::PatchVisualLayout,
theme_picker::ThemePicker,
};
use clankerdiff_core::{
DiffDocument, DiffPresentation, DiffScope, DiffSide, FileStatus, Layout, RepositoryAction,
RevealAmount, Review, ReviewCapabilities, ReviewSession, StageState, ViewMode,
};
use clankerdiff_syntax::{HighlightStats, SyntaxHighlighter};
use clankerdiff_theme::ReviewTheme;
use ratatui::layout::{Position, Rect};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::Arc,
};
pub(crate) const SPLIT_BREAKPOINT: u16 = 96;
pub use clankerdiff_core::FocusPane;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffReviewStatus {
Loading,
Ready,
Error(String),
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct HitLayout {
pub drawer: Rect,
pub drawer_stage_column: Option<u16>,
pub patch: Rect,
}
#[derive(Debug, Clone, Copy, Default)]
enum DrawerFollow {
#[default]
Pending,
Settled,
}
#[derive(Debug)]
pub(crate) enum RepositoryPrompt {
Commit {
message: String,
},
Discard {
path: clankerdiff_core::RepoPath,
status: FileStatus,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum RepositoryOperationStatus {
#[default]
Idle,
Pending,
Error(String),
}
#[derive(Debug)]
struct CachedPatchLayout {
key: u64,
layout: Arc<PatchVisualLayout>,
}
#[derive(Debug)]
pub struct DiffReviewState {
pub(crate) session: ReviewSession,
pub(crate) scope: DiffScope,
pub(crate) options: ReviewOptions,
pub(crate) capabilities: ReviewCapabilities,
pub(crate) keybindings: Vec<KeyBinding<DiffReviewCommand>>,
pub(crate) theme_choices: Arc<[ThemeChoice]>,
pub(crate) theme: ReviewTheme,
pub(crate) highlighter: SyntaxHighlighter,
pub(crate) status: DiffReviewStatus,
pub(crate) focus: FocusPane,
pub(crate) drawer: DrawerTree,
pub(crate) drawer_selected: usize,
pub(crate) drawer_scroll: usize,
pub(crate) drawer_height: usize,
drawer_follow: DrawerFollow,
pub(crate) scroll: usize,
pub(crate) last_height: usize,
pub(crate) presentation_width: u16,
patch_layout: Option<CachedPatchLayout>,
pub(crate) help: bool,
pub(crate) help_scroll: usize,
pub(crate) theme_picker: Option<ThemePicker>,
pub(crate) repository_prompt: Option<RepositoryPrompt>,
pub(crate) repository_status: RepositoryOperationStatus,
pub(crate) background_error: Option<String>,
pub(crate) hit_layout: HitLayout,
pub(crate) visible_rows: Vec<(u16, usize)>,
pub(crate) cursor_position: Option<Position>,
pub(crate) follow_pending: bool,
pub(crate) dirty: bool,
}
impl DiffReviewState {
#[must_use]
pub fn new(document: Arc<DiffDocument>) -> Self {
Self::with_theme(document, ReviewTheme::default())
}
#[must_use]
pub fn with_theme(document: Arc<DiffDocument>, theme: ReviewTheme) -> Self {
let drawer = DrawerTree::new(&document);
let drawer_selected = drawer.position_of_file(0).unwrap_or(0);
let mut state = Self {
session: ReviewSession::new(document),
scope: DiffScope::Both,
options: ReviewOptions::default(),
capabilities: ReviewCapabilities::default(),
keybindings: default_diff_keybindings(),
theme_choices: Arc::from([]),
theme,
highlighter: SyntaxHighlighter::default(),
status: DiffReviewStatus::Ready,
focus: FocusPane::Files,
drawer,
drawer_selected,
drawer_scroll: 0,
drawer_height: 1,
drawer_follow: DrawerFollow::Pending,
scroll: 0,
last_height: 0,
presentation_width: 0,
patch_layout: None,
help: false,
help_scroll: 0,
theme_picker: None,
repository_prompt: None,
repository_status: RepositoryOperationStatus::Idle,
background_error: None,
hit_layout: HitLayout::default(),
visible_rows: Vec::new(),
cursor_position: None,
follow_pending: true,
dirty: true,
};
state.scroll_to_selected_file();
state
}
#[must_use]
pub fn loading() -> Self {
let mut state = Self::new(Arc::new(DiffDocument::empty()));
state.status = DiffReviewStatus::Loading;
state
}
#[must_use]
pub const fn session(&self) -> &ReviewSession {
&self.session
}
pub const fn session_mut(&mut self) -> &mut ReviewSession {
&mut self.session
}
#[must_use]
pub const fn scope(&self) -> DiffScope {
self.scope
}
pub fn set_scope(&mut self, scope: DiffScope) {
self.scope = scope;
self.mark_dirty();
}
#[must_use]
pub const fn document(&self) -> &Arc<DiffDocument> {
self.session.document()
}
#[must_use]
pub const fn review(&self) -> &Review {
self.session.review()
}
pub const fn review_mut(&mut self) -> &mut Review {
self.session.review_mut()
}
#[must_use]
pub const fn presentation(&self) -> &DiffPresentation {
self.session.presentation()
}
#[must_use]
pub const fn status(&self) -> &DiffReviewStatus {
&self.status
}
#[must_use]
pub const fn focus(&self) -> FocusPane {
self.focus
}
#[must_use]
pub fn selected_file(&self) -> Option<usize> {
self.session.selected_file()
}
#[must_use]
pub fn selected_row(&self) -> Option<usize> {
self.session.selected_row()
}
#[must_use]
pub const fn selected_side(&self) -> DiffSide {
self.session.selected_side()
}
#[must_use]
pub const fn theme(&self) -> &ReviewTheme {
&self.theme
}
#[must_use]
pub const fn highlight_stats(&self) -> HighlightStats {
self.highlighter.stats()
}
#[must_use]
pub const fn scroll_offset(&self) -> usize {
self.scroll
}
#[must_use]
pub const fn is_dirty(&self) -> bool {
self.dirty
}
pub const fn mark_dirty(&mut self) {
self.dirty = true;
}
#[must_use]
pub const fn view_mode(&self) -> ViewMode {
self.session.view_mode()
}
#[must_use]
pub const fn layout(&self) -> Layout {
self.session.layout()
}
#[must_use]
pub const fn cursor_position(&self) -> Option<Position> {
self.cursor_position
}
pub fn set_document(&mut self, document: Arc<DiffDocument>) {
self.session.set_document(document);
self.status = DiffReviewStatus::Ready;
self.cursor_position = None;
self.mark_dirty();
let document = self.document().clone();
self.drawer.rebuild(&document);
if let Some(selected) = self.session.selected_file() {
self.drawer.expand_file(&document, selected);
self.drawer_selected = self.drawer.position_of_file(selected).unwrap_or(0);
} else {
self.drawer_selected = 0;
}
self.follow_drawer_selection();
self.request_follow();
}
pub fn set_loading(&mut self) {
self.status = DiffReviewStatus::Loading;
self.session.cancel_draft();
self.cursor_position = None;
self.mark_dirty();
}
pub fn set_error(&mut self, message: impl Into<String>) {
self.status = DiffReviewStatus::Error(message.into());
self.session.cancel_draft();
self.cursor_position = None;
self.mark_dirty();
}
pub fn set_repository_pending(&mut self) {
self.repository_status = RepositoryOperationStatus::Pending;
self.repository_prompt = None;
self.mark_dirty();
}
pub fn clear_repository_pending(&mut self) {
self.repository_status = RepositoryOperationStatus::Idle;
self.mark_dirty();
}
pub fn set_repository_error(&mut self, message: impl Into<String>) {
self.repository_status = RepositoryOperationStatus::Error(message.into());
self.repository_prompt = None;
self.mark_dirty();
}
pub fn set_background_error(&mut self, message: Option<String>) {
if self.background_error != message {
self.background_error = message;
self.mark_dirty();
}
}
#[must_use]
pub fn repository_pending(&self) -> bool {
matches!(self.repository_status, RepositoryOperationStatus::Pending)
}
#[must_use]
pub fn repository_error(&self) -> Option<&str> {
match &self.repository_status {
RepositoryOperationStatus::Error(message) => Some(message),
_ => self.background_error.as_deref(),
}
}
pub(crate) fn toggle_stage_action(&self) -> Option<RepositoryAction> {
let entry = self.drawer.entry(self.drawer_selected)?;
let document = self.document();
let state = DrawerTree::stage_state_for_entry(document, entry);
let paths = DrawerTree::paths_for_entry(document, entry);
if paths.is_empty() {
return None;
}
Some(if state == StageState::Staged {
RepositoryAction::UnstagePaths(paths)
} else {
RepositoryAction::StagePaths(paths)
})
}
pub(crate) fn begin_commit(&mut self) {
self.repository_prompt = Some(RepositoryPrompt::Commit {
message: String::new(),
});
}
pub(crate) fn begin_discard(&mut self) {
let Some(file) = self
.selected_file()
.and_then(|index| self.document().files.get(index))
else {
return;
};
self.repository_prompt = Some(RepositoryPrompt::Discard {
path: file.path.clone(),
status: file.status,
});
}
pub fn set_theme(&mut self, theme: ReviewTheme) {
self.theme_picker = None;
self.apply_theme(theme);
}
pub(crate) fn apply_theme(&mut self, theme: ReviewTheme) {
self.theme = theme;
self.mark_dirty();
}
#[must_use]
pub const fn options(&self) -> &ReviewOptions {
&self.options
}
#[must_use]
pub fn keybindings(&self) -> &[KeyBinding<DiffReviewCommand>] {
&self.keybindings
}
pub fn set_keybindings(&mut self, bindings: impl Into<Vec<KeyBinding<DiffReviewCommand>>>) {
self.keybindings = bindings.into();
self.help_scroll = 0;
self.mark_dirty();
}
pub fn set_options(&mut self, options: ReviewOptions) {
if matches!(
options.navigation,
NavigationPane::Hidden | NavigationPane::Width(0)
) {
self.focus = FocusPane::Diff;
}
self.options = options;
self.hit_layout = HitLayout::default();
self.request_follow();
}
#[must_use]
pub fn theme_choices(&self) -> &[ThemeChoice] {
&self.theme_choices
}
pub fn set_theme_choices(&mut self, themes: impl Into<Arc<[ThemeChoice]>>) {
if let Some(picker) = self.theme_picker.take() {
self.set_theme(picker.cancel());
}
self.theme_choices = themes.into();
self.mark_dirty();
}
pub(crate) fn select_file(&mut self, index: usize) -> bool {
if !self.session.select_file(index) {
return false;
}
self.drawer.expand_file(self.session.document(), index);
self.drawer_selected = self.drawer.position_of_file(index).unwrap_or(0);
self.follow_drawer_selection();
self.scroll_to_selected_file();
true
}
fn finish_projection_change(&mut self, changed: bool) -> bool {
if changed {
self.request_follow();
}
changed
}
pub fn reveal_selected_gap(&mut self, amount: RevealAmount) -> bool {
let previous = (
self.session.selected_file_range(),
self.session.selected_row(),
self.session.selected_side(),
);
self.session.reveal_selected_gap(amount);
let changed = previous
!= (
self.session.selected_file_range(),
self.session.selected_row(),
self.session.selected_side(),
);
self.finish_projection_change(changed)
}
pub fn toggle_full_file(&mut self) -> bool {
let changed = self.session.toggle_full_file();
self.finish_projection_change(changed)
}
pub fn set_view_mode(&mut self, mode: ViewMode) {
if self.session.set_view_mode(mode) {
self.scroll_to_selected_file();
}
}
pub fn clear_review(&mut self) {
self.session.clear_review();
self.cursor_position = None;
self.mark_dirty();
}
pub(crate) fn ensure_presentation(&mut self, width: u16) {
let width_changed = self.presentation_width != width;
self.presentation_width = width;
if self.session.set_split_when_auto(width >= SPLIT_BREAKPOINT) {
self.scroll_to_selected_file();
} else if width_changed {
self.request_follow();
}
}
pub(crate) fn scroll_to_selected_file(&mut self) {
self.scroll = 0;
self.request_follow();
}
pub(crate) fn move_drawer_entry(&mut self, delta: isize) {
let last = self.drawer.entries().len().saturating_sub(1);
self.drawer_selected = offset(self.drawer_selected, delta, last);
if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(self.drawer_selected) {
let index = *index;
if self.session.select_file(index) {
self.scroll_to_selected_file();
}
}
self.follow_drawer_selection();
}
pub(crate) fn select_drawer_entry(&mut self, index: usize) {
if index >= self.drawer.entries().len() {
return;
}
self.drawer_selected = index;
if let Some(DrawerEntry::File { index, .. }) = self.drawer.entry(index) {
let index = *index;
if self.session.select_file(index) {
self.scroll_to_selected_file();
}
}
self.follow_drawer_selection();
}
pub(crate) fn expand_or_open_drawer_entry(&mut self) -> bool {
match self.drawer.entry(self.drawer_selected).cloned() {
Some(DrawerEntry::Directory { path, .. }) => {
self.drawer.expand(&path);
self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
self.follow_drawer_selection();
self.mark_dirty();
true
}
Some(DrawerEntry::File { index, .. }) => {
if self.session.select_file(index) {
self.scroll_to_selected_file();
}
false
}
None => false,
}
}
pub(crate) fn collapse_drawer_entry(&mut self) {
let Some(DrawerEntry::Directory { path, .. }) =
self.drawer.entry(self.drawer_selected).cloned()
else {
return;
};
self.drawer.collapse(&path);
self.drawer_selected = self.drawer.position_of_directory(&path).unwrap_or(0);
self.follow_drawer_selection();
self.mark_dirty();
}
fn follow_drawer_selection(&mut self) {
self.drawer_follow = DrawerFollow::Pending;
if self.drawer_selected < self.drawer_scroll {
self.drawer_scroll = self.drawer_selected;
} else if self.drawer_selected >= self.drawer_scroll.saturating_add(self.drawer_height) {
self.drawer_scroll = self
.drawer_selected
.saturating_sub(self.drawer_height.saturating_sub(1));
}
}
pub(crate) fn take_drawer_follow_request(&mut self) -> bool {
matches!(
std::mem::replace(&mut self.drawer_follow, DrawerFollow::Settled),
DrawerFollow::Pending
)
}
pub(crate) fn move_row(&mut self, delta: isize) {
let selected = self.session.selected_row();
let side = self.session.selected_side();
self.session.move_row(delta);
if self.session.selected_row() != selected || self.session.selected_side() != side {
self.request_follow();
}
}
pub(crate) fn select_boundary(&mut self, end: bool) {
self.session.select_boundary(end);
self.request_follow();
}
pub(crate) fn scroll_patch(&mut self, delta: isize) {
let Some(layout) = self.patch_visual_layout() else {
return;
};
if layout.is_empty() {
return;
}
let target = if delta.is_negative() {
self.scroll.saturating_sub(delta.unsigned_abs())
} else {
self.scroll.saturating_add(delta.unsigned_abs())
};
let last = layout.len().saturating_sub(self.last_height.max(1));
let clamped = target.min(last);
self.follow_pending = false;
if clamped != self.scroll {
self.scroll = clamped;
self.mark_dirty();
}
}
pub(crate) fn scroll_drawer(&mut self, delta: isize) {
let last = self
.drawer
.entries()
.len()
.saturating_sub(self.drawer_height);
let target = if delta.is_negative() {
self.drawer_scroll.saturating_sub(delta.unsigned_abs())
} else {
self.drawer_scroll.saturating_add(delta.unsigned_abs())
};
let clamped = target.min(last);
self.drawer_follow = DrawerFollow::Settled;
if clamped != self.drawer_scroll {
self.drawer_scroll = clamped;
self.mark_dirty();
}
}
pub(crate) fn page(&mut self, delta: isize) {
let height = isize::try_from(self.last_height.max(1)).unwrap_or(isize::MAX);
self.scroll_patch(delta.saturating_mul(height));
}
pub(crate) fn request_follow(&mut self) {
self.follow_pending = true;
self.mark_dirty();
self.follow_selection();
}
pub(crate) fn take_follow_request(&mut self) -> bool {
std::mem::take(&mut self.follow_pending)
}
pub(crate) fn follow_selection(&mut self) {
if self.last_height == 0 {
return;
}
let Some(selected) = self.session.selected_row() else {
return;
};
let draft = self.session.draft().is_some();
let Some(layout) = self.patch_visual_layout() else {
return;
};
let Some(target) = layout.focused_visual_row(selected, draft) else {
return;
};
let height = self.last_height.max(1);
if target < self.scroll {
self.scroll = target;
} else if target >= self.scroll.saturating_add(height) {
self.scroll = target.saturating_sub(height.saturating_sub(1));
}
self.scroll = self.scroll.min(layout.len().saturating_sub(height));
}
pub(crate) fn patch_visual_layout(&mut self) -> Option<Arc<PatchVisualLayout>> {
let range = self.session.selected_file_range()?;
let key = self.patch_layout_key(&range);
let rebuild = self
.patch_layout
.as_ref()
.is_none_or(|cached| cached.key != key);
if rebuild {
self.patch_layout = Some(CachedPatchLayout {
key,
layout: Arc::new(PatchVisualLayout::new(
&self.session,
range,
self.presentation_width,
)),
});
}
self.patch_layout
.as_ref()
.map(|cached| cached.layout.clone())
}
fn patch_layout_key(&self, range: &std::ops::Range<usize>) -> u64 {
let mut hasher = DefaultHasher::new();
(Arc::as_ptr(self.document()) as usize).hash(&mut hasher);
self.presentation_width.hash(&mut hasher);
self.layout().is_split().hash(&mut hasher);
self.session.projection_revision().hash(&mut hasher);
range.start.hash(&mut hasher);
range.end.hash(&mut hasher);
for comment in self.review().comments() {
comment.id.hash(&mut hasher);
comment.anchor.hash(&mut hasher);
comment.body.hash(&mut hasher);
comment.outdated.hash(&mut hasher);
}
if let Some(draft) = self.session.draft() {
draft.anchor().hash(&mut hasher);
draft.body().hash(&mut hasher);
draft.cursor().hash(&mut hasher);
}
hasher.finish()
}
pub(crate) fn select_clicked_row(&mut self, row: u16) {
let clicked = self
.visible_rows
.iter()
.find(|(screen_row, _)| *screen_row == row)
.map(|(_, index)| *index);
if let Some(index) = clicked
&& self.session.select_row(index)
{
if self.session.presentation().gap_info(index).is_some() {
self.reveal_selected_gap(RevealAmount::Step);
}
self.request_follow();
}
}
}
fn offset(current: usize, delta: isize, last: usize) -> usize {
if delta.is_negative() {
current.saturating_sub(delta.unsigned_abs())
} else {
current.saturating_add(delta.unsigned_abs()).min(last)
}
}
#[cfg(test)]
mod tests {
use super::*;
use clankerdiff_core::FileDiff;
use clankerdiff_theme::ThemeId;
#[test]
fn new_uses_the_default_sage_theme() {
let state = DiffReviewState::new(Arc::new(DiffDocument::empty()));
assert_eq!(state.theme.id(), &ThemeId::Sage);
}
#[test]
fn comments_above_selection_count_toward_viewport_height() {
let document = Arc::new(DiffDocument {
repo_root: "/repo".into(),
files: vec![FileDiff::from_texts("a.rs", "a\nb\nc\n", "A\nB\nC\n").unwrap()],
});
let mut state = DiffReviewState::new(document);
state.last_height = 3;
let anchor = state.session.selected_anchor().unwrap();
state.session.review_mut().add_comment(anchor, "note");
state.session.move_row(2);
state.follow_selection();
assert!(state.scroll > 0);
}
}