use crate::blame::BlameInfo;
use crate::config::{
BlameMode, DiffExtentMarkerMode, DiffExtentMarkerScope, DiffForegroundMode, DiffHighlightMode,
FileCountMode, FoldContextMode, HunkWrapMode, MentionFileScope, MentionFinder,
ModifiedStepMode, ResolvedTheme, StepWrapMode, SyntaxMode,
};
use crate::keybindings::Keybindings;
use crate::syntax::{SyntaxCache, SyntaxEngine};
use crate::time_format::TimeFormatter;
use oyo_core::{
multi::DiffStatus, AnimationFrame, LineKind, MultiFileDiff, StepDirection, StepState, ViewLine,
};
use ratatui::style::Color;
use regex::Regex;
use rustc_hash::FxHashMap;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant, SystemTime};
mod blame;
mod diff_worker;
mod file_panel;
mod files;
mod navigation;
mod palette;
mod playback;
mod review;
mod search;
mod syntax;
mod types;
mod utils;
pub(crate) use types::{
AnimationPhase, BlameDisplay, BlameRenderCache, BlameRenderKey, PeekMode, PeekScope, PeekState,
UnifiedRenderKey, UnifiedRenderModel, ViewMode, DIFF_VIEW_MIN_WIDTH, FILE_PANEL_MIN_WIDTH,
};
use types::{
BlameCacheKey, BlamePrefetchKey, BlamePrefetchRange, BlameRequest, BlameResponse,
BlameStepHint, DiffRequest, DiffResponse, HunkBounds, HunkEdge, HunkEdgeHint, HunkStart,
NoStepState, StepEdge, StepEdgeHint, SyntaxScopeCache,
};
use utils::{allow_overscroll_state, max_scroll};
pub(crate) use utils::{display_metrics, is_conflict_marker, is_fold_line};
type UnifiedHunkCacheKey = (usize, ViewMode, FoldContextMode, bool, usize, usize, usize);
type SplitHunkCacheKey = (usize, FoldContextMode, bool, bool, usize, usize, usize);
type UnifiedHunkStartsCache = Option<(UnifiedHunkCacheKey, Vec<Option<HunkStart>>)>;
type UnifiedHunkBoundsCache = Option<(UnifiedHunkCacheKey, Vec<Option<HunkBounds>>)>;
type SplitHunkStartsCache = Option<(
SplitHunkCacheKey,
(Vec<Option<HunkStart>>, Vec<Option<HunkStart>>),
)>;
type SplitHunkBoundsCache = Option<(
SplitHunkCacheKey,
(Vec<Option<HunkBounds>>, Vec<Option<HunkBounds>>),
)>;
pub struct App {
pub multi_diff: MultiFileDiff,
pub view_mode: ViewMode,
pub animation_speed: u64,
pub autoplay: bool,
pub autoplay_reverse: bool,
pub scroll_offset: usize,
scroll_offsets_step: Vec<usize>,
scroll_offsets_no_step: Vec<usize>,
no_step_visited: Vec<bool>,
files_visited: Vec<bool>,
pub should_quit: bool,
pub(crate) keybindings: Keybindings,
pub open_dashboard: bool,
pub animation_phase: AnimationPhase,
pub animation_progress: f32,
last_animation_tick: Instant,
last_autoplay_tick: Instant,
pub file_list_focused: bool,
pub file_panel_visible: bool,
pub file_panel_width: u16,
pub file_panel_rect: Option<(u16, u16, u16, u16)>,
pub diff_view_area: Option<(u16, u16, u16, u16)>,
pub file_panel_resizing: bool,
pub file_list_scroll: usize,
pub file_list_area: Option<(u16, u16, u16, u16)>,
pub file_list_rows: Vec<Option<usize>>,
pub file_filter_area: Option<(u16, u16, u16, u16)>,
pub file_count_mode: FileCountMode,
pub file_filter: String,
pub file_filter_active: bool,
pub animation_enabled: bool,
pub zen_mode: bool,
pub needs_scroll_to_active: bool,
pub show_help: bool,
pub help_scroll: usize,
pub help_max_scroll: usize,
pub git_branch: Option<String>,
pub auto_center: bool,
pub overscroll: bool,
pub topbar: bool,
pub animation_duration: u64,
pub pending_count: Option<usize>,
pub files_changed_on_disk: bool,
last_fs_check: Instant,
file_disk_baseline: Vec<FileDiskStamp>,
file_disk_changed: Vec<bool>,
view_build_defer: bool,
view_build_pending: bool,
pub horizontal_scroll: usize,
horizontal_scrolls_step: Vec<usize>,
horizontal_scrolls_no_step: Vec<usize>,
max_line_widths_step: Vec<usize>,
max_line_widths_no_step: Vec<usize>,
pub line_wrap: bool,
pub fold_context: FoldContextMode,
fold_context_default: FoldContextMode,
last_wrap_display_len: Option<usize>,
last_wrap_active_idx: Option<usize>,
pub scrollbar_visible: bool,
pub strikethrough_deletions: bool,
pub gutter_signs: bool,
pub file_panel_manually_set: bool,
pub show_path_popup: bool,
pub file_panel_auto_hidden: bool,
pub auto_step_on_enter: bool,
pub auto_step_blank_files: bool,
pub no_step_auto_jump_on_enter: bool,
pub centered_once: bool,
pub primary_marker: String,
pub primary_marker_right: String,
pub extent_marker: String,
pub extent_marker_right: String,
pub clear_active_on_next_render: bool,
pub theme: ResolvedTheme,
pub time_format: TimeFormatter,
pub theme_is_light: bool,
pub stepping: bool,
pub hunk_wrap: HunkWrapMode,
pub step_wrap: StepWrapMode,
pub diff_bg: bool,
pub diff_fg: DiffForegroundMode,
pub diff_highlight: DiffHighlightMode,
pub diff_extent_marker: DiffExtentMarkerMode,
pub diff_extent_marker_scope: DiffExtentMarkerScope,
pub diff_extent_marker_context: bool,
pub blame_enabled: bool,
pub blame_mode: BlameMode,
pub blame_hunk_hint_enabled: bool,
blame_toggle: bool,
blame_user_name: Option<String>,
blame_cache: FxHashMap<BlameCacheKey, BlameInfo>,
blame_display_cache: FxHashMap<BlameCacheKey, BlameDisplay>,
blame_bar_cache: FxHashMap<BlameCacheKey, Color>,
blame_time_ranges: FxHashMap<BlamePrefetchKey, (i64, i64)>,
hunk_starts_unified_cache: UnifiedHunkStartsCache,
hunk_bounds_unified_cache: UnifiedHunkBoundsCache,
hunk_starts_split_cache: SplitHunkStartsCache,
hunk_bounds_split_cache: SplitHunkBoundsCache,
blame_prefetch: FxHashMap<BlamePrefetchKey, BlamePrefetchRange>,
pub(crate) blame_render_cache: Option<BlameRenderCache>,
pub(crate) blame_cache_revision: u64,
blame_pending: FxHashMap<BlamePrefetchKey, BlamePrefetchRange>,
blame_prefetch_at: Option<Instant>,
blame_worker_tx: Option<mpsc::Sender<BlameRequest>>,
blame_worker_rx: Option<mpsc::Receiver<BlameResponse>>,
pub diff_defer: bool,
pub diff_idle_ms: u64,
diff_last_input: Instant,
diff_queue: VecDeque<usize>,
diff_inflight: Option<usize>,
diff_worker_tx: Option<mpsc::Sender<DiffRequest>>,
diff_worker_rx: Option<mpsc::Receiver<DiffResponse>>,
pub(crate) blame_extra_rows: Option<Vec<usize>>,
blame_step_hint: Option<BlameStepHint>,
blame_hunk_hint: Option<String>,
pub unified_modified_step_mode: ModifiedStepMode,
pub split_align_lines: bool,
pub split_align_fill: String,
pub evo_syntax: crate::config::EvoSyntaxMode,
pub syntax_mode: SyntaxMode,
pub syntax_theme: String,
pub syntax_warmup_active_lines: usize,
pub syntax_warmup_pending_lines: usize,
pub syntax_warmup_idle_lines: usize,
pub syntax_warmup_debounce_ms: u64,
syntax_warmup_frame_old: Option<WarmupRange>,
syntax_warmup_frame_new: Option<WarmupRange>,
syntax_warmup_target: Option<SyntaxWarmupTarget>,
syntax_warmup_target_applied: Option<SyntaxWarmupTarget>,
syntax_warmup_target_at: Option<Instant>,
syntax_engine: Option<SyntaxEngine>,
syntax_caches: Vec<Option<SyntaxCache>>,
show_syntax_scopes: bool,
syntax_scope_cache: Option<SyntaxScopeCache>,
peek_state: Option<PeekState>,
step_peek_state: Option<PeekState>,
step_state_snapshots: Vec<Option<StepState>>,
no_step_state_snapshots: Vec<Option<NoStepState>>,
step_view_mode: ViewMode,
search_query: String,
search_active: bool,
command_palette_query: String,
command_palette_active: bool,
command_palette_selection: usize,
command_palette_list_area: Option<(u16, u16, u16, u16)>,
command_palette_list_start: usize,
command_palette_list_count: usize,
command_palette_item_height: u16,
file_search_query: String,
file_search_active: bool,
file_search_selection: usize,
file_search_list_area: Option<(u16, u16, u16, u16)>,
file_search_list_start: usize,
file_search_list_count: usize,
file_search_item_height: u16,
review_mode: bool,
review_comments: Vec<review::ReviewComment>,
review_editor: Option<review::ReviewEditorState>,
review_session_path: Option<PathBuf>,
review_persist_enabled: bool,
review_clear_session_on_start: bool,
review_diff_fingerprint: String,
review_repo_root: Option<String>,
review_session_created_at: u64,
review_next_comment_id: u64,
review_submission_output: Option<String>,
review_preview_boxes: Vec<review::ReviewPreviewBox>,
review_mention_picker: Option<review::ReviewMentionPickerState>,
pub review_mention_file_scope: MentionFileScope,
pub review_mention_finder: MentionFinder,
review_mention_fzf_available: Option<bool>,
review_repo_file_cache: Option<Vec<String>>,
review_revision: u64,
search_last_target: Option<usize>,
needs_scroll_to_search: bool,
search_target: Option<usize>,
search_regex: Option<Regex>,
goto_query: String,
goto_active: bool,
snap_frame: Option<AnimationFrame>,
snap_frame_started_at: Option<Instant>,
autoplay_remaining: Option<usize>,
step_edge_hint: Option<StepEdgeHint>,
hunk_edge_hint: Option<HunkEdgeHint>,
pub last_viewport_height: usize,
view_cache: Option<ViewCache>,
pub(crate) unified_render_cache: Option<UnifiedRenderModel>,
view_window_start: usize,
view_window_total_len: Option<usize>,
}
const SNAP_PHASE_MS: u64 = 50;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ViewCacheKey {
file_index: usize,
view_mode: ViewMode,
frame: AnimationFrame,
current_step: usize,
active_change: Option<usize>,
cursor_change: Option<usize>,
animating_hunk: Option<usize>,
step_direction: StepDirection,
current_hunk: usize,
last_nav_was_hunk: bool,
hunk_preview_mode: bool,
preview_from_backward: bool,
show_hunk_extent_while_stepping: bool,
placeholder_view: bool,
fold_context: FoldContextMode,
viewport_height: usize,
windowed: bool,
window_start: usize,
}
struct ViewCache {
key: ViewCacheKey,
lines: std::sync::Arc<Vec<ViewLine>>,
window_start: usize,
window_total_len: Option<usize>,
}
#[derive(Clone, Copy, Debug)]
struct ViewWindow {
start: usize,
end: usize,
total_len: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct WarmupRange {
pub(crate) start: usize,
pub(crate) end: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct SyntaxWarmupTarget {
file_index: usize,
old: Option<WarmupRange>,
new: Option<WarmupRange>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct FileDiskStamp {
pub(crate) modified: Option<SystemTime>,
pub(crate) len: u64,
pub(crate) exists: bool,
}
impl App {
pub fn new(
multi_diff: MultiFileDiff,
view_mode: ViewMode,
animation_speed: u64,
autoplay: bool,
git_branch: Option<String>,
) -> Self {
let file_count = multi_diff.file_count();
let mut app = Self {
multi_diff,
view_mode,
animation_speed,
autoplay,
autoplay_reverse: false,
scroll_offset: 0,
scroll_offsets_step: vec![0; file_count],
scroll_offsets_no_step: vec![0; file_count],
no_step_visited: vec![false; file_count],
files_visited: vec![false; file_count],
should_quit: false,
keybindings: Keybindings::default(),
open_dashboard: false,
animation_phase: AnimationPhase::Idle,
animation_progress: 1.0,
last_animation_tick: Instant::now(),
last_autoplay_tick: Instant::now(),
file_list_focused: false,
file_panel_visible: true,
file_panel_width: 30,
file_panel_rect: None,
diff_view_area: None,
file_panel_resizing: false,
file_list_scroll: 0,
file_list_area: None,
file_list_rows: Vec::new(),
file_filter_area: None,
file_count_mode: FileCountMode::Active,
file_filter: String::new(),
file_filter_active: false,
animation_enabled: false,
zen_mode: false,
needs_scroll_to_active: true, show_help: false,
help_scroll: 0,
help_max_scroll: 0,
git_branch,
auto_center: true,
overscroll: false,
topbar: true,
animation_duration: 150,
pending_count: None,
files_changed_on_disk: false,
last_fs_check: Instant::now(),
file_disk_baseline: vec![FileDiskStamp::default(); file_count],
file_disk_changed: vec![false; file_count],
view_build_defer: false,
view_build_pending: false,
horizontal_scroll: 0,
horizontal_scrolls_step: vec![0; file_count],
horizontal_scrolls_no_step: vec![0; file_count],
max_line_widths_step: vec![0; file_count],
max_line_widths_no_step: vec![0; file_count],
line_wrap: false,
fold_context: FoldContextMode::Off,
fold_context_default: FoldContextMode::Off,
last_wrap_display_len: None,
last_wrap_active_idx: None,
scrollbar_visible: false,
strikethrough_deletions: false,
gutter_signs: true,
file_panel_manually_set: false,
show_path_popup: false,
file_panel_auto_hidden: false,
auto_step_on_enter: true,
auto_step_blank_files: true,
no_step_auto_jump_on_enter: true,
centered_once: false,
primary_marker: "▶".to_string(),
primary_marker_right: "◀".to_string(),
extent_marker: "▌".to_string(),
extent_marker_right: "▐".to_string(),
clear_active_on_next_render: false,
theme: ResolvedTheme::default(),
time_format: TimeFormatter::default(),
theme_is_light: false,
stepping: true,
hunk_wrap: HunkWrapMode::None,
step_wrap: StepWrapMode::None,
diff_bg: false,
diff_fg: DiffForegroundMode::Theme,
diff_highlight: DiffHighlightMode::Text,
diff_extent_marker: DiffExtentMarkerMode::Neutral,
diff_extent_marker_scope: DiffExtentMarkerScope::Progress,
diff_extent_marker_context: false,
blame_enabled: false,
blame_mode: BlameMode::OneShot,
blame_hunk_hint_enabled: true,
blame_toggle: false,
blame_user_name: None,
blame_cache: FxHashMap::default(),
blame_display_cache: FxHashMap::default(),
blame_bar_cache: FxHashMap::default(),
blame_time_ranges: FxHashMap::default(),
hunk_starts_unified_cache: None,
hunk_bounds_unified_cache: None,
hunk_starts_split_cache: None,
hunk_bounds_split_cache: None,
blame_prefetch: FxHashMap::default(),
blame_render_cache: None,
blame_cache_revision: 0,
blame_pending: FxHashMap::default(),
blame_prefetch_at: None,
blame_worker_tx: None,
blame_worker_rx: None,
diff_defer: true,
diff_idle_ms: 250,
diff_last_input: Instant::now(),
diff_queue: VecDeque::new(),
diff_inflight: None,
diff_worker_tx: None,
diff_worker_rx: None,
blame_extra_rows: None,
blame_step_hint: None,
blame_hunk_hint: None,
unified_modified_step_mode: ModifiedStepMode::Mixed,
split_align_lines: false,
split_align_fill: "╱".to_string(),
evo_syntax: crate::config::EvoSyntaxMode::Context,
syntax_mode: SyntaxMode::On,
syntax_theme: "ansi".to_string(),
syntax_warmup_active_lines: 100,
syntax_warmup_pending_lines: 300,
syntax_warmup_idle_lines: 1_000,
syntax_warmup_debounce_ms: 80,
syntax_warmup_frame_old: None,
syntax_warmup_frame_new: None,
syntax_warmup_target: None,
syntax_warmup_target_applied: None,
syntax_warmup_target_at: None,
syntax_engine: None,
syntax_caches: vec![None; file_count],
show_syntax_scopes: false,
syntax_scope_cache: None,
peek_state: None,
step_peek_state: None,
step_state_snapshots: vec![None; file_count],
no_step_state_snapshots: vec![None; file_count],
step_view_mode: view_mode,
search_query: String::new(),
search_active: false,
command_palette_query: String::new(),
command_palette_active: false,
command_palette_selection: 0,
command_palette_list_area: None,
command_palette_list_start: 0,
command_palette_list_count: 0,
command_palette_item_height: 1,
file_search_query: String::new(),
file_search_active: false,
file_search_selection: 0,
file_search_list_area: None,
file_search_list_start: 0,
file_search_list_count: 0,
file_search_item_height: 1,
review_mode: false,
review_comments: Vec::new(),
review_editor: None,
review_session_path: None,
review_persist_enabled: true,
review_clear_session_on_start: false,
review_diff_fingerprint: String::new(),
review_repo_root: None,
review_session_created_at: 0,
review_next_comment_id: 1,
review_submission_output: None,
review_preview_boxes: Vec::new(),
review_mention_picker: None,
review_mention_file_scope: MentionFileScope::default(),
review_mention_finder: MentionFinder::default(),
review_mention_fzf_available: None,
review_repo_file_cache: None,
review_revision: 0,
search_last_target: None,
needs_scroll_to_search: false,
search_target: None,
search_regex: None,
goto_query: String::new(),
goto_active: false,
snap_frame: None,
snap_frame_started_at: None,
autoplay_remaining: None,
step_edge_hint: None,
hunk_edge_hint: None,
last_viewport_height: 0,
view_cache: None,
unified_render_cache: None,
view_window_start: 0,
view_window_total_len: None,
};
app.rebuild_file_disk_baseline();
app
}
pub fn push_count_digit(&mut self, digit: u8) {
let current = self.pending_count.unwrap_or(0);
let new_count = current.saturating_mul(10).saturating_add(digit as usize);
self.pending_count = Some(new_count.min(9999));
}
pub fn take_count(&mut self) -> usize {
self.pending_count.take().unwrap_or(1)
}
pub fn reset_count(&mut self) {
self.pending_count = None;
}
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
if self.show_help {
self.help_scroll = 0;
}
}
pub fn help_scroll_up(&mut self) {
self.help_scroll = self.help_scroll.saturating_sub(1);
}
pub fn help_scroll_down(&mut self) {
self.help_scroll = (self.help_scroll + 1).min(self.help_max_scroll);
}
pub fn toggle_path_popup(&mut self) {
self.show_path_popup = !self.show_path_popup;
}
pub fn toggle_zen(&mut self) {
self.zen_mode = !self.zen_mode;
}
pub fn toggle_syntax(&mut self) {
self.syntax_mode = match self.syntax_mode {
SyntaxMode::On => SyntaxMode::Off,
SyntaxMode::Off => SyntaxMode::On,
};
if matches!(self.syntax_mode, SyntaxMode::Off) {
self.syntax_engine = None;
self.syntax_caches = vec![None; self.multi_diff.file_count()];
}
}
pub fn toggle_evo_syntax(&mut self) {
self.evo_syntax = match self.evo_syntax {
crate::config::EvoSyntaxMode::Context => crate::config::EvoSyntaxMode::Full,
crate::config::EvoSyntaxMode::Full => crate::config::EvoSyntaxMode::Context,
};
}
pub fn scroll_up(&mut self) {
self.centered_once = false;
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.centered_once = false;
self.scroll_offset += 1;
}
pub fn scroll_half_page_up(&mut self, viewport_height: usize) {
self.centered_once = false;
let half = viewport_height / 2;
self.scroll_offset = self.scroll_offset.saturating_sub(half);
}
pub fn clamp_scroll(
&mut self,
total_lines: usize,
viewport_height: usize,
allow_overscroll: bool,
) {
self.scroll_offset =
self.scroll_offset
.min(max_scroll(total_lines, viewport_height, allow_overscroll));
}
pub fn allow_overscroll(&self) -> bool {
allow_overscroll_state(
self.overscroll,
self.auto_center,
self.needs_scroll_to_active,
self.centered_once,
)
}
pub fn scroll_half_page_down(&mut self, viewport_height: usize) {
self.centered_once = false;
let half = viewport_height / 2;
self.scroll_offset += half;
}
pub fn scroll_left(&mut self) {
if !self.line_wrap {
self.horizontal_scroll = self.horizontal_scroll.saturating_sub(4);
}
}
pub fn scroll_right(&mut self) {
if !self.line_wrap {
self.horizontal_scroll += 4;
}
}
pub fn scroll_to_line_start(&mut self) {
self.horizontal_scroll = 0;
}
pub fn scroll_to_line_end(&mut self) {
if !self.line_wrap {
self.horizontal_scroll = usize::MAX / 2;
}
}
pub fn clamp_horizontal_scroll(&mut self, max_line_width: usize, viewport_width: usize) {
if !self.line_wrap {
let max_scroll = max_line_width.saturating_sub(viewport_width);
self.horizontal_scroll = self.horizontal_scroll.min(max_scroll);
}
}
pub fn clamp_horizontal_scroll_cached(&mut self, viewport_width: usize) {
if self.line_wrap {
return;
}
let max_line_width = self.current_max_line_width();
if max_line_width == 0 {
return;
}
let max_scroll = max_line_width.saturating_sub(viewport_width);
self.horizontal_scroll = self.horizontal_scroll.min(max_scroll);
}
pub fn reset_current_max_line_width(&mut self) {
let idx = self.multi_diff.selected_index;
if self.stepping {
if let Some(slot) = self.max_line_widths_step.get_mut(idx) {
*slot = 0;
}
} else if let Some(slot) = self.max_line_widths_no_step.get_mut(idx) {
*slot = 0;
}
}
pub fn set_current_max_line_width(&mut self, max_line_width: usize) {
let idx = self.multi_diff.selected_index;
if self.stepping {
if let Some(slot) = self.max_line_widths_step.get_mut(idx) {
*slot = max_line_width;
}
} else if let Some(slot) = self.max_line_widths_no_step.get_mut(idx) {
*slot = max_line_width;
}
}
pub fn update_current_max_line_width(&mut self, max_line_width: usize) {
let idx = self.multi_diff.selected_index;
if self.stepping {
if let Some(slot) = self.max_line_widths_step.get_mut(idx) {
*slot = (*slot).max(max_line_width);
}
} else if let Some(slot) = self.max_line_widths_no_step.get_mut(idx) {
*slot = (*slot).max(max_line_width);
}
}
fn current_max_line_width(&self) -> usize {
let idx = self.multi_diff.selected_index;
if self.stepping {
self.max_line_widths_step.get(idx).copied().unwrap_or(0)
} else {
self.max_line_widths_no_step.get(idx).copied().unwrap_or(0)
}
}
pub fn toggle_line_wrap(&mut self) {
self.line_wrap = !self.line_wrap;
if self.line_wrap {
self.horizontal_scroll = 0;
}
self.last_wrap_display_len = None;
self.last_wrap_active_idx = None;
self.needs_scroll_to_active = true;
self.centered_once = false;
}
pub fn toggle_fold_context(&mut self) {
if self.fold_context.is_enabled() {
self.fold_context = FoldContextMode::Off;
} else if self.fold_context_default.is_enabled() {
self.fold_context = self.fold_context_default;
} else {
self.fold_context = FoldContextMode::On;
}
self.last_wrap_display_len = None;
self.last_wrap_active_idx = None;
self.needs_scroll_to_active = true;
self.centered_once = false;
self.blame_render_cache = None;
}
pub fn set_fold_context_mode(&mut self, mode: FoldContextMode) {
self.fold_context = mode;
self.fold_context_default = mode;
}
pub fn toggle_strikethrough_deletions(&mut self) {
self.strikethrough_deletions = !self.strikethrough_deletions;
}
fn wrap_to_file_hunk(&mut self, forward: bool, stepping: bool) -> bool {
let indices = if !self.file_filter.is_empty() {
self.filtered_file_indices()
} else {
(0..self.multi_diff.file_count()).collect()
};
if indices.is_empty() {
return false;
}
let current = self.multi_diff.selected_index;
let start_pos = indices.iter().position(|&i| i == current).unwrap_or(0);
for offset in 1..=indices.len() {
let pos = if forward {
(start_pos + offset) % indices.len()
} else {
(start_pos + indices.len().saturating_sub(offset)) % indices.len()
};
let index = indices[pos];
if index == current {
break;
}
self.select_file(index);
let total = self.multi_diff.current_navigator().state().total_hunks;
if total == 0 {
continue;
}
let target = if forward { 0 } else { total.saturating_sub(1) };
if stepping {
self.goto_hunk_index(target);
} else {
self.goto_hunk_index_scroll(target);
}
return true;
}
false
}
fn active_scroll_buffers(&self) -> (&Vec<usize>, &Vec<usize>) {
if self.stepping {
(&self.scroll_offsets_step, &self.horizontal_scrolls_step)
} else {
(
&self.scroll_offsets_no_step,
&self.horizontal_scrolls_no_step,
)
}
}
fn active_scroll_buffers_mut(&mut self) -> (&mut Vec<usize>, &mut Vec<usize>) {
if self.stepping {
(
&mut self.scroll_offsets_step,
&mut self.horizontal_scrolls_step,
)
} else {
(
&mut self.scroll_offsets_no_step,
&mut self.horizontal_scrolls_no_step,
)
}
}
fn save_scroll_position_for(&mut self, index: usize) {
let scroll_offset = self.scroll_offset;
let horizontal_scroll = self.horizontal_scroll;
let (scrolls, horizontals) = self.active_scroll_buffers_mut();
if let Some(slot) = scrolls.get_mut(index) {
*slot = scroll_offset;
}
if let Some(slot) = horizontals.get_mut(index) {
*slot = horizontal_scroll;
}
}
fn restore_scroll_position_for(&mut self, index: usize) {
let (scrolls, horizontals) = self.active_scroll_buffers();
let scroll_value = scrolls.get(index).copied();
let horizontal_value = horizontals.get(index).copied();
if let Some(value) = scroll_value {
self.scroll_offset = value;
}
if let Some(value) = horizontal_value {
self.horizontal_scroll = value;
}
}
fn save_step_state_snapshot(&mut self, index: usize) {
let state = self.multi_diff.current_navigator().state().clone();
if let Some(slot) = self.step_state_snapshots.get_mut(index) {
*slot = Some(state);
}
}
fn restore_step_state_snapshot(&mut self, index: usize) -> bool {
let Some(snapshot) = self.step_state_snapshots.get(index).and_then(|s| s.clone()) else {
return false;
};
self.multi_diff.current_navigator().set_state(snapshot)
}
fn ensure_step_state_snapshot(&mut self, index: usize) {
let needs_snapshot = self
.step_state_snapshots
.get(index)
.map(|slot| slot.is_none())
.unwrap_or(false);
if !needs_snapshot {
return;
}
let state = self.multi_diff.current_navigator().state().clone();
if let Some(slot) = self.step_state_snapshots.get_mut(index) {
*slot = Some(state);
}
}
fn save_no_step_state_snapshot(&mut self, index: usize) {
if self.stepping {
return;
}
let state = self.multi_diff.current_navigator().state();
if let Some(slot) = self.no_step_state_snapshots.get_mut(index) {
*slot = Some(NoStepState {
current_hunk: state.current_hunk,
cursor_change: state.cursor_change,
last_nav_was_hunk: state.last_nav_was_hunk,
});
}
}
fn restore_no_step_state_snapshot(&mut self, index: usize) -> bool {
let Some(snapshot) = self.no_step_state_snapshots.get(index).and_then(|s| *s) else {
return false;
};
if snapshot.last_nav_was_hunk && snapshot.cursor_change.is_some() {
self.multi_diff
.current_navigator()
.set_cursor_hunk(snapshot.current_hunk, snapshot.cursor_change);
self.multi_diff
.current_navigator()
.set_hunk_scope(snapshot.last_nav_was_hunk);
} else if self.no_step_auto_jump_on_enter {
self.goto_hunk_index_scroll(0);
} else {
self.multi_diff.current_navigator().clear_cursor_change();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
true
}
fn start_animation(&mut self) {
self.animation_phase = AnimationPhase::FadeOut;
self.animation_progress = 0.0;
self.last_animation_tick = Instant::now();
}
pub(crate) fn animation_frame(&self) -> AnimationFrame {
if let Some(frame) = self.snap_frame {
return frame;
}
if self.clear_active_on_next_render {
return AnimationFrame::FadeOut;
}
match self.animation_phase {
AnimationPhase::FadeOut => AnimationFrame::FadeOut,
AnimationPhase::FadeIn => AnimationFrame::FadeIn,
AnimationPhase::Idle => AnimationFrame::Idle,
}
}
pub(crate) fn current_file_diff_ready(&self) -> bool {
matches!(
self.multi_diff.current_file_diff_status(),
DiffStatus::Ready
)
}
fn view_cache_key(
&mut self,
frame: AnimationFrame,
windowed: bool,
window_start: usize,
) -> ViewCacheKey {
let idx = self.multi_diff.selected_index;
let state = self.multi_diff.current_navigator().state();
ViewCacheKey {
file_index: idx,
view_mode: self.view_mode,
frame,
current_step: state.current_step,
active_change: state.active_change,
cursor_change: state.cursor_change,
animating_hunk: state.animating_hunk,
step_direction: state.step_direction,
current_hunk: state.current_hunk,
last_nav_was_hunk: state.last_nav_was_hunk,
hunk_preview_mode: state.hunk_preview_mode,
preview_from_backward: state.preview_from_backward,
show_hunk_extent_while_stepping: state.show_hunk_extent_while_stepping,
placeholder_view: self.multi_diff.current_navigator_is_placeholder(),
fold_context: self.fold_context,
viewport_height: self.last_viewport_height,
windowed,
window_start,
}
}
pub(crate) fn render_scroll_offset(&self) -> usize {
self.scroll_offset.saturating_sub(self.view_window_start)
}
pub(crate) fn peek_state(&self) -> Option<PeekState> {
self.peek_state
}
pub(crate) fn view_window_start(&self) -> usize {
self.view_window_start
}
pub(crate) fn view_windowed(&self) -> bool {
self.view_window_total_len.is_some()
}
pub(crate) fn render_total_lines(&self, view_len: usize) -> usize {
self.view_window_total_len.unwrap_or(view_len)
}
pub(crate) fn view_build_pending(&self) -> bool {
self.view_build_pending
}
pub(crate) fn defer_view_build_for_jump(&mut self) {
if !self.stepping {
return;
}
if !self.multi_diff.current_file_is_large() {
return;
}
if !self.current_file_diff_ready() {
return;
}
if self.view_cache.is_none() {
return;
}
self.view_build_defer = true;
}
fn cancel_view_build_defer(&mut self) {
self.view_build_defer = false;
self.view_build_pending = false;
}
fn compute_view_window(&mut self) -> Option<ViewWindow> {
if self.line_wrap {
return None;
}
if !self.multi_diff.current_file_is_large() {
return None;
}
let allow_split = self.view_mode == ViewMode::Split
&& self.split_align_lines
&& !self.fold_context.is_enabled();
if !matches!(self.view_mode, ViewMode::UnifiedPane | ViewMode::Blame) && !allow_split {
return None;
}
let allow_overscroll = self.allow_overscroll();
let nav = self.multi_diff.current_navigator();
let total_len = nav.diff().changes.len();
if total_len == 0 {
return None;
}
let viewport_height = self.last_viewport_height.max(1);
let max_scroll = max_scroll(total_len, viewport_height, allow_overscroll);
if self.scroll_offset > max_scroll {
self.scroll_offset = max_scroll;
}
let span = self.last_viewport_height.max(20).saturating_mul(4).max(200);
let margin = span / 4;
if self.stepping {
let state = nav.state();
let change_id = state
.active_change
.or(state.applied_changes.last().copied())
.or_else(|| nav.diff().significant_changes.first().copied())?;
let idx = nav.change_index_for(change_id)?;
let mut start = idx.saturating_sub(span);
let mut end = (idx + span).min(total_len.saturating_sub(1));
let scroll = self.scroll_offset.min(total_len.saturating_sub(1));
if !self.needs_scroll_to_active {
let inside_window =
scroll >= start.saturating_add(margin) && scroll <= end.saturating_sub(margin);
if !inside_window {
start = scroll.saturating_sub(margin);
end = (start + span).min(total_len.saturating_sub(1));
}
}
return Some(ViewWindow {
start,
end,
total_len,
});
}
let scroll = self.scroll_offset.min(total_len.saturating_sub(1));
if total_len <= span {
return Some(ViewWindow {
start: 0,
end: total_len.saturating_sub(1),
total_len,
});
}
let mut start = scroll.saturating_sub(margin);
let mut end = (start + span).min(total_len.saturating_sub(1));
if self.view_window_total_len.is_some() {
let current_start = self.view_window_start;
let current_end = (current_start + span).min(total_len.saturating_sub(1));
let inside_window = scroll >= current_start.saturating_add(margin)
&& scroll <= current_end.saturating_sub(margin);
if inside_window {
start = current_start;
end = current_end;
}
}
Some(ViewWindow {
start,
end,
total_len,
})
}
pub(crate) fn current_view_with_frame(
&mut self,
frame: AnimationFrame,
) -> std::sync::Arc<Vec<ViewLine>> {
let window = self.compute_view_window();
let windowed = window.is_some();
let window_start = window.map(|w| w.start).unwrap_or(0);
let mut window_start_override = None;
let mut window_total_override = None;
let key = self.view_cache_key(frame, windowed, window_start);
if let Some(cache) = &self.view_cache {
if cache.key == key {
self.view_window_start = cache.window_start;
self.view_window_total_len = cache.window_total_len;
self.view_build_pending = false;
return cache.lines.clone();
}
}
if self.view_build_defer {
self.view_build_defer = false;
if let Some(cache) = &self.view_cache {
if cache.key.file_index == self.multi_diff.selected_index {
self.view_window_start = cache.window_start;
self.view_window_total_len = cache.window_total_len;
self.view_build_pending = true;
return cache.lines.clone();
}
}
self.view_build_pending = false;
} else {
self.view_build_pending = false;
}
let mut view = if let Some(window) = window {
let nav = self.multi_diff.current_navigator();
let view = nav.current_view_for_change_range(frame, window.start, window.end);
if self.view_mode == ViewMode::Evolution {
let display_start = nav
.evolution_display_index_for_change_index(window.start)
.unwrap_or(0);
window_start_override = Some(display_start);
window_total_override = Some(nav.evolution_visible_len());
}
view
} else if self.stepping && self.multi_diff.current_file_is_large() {
let nav = self.multi_diff.current_navigator();
let state = nav.state();
let change_id = state
.active_change
.or(state.applied_changes.last().copied())
.or_else(|| nav.diff().significant_changes.first().copied());
if let Some(change_id) = change_id {
let radius = self.last_viewport_height.max(20).saturating_mul(4).max(200);
if self.view_mode == ViewMode::Evolution {
if let Some(display_idx) = nav.evolution_display_index_or_nearest(change_id) {
if let Some((start, end)) =
nav.evolution_change_range_for_display(display_idx, radius)
{
let start_display = display_idx.saturating_sub(radius);
window_start_override = Some(start_display);
window_total_override = Some(nav.evolution_visible_len());
nav.current_view_for_change_range(frame, start, end)
} else if let Some(anchor) =
nav.evolution_nearest_visible_change_id_dynamic(change_id, radius)
{
nav.current_view_for_change_window(frame, anchor, radius)
} else {
nav.current_view_for_change_window(frame, change_id, radius)
}
} else if let Some(anchor) =
nav.evolution_nearest_visible_change_id_dynamic(change_id, radius)
{
nav.current_view_for_change_window(frame, anchor, radius)
} else {
nav.current_view_for_change_window(frame, change_id, radius)
}
} else {
nav.current_view_for_change_window(frame, change_id, radius)
}
} else {
nav.current_view_with_frame(frame)
}
} else {
self.multi_diff
.current_navigator()
.current_view_with_frame(frame)
};
if self.view_mode == ViewMode::Evolution && self.multi_diff.current_file_is_large() {
let nav = self.multi_diff.current_navigator();
let hunk_preview = nav.state().hunk_preview_mode;
let has_visible = view.iter().any(|line| match line.kind {
LineKind::Deleted => false,
LineKind::PendingDelete => {
if hunk_preview {
false
} else {
frame != AnimationFrame::Idle && line.is_active_change
}
}
_ => true,
});
if !has_visible {
let state = nav.state();
let change_id = nav
.state()
.active_change
.or(state.applied_changes.last().copied())
.or_else(|| nav.diff().significant_changes.first().copied());
if let Some(change_id) = change_id {
let radius = self.last_viewport_height.max(20).saturating_mul(4).max(200);
if let Some(anchor) =
nav.evolution_nearest_visible_change_id_dynamic(change_id, radius)
{
if let Some(display_idx) = nav.evolution_display_index_for_change(anchor) {
window_start_override = Some(display_idx.saturating_sub(radius));
window_total_override = Some(nav.evolution_visible_len());
}
view = nav.current_view_for_change_window(frame, anchor, radius);
}
}
}
}
let view = utils::fold_context_view(view, self.fold_context);
let lines = std::sync::Arc::new(view);
let applied_start = window_start_override.unwrap_or(window_start);
let applied_total = window_total_override.or(window.map(|w| w.total_len));
self.view_window_start = applied_start;
self.view_window_total_len = applied_total;
self.view_cache = Some(ViewCache {
key,
lines: lines.clone(),
window_start: applied_start,
window_total_len: self.view_window_total_len,
});
lines
}
pub(crate) fn is_backward_animation(&self) -> bool {
if self.snap_frame.is_some() {
return self.multi_diff.current_step_direction() == StepDirection::Backward;
}
self.animation_phase != AnimationPhase::Idle
&& self.multi_diff.current_step_direction() == StepDirection::Backward
}
pub(crate) fn allow_virtual_lines(&self) -> bool {
if self.snap_frame.is_some() {
return false;
}
!self.is_backward_animation()
}
pub(crate) fn cursor_visible_in_wrap(&self, viewport_height: usize) -> bool {
self.last_wrap_active_idx
.map(|idx| {
idx >= self.scroll_offset
&& idx < self.scroll_offset.saturating_add(viewport_height)
})
.unwrap_or(false)
}
pub fn ensure_active_visible_if_needed(&mut self, viewport_height: usize) {
if !self.current_file_diff_ready() {
return;
}
if self.handle_search_scroll_if_needed(viewport_height) {
return;
}
if !self.needs_scroll_to_active {
return;
}
if self.auto_center && self.snap_frame.is_some() {
return;
}
let frame = self.animation_frame();
let view = self.current_view_with_frame(frame);
if self.view_build_pending {
return;
}
let step_direction = self.multi_diff.current_step_direction();
let auto_center = self.auto_center;
if auto_center {
self.center_on_active(viewport_height);
self.needs_scroll_to_active = false;
return;
}
let scroll_offset = self.render_scroll_offset();
let view_start = self.view_window_start;
let (display_len, display_idx) = display_metrics(
&view,
self.view_mode,
self.animation_phase,
scroll_offset,
step_direction,
self.split_align_lines,
);
if let Some(idx) = display_idx {
let margin = 3.min(viewport_height / 4);
if idx < scroll_offset.saturating_add(margin) {
self.scroll_offset = view_start.saturating_add(idx.saturating_sub(margin));
}
else if idx >= scroll_offset.saturating_add(viewport_height.saturating_sub(margin)) {
self.scroll_offset = view_start
.saturating_add(idx.saturating_sub(viewport_height.saturating_sub(margin + 1)));
}
} else if display_len > 0 {
let state = self.multi_diff.current_navigator().state();
if self.view_mode == ViewMode::Evolution && self.stepping && state.current_step > 0 {
return;
}
self.scroll_offset = 0;
}
self.needs_scroll_to_active = false;
}
pub fn ensure_active_visible_if_needed_wrapped(
&mut self,
viewport_height: usize,
display_len: usize,
display_idx: Option<usize>,
) {
if !self.current_file_diff_ready() {
return;
}
self.last_wrap_display_len = Some(display_len);
self.last_wrap_active_idx = display_idx;
if !self.needs_scroll_to_active {
return;
}
if self.auto_center && self.snap_frame.is_some() {
return;
}
self.needs_scroll_to_active = false;
if self.auto_center {
self.center_with_display_idx(viewport_height, display_len, display_idx);
return;
}
if let Some(idx) = display_idx {
let margin = 3.min(viewport_height / 4);
if idx < self.scroll_offset.saturating_add(margin) {
self.scroll_offset = idx.saturating_sub(margin);
} else if idx
>= self
.scroll_offset
.saturating_add(viewport_height.saturating_sub(margin))
{
self.scroll_offset = idx.saturating_sub(viewport_height.saturating_sub(margin + 1));
}
} else if display_len > 0 {
let state = self.multi_diff.current_navigator().state();
if self.view_mode == ViewMode::Evolution && self.stepping && state.current_step > 0 {
return;
}
self.scroll_offset = 0;
}
}
fn center_with_display_idx(
&mut self,
viewport_height: usize,
display_len: usize,
display_idx: Option<usize>,
) {
if let Some(idx) = display_idx {
let half_viewport = viewport_height / 2;
self.scroll_offset = idx.saturating_sub(half_viewport);
} else if display_len > 0 {
let state = self.multi_diff.current_navigator().state();
if self.view_mode == ViewMode::Evolution && self.stepping && state.current_step > 0 {
return;
}
self.scroll_offset = 0;
}
self.centered_once = true;
self.horizontal_scroll = 0;
}
pub fn center_on_active(&mut self, viewport_height: usize) {
if self.line_wrap {
if let Some(display_len) = self.last_wrap_display_len {
let display_idx = self.last_wrap_active_idx;
self.center_with_display_idx(viewport_height, display_len, display_idx);
return;
}
}
if self.multi_diff.current_file_is_large() && self.view_mode != ViewMode::Split {
let nav = self.multi_diff.current_navigator();
let state = nav.state();
let primary_change = if state.cursor_change.is_some()
&& state.active_change.is_none()
&& state.step_direction == StepDirection::None
{
state.cursor_change
} else if state.step_direction == StepDirection::Backward {
state
.applied_changes
.last()
.copied()
.or(state.active_change)
} else {
state.active_change
};
let change_id = primary_change
.or(state.applied_changes.last().copied())
.or_else(|| nav.diff().significant_changes.first().copied());
if let Some(change_id) = change_id {
if self.view_mode == ViewMode::Evolution {
if let Some(idx) = nav.evolution_display_index_or_nearest(change_id) {
let display_len = nav.evolution_visible_len();
self.center_with_display_idx(viewport_height, display_len, Some(idx));
return;
}
} else if let Some(idx) = nav.change_index_for(change_id) {
let display_len = nav.diff().changes.len();
self.center_with_display_idx(viewport_height, display_len, Some(idx));
return;
}
}
}
let frame = self.animation_frame();
let view = self.current_view_with_frame(frame);
let step_direction = self.multi_diff.current_step_direction();
let (display_len, display_idx) = display_metrics(
&view,
self.view_mode,
self.animation_phase,
self.render_scroll_offset(),
step_direction,
self.split_align_lines,
);
let total_len = self.render_total_lines(display_len);
let global_idx = display_idx.map(|idx| idx.saturating_add(self.view_window_start));
self.center_with_display_idx(viewport_height, total_len, global_idx);
}
pub fn tick(&mut self) {
let now = Instant::now();
if let Some(hint) = self.step_edge_hint {
if now >= hint.until {
self.step_edge_hint = None;
}
}
if let Some(hint) = self.hunk_edge_hint {
if now >= hint.until {
self.hunk_edge_hint = None;
}
}
self.poll_diff_responses();
self.maybe_queue_idle_diff();
self.maybe_check_file_changes();
if let Some(frame) = self.snap_frame {
let started_at = self.snap_frame_started_at.get_or_insert(now);
let phase_duration = Duration::from_millis(SNAP_PHASE_MS);
if now.duration_since(*started_at) >= phase_duration {
match frame {
AnimationFrame::FadeOut => {
self.snap_frame = Some(AnimationFrame::FadeIn);
self.snap_frame_started_at = Some(now);
}
AnimationFrame::FadeIn | AnimationFrame::Idle => {
self.snap_frame = None;
self.snap_frame_started_at = None;
let step_dir = self.multi_diff.current_navigator().state().step_direction;
if step_dir == StepDirection::Backward {
self.multi_diff.current_navigator().clear_active_change();
}
}
}
}
}
if self.animation_phase != AnimationPhase::Idle {
let elapsed = now.duration_since(self.last_animation_tick);
let phase_duration = Duration::from_millis(self.animation_duration);
self.animation_progress =
(elapsed.as_secs_f32() / phase_duration.as_secs_f32()).min(1.0);
if self.animation_progress >= 1.0 {
match self.animation_phase {
AnimationPhase::FadeOut => {
self.animation_phase = AnimationPhase::FadeIn;
self.animation_progress = 0.0;
self.last_animation_tick = now;
}
AnimationPhase::FadeIn => {
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
let step_dir = self.multi_diff.current_navigator().state().step_direction;
if step_dir == StepDirection::Backward {
self.multi_diff.current_navigator().clear_active_change();
}
}
AnimationPhase::Idle => {}
}
}
}
if self.stepping && self.autoplay && self.animation_phase == AnimationPhase::Idle {
let autoplay_interval = Duration::from_millis(self.animation_speed * 2);
if now.duration_since(self.last_autoplay_tick) >= autoplay_interval {
let moved = if self.autoplay_reverse {
self.step_backward()
} else {
self.step_forward()
};
if let Some(remaining) = self.autoplay_remaining.as_mut() {
if moved && *remaining > 0 {
*remaining = remaining.saturating_sub(1);
}
if !moved || *remaining == 0 {
self.autoplay_remaining = None;
self.autoplay = false;
}
} else if !moved {
self.autoplay = false;
}
self.last_autoplay_tick = now;
}
}
self.maybe_warm_syntax_cache();
}
}
#[cfg(test)]
mod tests;