use crate::blame::BlameInfo;
use crate::config::{
BlameMode, DiffExtentMarkerMode, DiffExtentMarkerScope, DiffForegroundMode, DiffHighlightMode,
FileCountMode, FilePanelPosition, FoldContextMode, GitIgnoreMode, HunkWrapMode,
MentionFileScope, MentionFinder, ModifiedStepMode, ResolvedTheme, ReviewActionConfig,
ReviewHookConfig, SelectionActionConfig, StepWrapMode, SyntaxMode,
};
use crate::csv_preview::CsvPreviewState;
use crate::keybindings::Keybindings;
use crate::structured_preview::StructuredPreviewState;
use crate::syntax::{EnclosingScopeRange, SyntaxCache, SyntaxEngine};
use crate::time_format::TimeFormatter;
use crate::toasts::{ToastEvent, ToastInteraction, ToastMouseButton, Toasts};
use oyo_core::{
multi::DiffStatus, AnimationFrame, LineKind, MultiFileDiff, StepDirection, StepState, ViewLine,
};
use ratatui::{layout::Size, style::Color};
use ratatui_image::{picker::Picker, protocol::Protocol};
use regex::Regex;
use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::{Duration, Instant, SystemTime};
mod blame;
mod content_worker;
mod diff_worker;
mod file_panel;
mod files;
pub(crate) mod grep;
mod history;
mod navigation;
mod palette;
mod playback;
mod remote_position;
mod remote_stream;
pub(crate) use remote_position::RemoteRowGeometry;
pub(crate) mod remote_open;
pub(crate) mod review;
mod review_queue;
pub(crate) mod startup;
pub(crate) use review_queue::ReviewedFilesExhausted;
mod search;
mod selection;
mod settings;
#[cfg(test)]
pub(crate) use settings::SettingItem;
pub(crate) use settings::{
SettingHit, SettingsLeaveAction, SettingsLeaveHit, SettingsResetAction, SettingsResetHit,
SettingsRow, SettingsTarget,
};
mod syntax;
mod types;
mod utils;
pub(crate) use content_worker::load_content_request_sync;
pub(crate) use types::{
AnimationPhase, BlameDisplay, BlameRenderCache, BlameRenderKey, FoldContextDirection,
FoldContextHit, FoldContextKey, FoldContextRegion, FoldContextRenderRow, PeekMode, PeekScope,
PeekState, UnifiedRenderKey, UnifiedRenderModel, ViewMode, DIFF_VIEW_MIN_WIDTH,
FILE_PANEL_MIN_WIDTH,
};
use types::{
BlameCacheKey, BlamePrefetchKey, BlamePrefetchRange, BlameRequest, BlameResponse,
BlameStepHint, ContentResponse, DiffRequest, DiffResponse, FoldContextExpansion, HunkBounds,
HunkEdge, HunkEdgeHint, HunkStart, NoStepState, StepEdge, StepEdgeHint, SyntaxScopeCache,
};
use utils::{allow_overscroll_state, copy_to_clipboard, max_scroll};
pub(crate) use utils::{display_metrics, fold_context_label, 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>>),
)>;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DiffScrollbarState {
pub x: u16,
pub y: u16,
pub height: u16,
pub total_lines: usize,
pub visible_lines: usize,
pub thumb_top: u16,
pub thumb_height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FilePanelMode {
Files,
Comments,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FilePanelScrollbarState {
pub x: u16,
pub y: u16,
pub height: u16,
pub total_items: usize,
pub visible_items: usize,
pub thumb_top: u16,
pub thumb_height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum FileContextMenuAction {
Open,
OpenInNewTab,
CopyPath,
}
impl FileContextMenuAction {
pub(crate) const ALL: [Self; 3] = [Self::Open, Self::OpenInNewTab, Self::CopyPath];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReviewCommentContextMenuAction {
Body,
Id,
FileLine,
Url,
MarkdownQuote,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ReviewCommentContextMenu {
pub comment_id: u64,
pub x: u16,
pub y: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ReviewCommentContextMenuHit {
pub action: ReviewCommentContextMenuAction,
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FileContextMenu {
pub file_index: usize,
pub x: u16,
pub y: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct FileContextMenuHit {
pub action: FileContextMenuAction,
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct StatusModeMenu {
pub x: u16,
pub y: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct StatusModeMenuHit {
pub mode: ViewMode,
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TopbarTabContent {
File(usize),
Help,
Settings,
PrComments,
OutdatedComments,
}
#[derive(Clone, Debug)]
pub(crate) struct TopbarTab {
pub id: usize,
pub content: TopbarTabContent,
pub view_mode: ViewMode,
pub step_view_mode: ViewMode,
pub stepping: bool,
pub scroll_offset: usize,
pub horizontal_scroll: usize,
pub preview_rendered: bool,
pub navigator_state: Option<StepState>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ViewHistoryRecipe {
File {
tab_id: Option<usize>,
file_index: usize,
scroll_offset: usize,
},
Comment {
comment_id: u64,
},
PrComments {
tab_id: usize,
focus_comment_id: Option<u64>,
},
OutdatedComments {
tab_id: usize,
focus_comment_id: Option<u64>,
},
Help {
tab_id: usize,
},
Settings {
tab_id: usize,
},
Tab {
tab_id: usize,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TopbarTabHit {
pub tab_id: usize,
pub row: u16,
pub start_col: u16,
pub end_col: u16,
pub close_col: Option<u16>,
}
struct OutdatedDiffView {
comment_id: u64,
file_path: String,
live_backup: MultiFileDiff,
active_tab_id: Option<usize>,
active_tab_content: Option<TopbarTabContent>,
cache_on_restore: bool,
worker_state: OutdatedDiffWorkerState,
}
struct OutdatedDiffWorkerState {
valid: bool,
diff_queue: VecDeque<usize>,
diff_inflight: Option<usize>,
diff_refresh_restore_end: Option<usize>,
diff_worker_tx: Option<mpsc::Sender<DiffRequest>>,
diff_worker_rx: Option<mpsc::Receiver<DiffResponse>>,
content_worker_rx: Option<content_worker::ContentWorker>,
content_generation: u64,
content_loading: FxHashMap<usize, oyo_core::multi::PendingFileContent>,
}
#[derive(Debug, Clone)]
pub(crate) struct PreviewLinkBox {
pub x: u16,
pub y: u16,
pub width: u16,
pub url: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ImagePreviewSignature {
pub path: PathBuf,
pub size: Size,
pub len: u64,
pub modified: Option<SystemTime>,
}
pub(crate) struct ImagePreviewCache {
pub signature: ImagePreviewSignature,
pub protocol: Protocol,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ReviewLineAddHit {
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
pub row: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ReviewEditorToolbarAction {
Save,
Cancel,
Mention,
ScrollLeft,
ScrollRight,
Custom(usize),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ReviewEditorToolbarHit {
pub action: ReviewEditorToolbarAction,
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SelectionToolbarAction {
Copy,
Comment,
Cancel,
ScrollLeft,
ScrollRight,
Custom(usize),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct SelectionToolbarHit {
pub action: SelectionToolbarAction,
pub x: u16,
pub y: u16,
pub width: u16,
pub height: u16,
}
pub(crate) fn diff_scrollbar_thumb(
total_lines: usize,
visible_lines: usize,
track_height: u16,
scroll_offset: usize,
) -> Option<(u16, u16)> {
if total_lines <= visible_lines || track_height == 0 {
return None;
}
let track_height = track_height as usize;
let max_thumb_height = track_height.saturating_sub(1).max(1);
let thumb_height = visible_lines
.saturating_mul(track_height)
.div_ceil(total_lines)
.clamp(1, max_thumb_height);
let max_scroll = total_lines.saturating_sub(visible_lines);
let max_thumb_top = track_height.saturating_sub(thumb_height);
let thumb_top = scroll_offset.min(max_scroll).saturating_mul(max_thumb_top) / max_scroll;
Some((thumb_top as u16, thumb_height as u16))
}
fn scrollbar_row_to_offset(
row: u16,
y: u16,
height: u16,
thumb_height: u16,
max_scroll: usize,
) -> Option<usize> {
if max_scroll == 0 || height == 0 {
return None;
}
let track_bottom = y.saturating_add(height.saturating_sub(1));
let track_row = row.clamp(y, track_bottom).saturating_sub(y);
let max_thumb_top = height.saturating_sub(thumb_height).max(1);
let thumb_mid = thumb_height / 2;
let thumb_top = track_row.saturating_sub(thumb_mid).min(max_thumb_top);
Some((thumb_top as usize).saturating_mul(max_scroll) / max_thumb_top as usize)
}
#[derive(Clone)]
pub(crate) struct JjWatchTarget {
pub(crate) repo_root: PathBuf,
pub(crate) revision: String,
pub(crate) working_copy_stamp: Option<String>,
}
pub struct App {
pub multi_diff: MultiFileDiff,
pub view_mode: ViewMode,
pub(crate) preview_forced_by_content: bool,
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 confirm_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_position: FilePanelPosition,
pub(crate) file_panel_mode: FilePanelMode,
pub(crate) files_tab_unseen: bool,
pub(crate) comments_tab_unseen: bool,
pub(crate) file_panel_mode_toggle_hit: Option<(u16, u16, u16, u16)>,
pub(crate) file_panel_mode_toggle_hover: bool,
pub(crate) file_panel_root_hit: Option<(u16, u16, u16, u16)>,
pub(crate) file_panel_root_hover: bool,
pub file_panel_rect: Option<(u16, u16, u16, u16)>,
pub diff_view_area: Option<(u16, u16, u16, u16)>,
pub(crate) diff_selection: Option<selection::DiffSelection>,
pub(crate) diff_selection_cursor: Option<selection::DiffSelectionCursor>,
pub(crate) selection_toolbar_visible: bool,
pub(crate) selection_toolbar_rect: Option<(u16, u16, u16, u16)>,
pub(crate) selection_toolbar_scroll: usize,
pub(crate) selection_toolbar_width: Option<u16>,
pub(crate) selection_toolbar_hits: Vec<SelectionToolbarHit>,
pub(crate) selection_toolbar_hover: Option<SelectionToolbarAction>,
pub(crate) diff_selection_cells: Vec<Vec<String>>,
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(crate) file_list_hover: Option<usize>,
pub(crate) file_context_menu: Option<FileContextMenu>,
pub(crate) file_context_menu_hits: Vec<FileContextMenuHit>,
pub(crate) file_context_menu_hover: Option<FileContextMenuAction>,
pub(crate) file_panel_hover: bool,
pub file_filter_area: Option<(u16, u16, u16, u16)>,
pub(crate) file_filter_clear_hit: Option<(u16, u16, u16, u16)>,
pub(crate) file_filter_clear_hover: bool,
pub(crate) file_filter_hover: bool,
pub(crate) file_filter_cursor_visible: bool,
file_filter_cursor_last_blink: Instant,
pub file_count_mode: FileCountMode,
pub file_git_ignore_mode: GitIgnoreMode,
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 watch: 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>,
file_recently_changed_until: Vec<Option<Instant>>,
last_change_list_watch_check: Instant,
jj_watch_target: Option<JjWatchTarget>,
outdated_diff_view: Option<OutdatedDiffView>,
view_history: Vec<ViewHistoryRecipe>,
view_history_cursor: usize,
view_history_replaying: bool,
git_index_baseline: FileDiskStamp,
pub no_changes_message: Option<String>,
pub(crate) no_changes_dashboard_hit: Option<(u16, u16, u16, u16)>,
pub(crate) all_reviewed_show_hit: Option<(u16, u16, u16, u16)>,
pub(crate) no_changes_dashboard_hover: bool,
pub(crate) no_changes_quit_hit: Option<(u16, u16, u16, u16)>,
pub(crate) no_changes_quit_hover: bool,
view_build_defer: bool,
view_build_pending: bool,
pub horizontal_scroll: usize,
scroll_to_render_end: bool,
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,
pub(crate) fold_context_lines: usize,
fold_context_expansions: FxHashMap<FoldContextKey, FoldContextExpansion>,
fold_context_fully_expanded_files: FxHashSet<PathBuf>,
fold_context_regions: Vec<FoldContextRegion>,
pub(crate) fold_context_hits: Vec<FoldContextHit>,
pub(crate) fold_context_screen_rows: Vec<u16>,
pub(crate) fold_context_hover: Option<(FoldContextKey, FoldContextDirection)>,
pub(crate) fold_context_prefix: Option<FoldContextDirection>,
fold_context_revision: u64,
last_wrap_display_len: Option<usize>,
last_wrap_active_idx: Option<usize>,
pub scrollbar_visible: bool,
pub(crate) diff_scrollbar: Option<DiffScrollbarState>,
diff_scrollbar_dragging: bool,
pub(crate) file_panel_scrollbar: Option<FilePanelScrollbarState>,
file_panel_scrollbar_dragging: bool,
pub(crate) topbar_tabs: Vec<TopbarTab>,
pub(crate) active_topbar_tab: Option<usize>,
next_topbar_tab_id: usize,
pub(crate) topbar_tab_scroll: usize,
pub(crate) topbar_tab_hits: Vec<TopbarTabHit>,
pub(crate) topbar_plus_hit: Option<(u16, u16, u16, u16)>,
pub(crate) topbar_scroll_left_hit: Option<(u16, u16, u16, u16)>,
pub(crate) topbar_scroll_right_hit: Option<(u16, u16, u16, u16)>,
pub(crate) preview_toggle_hit: Option<(u16, u16, u16, u16)>,
pub(crate) topbar_sidebar_toggle_hit: Option<(u16, u16, u16, u16)>,
pub(crate) status_mode_hit: Option<(u16, u16, u16, u16)>,
pub(crate) status_mode_menu: Option<StatusModeMenu>,
pub(crate) status_mode_menu_hits: Vec<StatusModeMenuHit>,
pub(crate) status_mode_menu_hover: Option<ViewMode>,
pub(crate) status_comments_hit: Option<(u16, u16, u16, u16)>,
pub(crate) status_file_hit: Option<(u16, u16, u16, u16)>,
pub(crate) binary_preview_hit: Option<(u16, u16, u16, u16)>,
pub(crate) review_file_comment_hit: Option<(u16, u16, u16, u16)>,
pub(crate) topbar_area: Option<(u16, u16, u16, u16)>,
pub(crate) topbar_hover_tab: Option<usize>,
pub(crate) topbar_hover_close: Option<usize>,
pub(crate) topbar_plus_hover: bool,
pub(crate) topbar_scroll_left_hover: bool,
pub(crate) topbar_scroll_right_hover: bool,
pub(crate) preview_toggle_hover: bool,
pub(crate) topbar_sidebar_toggle_hover: bool,
pub(crate) status_comments_hover: bool,
pub(crate) status_file_hover: bool,
pub(crate) binary_preview_hover: bool,
pub(crate) review_file_comment_hover: bool,
pub(crate) topbar_drag_target: Option<usize>,
topbar_drag_tab: Option<usize>,
pub(crate) structured_previews: FxHashMap<usize, StructuredPreviewState>,
pub(crate) csv_previews: FxHashMap<usize, CsvPreviewState>,
pub(crate) image_picker: Option<Picker>,
pub(crate) image_preview_cache: Option<ImagePreviewCache>,
pub strikethrough_deletions: bool,
pub gutter_signs: bool,
pub toasts_enabled: bool,
pub(crate) toast_engine: Toasts,
pub(crate) toast_position: crate::config::ToastPositionConfig,
pub preview_change_bars: 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 extent_marker_deleted: String,
pub clear_active_on_next_render: bool,
pub theme: ResolvedTheme,
pub(crate) ui_theme_name: Option<String>,
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_refresh_restore_end: Option<usize>,
diff_worker_tx: Option<mpsc::Sender<DiffRequest>>,
diff_worker_rx: Option<mpsc::Receiver<DiffResponse>>,
content_worker_rx: Option<content_worker::ContentWorker>,
remote_refresh_rx: Option<crate::remote::loading::SnapshotWorker>,
pub(crate) remote_stream: Option<crate::remote::stream::PatchSession>,
pub(crate) remote_validation: Option<std::sync::Arc<crate::remote::stream::ValidatedSnapshot>>,
pub(crate) pending_remote_review: Option<remote_stream::ConfigureRemoteReview>,
remote_positions: remote_position::RemotePositions,
remote_sync_generation: u64,
content_generation: u64,
content_loading: FxHashMap<usize, oyo_core::multi::PendingFileContent>,
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_engine_rx: Option<std::sync::mpsc::Receiver<SyntaxEngine>>,
syntax_caches: Vec<Option<SyntaxCache>>,
fold_scope_caches: Vec<Option<Vec<EnclosingScopeRange>>>,
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,
pub(crate) search_cursor_visible: bool,
search_cursor_last_blink: Instant,
pub(crate) search_bar_hit: Option<(u16, u16, u16, u16)>,
pub(crate) search_prev_hit: Option<(u16, u16, u16, u16)>,
pub(crate) search_next_hit: Option<(u16, u16, u16, u16)>,
pub(crate) search_clear_hit: Option<(u16, u16, u16, u16)>,
pub(crate) search_prev_hover: bool,
pub(crate) search_next_hover: bool,
pub(crate) search_clear_hover: bool,
command_palette_query: String,
command_palette_active: bool,
pub(crate) remote_open: Option<remote_open::RemoteOpen>,
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,
pub(crate) review_grep: grep::ReviewGrepState,
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,
comment_picker_query: String,
comment_picker_active: bool,
comment_picker_selection: usize,
comment_picker_list_area: Option<(u16, u16, u16, u16)>,
comment_picker_list_start: usize,
comment_picker_list_count: usize,
comment_picker_item_height: u16,
theme_picker_query: String,
theme_picker_active: bool,
theme_picker_selection: usize,
theme_picker_list_area: Option<(u16, u16, u16, u16)>,
theme_picker_list_start: usize,
theme_picker_list_count: usize,
theme_picker_item_height: u16,
theme_picker_restore: Option<(ResolvedTheme, Option<String>)>,
theme_picker_syntax: bool,
syntax_theme_picker_restore: Option<String>,
control_session_name: Option<String>,
session_rename_query: String,
session_rename_active: bool,
pending_session_rename: Option<String>,
review_mode: bool,
review_comments: Vec<review::ReviewComment>,
reviewed_files: FxHashMap<String, String>,
incremental_review: review_queue::IncrementalReview,
reviewed_file_opened_on_purpose: Option<String>,
outdated_view_closed_since_tick: bool,
review_session_baseline: Vec<review::ReviewComment>,
active_review_comment_id: Option<u64>,
outdated_reconstruction_cache: FxHashMap<u64, review::OutdatedReconstructionState>,
outdated_reconstruction_tx: Option<mpsc::Sender<review::OutdatedReconstructionRequest>>,
outdated_reconstruction_rx: Option<mpsc::Receiver<review::OutdatedReconstructionResponse>>,
pending_outdated_reconstruction: Option<(u64, String)>,
last_outdated_reconstruction_idle_enqueue: Instant,
review_editor: Option<review::ReviewEditorState>,
review_editor_wrap_width: usize,
review_dir_path: Option<PathBuf>,
review_db_path: Option<PathBuf>,
review_db_stamp: FileDiskStamp,
last_review_db_check: Instant,
review_base_dir_override: Option<PathBuf>,
review_workspace_root_override: Option<PathBuf>,
review_persist_enabled: bool,
review_filter_to_current_diff: bool,
review_diff_fingerprint: String,
review_storage_key: String,
review_repo_root: Option<String>,
review_target_metadata: Option<review::ReviewTargetMetadata>,
review_pull_request_target: Option<review::ReviewPullRequestTarget>,
review_author: Option<review::ReviewAuthor>,
review_session_created_at: u64,
review_next_comment_id: u64,
review_preview_boxes: Vec<review::ReviewPreviewBox>,
pub(crate) review_preview_hover: Option<String>,
pub(crate) review_preview_hover_id: Option<u64>,
pub(crate) review_preview_edit_hover: Option<u64>,
pub(crate) review_preview_reply_hover: Option<u64>,
pub(crate) review_preview_resolve_hover: Option<u64>,
pub(crate) review_preview_delete_hover: Option<u64>,
pub(crate) review_preview_overflow_hover: Option<u64>,
pub(crate) review_comment_context_menu: Option<ReviewCommentContextMenu>,
pub(crate) review_comment_context_menu_hits: Vec<ReviewCommentContextMenuHit>,
pub(crate) review_comment_context_menu_hover: Option<ReviewCommentContextMenuAction>,
review_preview_flash: Option<(u64, String, Instant)>,
pub(crate) settings_selection: usize,
pub(crate) settings_hits: Vec<settings::SettingHit>,
pub(crate) settings_hover: Option<settings::SettingsTarget>,
pub(crate) settings_scroll: usize,
pub(crate) settings_config_path_override: Option<std::path::PathBuf>,
settings_open_state: Option<settings::SettingsSnapshot>,
settings_saved_state: Option<settings::SettingsSnapshot>,
settings_leave_confirmation: Option<settings::SettingsLeaveConfirmation>,
settings_reset_confirmation: Option<settings::SettingsResetConfirmation>,
pub(crate) settings_reset_hits: Vec<settings::SettingsResetHit>,
pub(crate) settings_reset_hover: Option<settings::SettingsResetAction>,
pub(crate) settings_leave_hits: Vec<settings::SettingsLeaveHit>,
pub(crate) settings_leave_hover: Option<settings::SettingsLeaveAction>,
settings_leave_replaying: bool,
quit_confirmation: bool,
review_delete_confirmation: Option<review::ReviewDeleteConfirmation>,
pub(crate) review_delete_confirmation_hits: Vec<review::ReviewDeleteConfirmationHit>,
pub(crate) review_delete_confirmation_hover: Option<review::ReviewDeleteConfirmationAction>,
pub(crate) comments_sidebar_sync_hit: Option<(u16, u16, u16, u16)>,
pub(crate) comments_sidebar_discard_hit: Option<(u16, u16, u16, u16)>,
pub(crate) comments_sidebar_sync_hover: bool,
pub(crate) comments_sidebar_discard_hover: bool,
pub(crate) comments_sidebar_overflow_hit: Option<(u16, u16, u16, u16)>,
pub(crate) comments_sidebar_overflow_hover: bool,
pub(crate) comments_sidebar_overflow_open: bool,
pub(crate) comments_sidebar_overflow_hits: Vec<review::ReviewSidebarOverflowHit>,
pub(crate) comments_sidebar_overflow_menu_hover: Option<review::ReviewSyncAction>,
review_remote_picker: Option<review::ReviewRemotePickerState>,
pub(crate) review_remote_picker_hits: Vec<review::ReviewRemotePickerHit>,
pub(crate) review_remote_picker_hover: Option<usize>,
pub(crate) pr_comment_focus: Option<u64>,
pub(crate) outdated_comment_focus: Option<u64>,
pub(crate) pr_comment_hits: Vec<review::PrCommentHit>,
pub(crate) pr_comment_add_hit: Option<(u16, u16, u16, u16)>,
pub(crate) pr_comment_action_hover: Option<review::PrCommentHitAction>,
pub(crate) pr_comment_action_hover_key: Option<String>,
pub(crate) pr_comment_add_hover: bool,
pub(crate) pr_reply_prefix: bool,
pub(crate) review_edit_prefix: bool,
pub(crate) review_reply_prefix: bool,
pub(crate) review_resolve_prefix: bool,
pub(crate) review_delete_prefix: bool,
pub(crate) review_overflow_prefix: bool,
pub(crate) review_editor_toolbar_hits: Vec<ReviewEditorToolbarHit>,
pub(crate) review_editor_toolbar_rect: Option<(u16, u16, u16, u16)>,
pub(crate) review_editor_toolbar_scroll: usize,
pub(crate) review_editor_toolbar_hover: Option<ReviewEditorToolbarAction>,
pub(crate) review_line_add_row: Option<u16>,
pub(crate) review_line_add_side: Option<review::ReviewSide>,
pub(crate) last_hovered_review_anchor: Option<(u64, review::ReviewAnchor)>,
pub(crate) review_unified_line_rows: Vec<(usize, usize)>,
pub(crate) review_split_line_rows: Vec<(u16, review::ReviewSide, usize)>,
pub(crate) review_line_add_hit: Option<ReviewLineAddHit>,
pub(crate) review_line_add_hover: bool,
preview_link_boxes: Vec<PreviewLinkBox>,
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,
pub review_hooks: Vec<ReviewHookConfig>,
pub review_actions: Vec<ReviewActionConfig>,
review_sync_requested: Option<review::ReviewSyncRequest>,
review_sync_status: Option<review::ReviewSyncAction>,
pub selection_actions: Vec<SelectionActionConfig>,
review_hook_warnings: Vec<String>,
search_last_target: Option<usize>,
needs_scroll_to_search: bool,
search_target: Option<usize>,
search_regex: Option<Regex>,
search_revision: u64,
preview_search_lines: Vec<String>,
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>,
diff_revision: u64,
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,
fold_context_revision: u64,
fold_comment_revision: u64,
search_revision: u64,
diff_revision: u64,
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 selected_file = multi_diff.selected_index;
let mut app = Self {
multi_diff,
view_mode,
preview_forced_by_content: false,
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,
confirm_quit: true,
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_position: FilePanelPosition::Left,
file_panel_mode: FilePanelMode::Files,
files_tab_unseen: false,
comments_tab_unseen: false,
file_panel_mode_toggle_hit: None,
file_panel_mode_toggle_hover: false,
file_panel_root_hit: None,
file_panel_root_hover: false,
file_panel_rect: None,
diff_view_area: None,
diff_selection: None,
diff_selection_cursor: None,
selection_toolbar_visible: false,
selection_toolbar_rect: None,
selection_toolbar_scroll: 0,
selection_toolbar_width: None,
selection_toolbar_hits: Vec::new(),
selection_toolbar_hover: None,
diff_selection_cells: Vec::new(),
file_panel_resizing: false,
file_list_scroll: 0,
file_list_area: None,
file_list_rows: Vec::new(),
file_list_hover: None,
file_context_menu: None,
file_context_menu_hits: Vec::new(),
file_context_menu_hover: None,
file_panel_hover: false,
file_filter_area: None,
file_filter_clear_hit: None,
file_filter_clear_hover: false,
file_filter_hover: false,
file_filter_cursor_visible: true,
file_filter_cursor_last_blink: Instant::now(),
file_count_mode: FileCountMode::Active,
file_git_ignore_mode: GitIgnoreMode::Auto,
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,
watch: 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],
file_recently_changed_until: vec![None; file_count],
last_change_list_watch_check: Instant::now(),
jj_watch_target: None,
outdated_diff_view: None,
view_history: Vec::new(),
view_history_cursor: 0,
view_history_replaying: false,
git_index_baseline: FileDiskStamp::default(),
no_changes_message: None,
no_changes_dashboard_hit: None,
all_reviewed_show_hit: None,
no_changes_dashboard_hover: false,
no_changes_quit_hit: None,
no_changes_quit_hover: false,
view_build_defer: false,
view_build_pending: false,
horizontal_scroll: 0,
scroll_to_render_end: false,
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::default(),
fold_context_lines: 3,
fold_context_expansions: FxHashMap::default(),
fold_context_fully_expanded_files: FxHashSet::default(),
fold_context_regions: Vec::new(),
fold_context_hits: Vec::new(),
fold_context_screen_rows: Vec::new(),
fold_context_hover: None,
fold_context_prefix: None,
fold_context_revision: 0,
last_wrap_display_len: None,
last_wrap_active_idx: None,
scrollbar_visible: true,
diff_scrollbar: None,
diff_scrollbar_dragging: false,
file_panel_scrollbar: None,
file_panel_scrollbar_dragging: false,
topbar_tabs: vec![TopbarTab {
id: 1,
content: TopbarTabContent::File(selected_file),
view_mode,
step_view_mode: view_mode,
stepping: true,
scroll_offset: 0,
horizontal_scroll: 0,
preview_rendered: true,
navigator_state: None,
}],
active_topbar_tab: Some(1),
next_topbar_tab_id: 2,
topbar_tab_scroll: 0,
topbar_tab_hits: Vec::new(),
topbar_plus_hit: None,
topbar_scroll_left_hit: None,
topbar_scroll_right_hit: None,
preview_toggle_hit: None,
topbar_sidebar_toggle_hit: None,
status_mode_hit: None,
status_mode_menu: None,
status_mode_menu_hits: Vec::new(),
status_mode_menu_hover: None,
status_comments_hit: None,
status_file_hit: None,
binary_preview_hit: None,
review_file_comment_hit: None,
topbar_area: None,
topbar_hover_tab: None,
topbar_hover_close: None,
topbar_plus_hover: false,
topbar_scroll_left_hover: false,
topbar_scroll_right_hover: false,
preview_toggle_hover: false,
topbar_sidebar_toggle_hover: false,
status_comments_hover: false,
status_file_hover: false,
binary_preview_hover: false,
review_file_comment_hover: false,
topbar_drag_target: None,
topbar_drag_tab: None,
structured_previews: FxHashMap::default(),
csv_previews: FxHashMap::default(),
image_picker: None,
image_preview_cache: None,
strikethrough_deletions: false,
gutter_signs: true,
toasts_enabled: true,
toast_engine: Toasts::default(),
toast_position: crate::config::ToastPositionConfig::BottomRight,
preview_change_bars: 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(),
extent_marker_deleted: "╏".to_string(),
clear_active_on_next_render: false,
theme: ResolvedTheme::default(),
ui_theme_name: None,
time_format: TimeFormatter::default(),
theme_is_light: false,
stepping: true,
hunk_wrap: HunkWrapMode::None,
step_wrap: StepWrapMode::None,
diff_bg: true,
diff_fg: DiffForegroundMode::Syntax,
diff_highlight: DiffHighlightMode::Word,
diff_extent_marker: DiffExtentMarkerMode::Diff,
diff_extent_marker_scope: DiffExtentMarkerScope::Hunk,
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_refresh_restore_end: None,
diff_worker_tx: None,
diff_worker_rx: None,
content_worker_rx: None,
remote_refresh_rx: None,
remote_stream: None,
remote_validation: None,
pending_remote_review: None,
remote_positions: remote_position::RemotePositions::default(),
remote_sync_generation: 0,
content_generation: 0,
content_loading: FxHashMap::default(),
blame_extra_rows: None,
blame_step_hint: None,
blame_hunk_hint: None,
unified_modified_step_mode: ModifiedStepMode::Mixed,
split_align_lines: true,
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_engine_rx: None,
syntax_caches: vec![None; file_count],
fold_scope_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,
search_cursor_visible: true,
search_cursor_last_blink: Instant::now(),
search_bar_hit: None,
search_prev_hit: None,
search_next_hit: None,
search_clear_hit: None,
search_prev_hover: false,
search_next_hover: false,
search_clear_hover: false,
command_palette_query: String::new(),
command_palette_active: false,
remote_open: None,
command_palette_selection: 0,
command_palette_list_area: None,
command_palette_list_start: 0,
command_palette_list_count: 0,
command_palette_item_height: 1,
review_grep: grep::ReviewGrepState::default(),
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,
comment_picker_query: String::new(),
comment_picker_active: false,
comment_picker_selection: 0,
comment_picker_list_area: None,
comment_picker_list_start: 0,
comment_picker_list_count: 0,
comment_picker_item_height: 1,
theme_picker_query: String::new(),
theme_picker_active: false,
theme_picker_selection: 0,
theme_picker_list_area: None,
theme_picker_list_start: 0,
theme_picker_list_count: 0,
theme_picker_item_height: 1,
theme_picker_restore: None,
theme_picker_syntax: false,
syntax_theme_picker_restore: None,
control_session_name: None,
session_rename_query: String::new(),
session_rename_active: false,
pending_session_rename: None,
review_mode: false,
review_comments: Vec::new(),
reviewed_files: FxHashMap::default(),
incremental_review: review_queue::IncrementalReview::Off,
reviewed_file_opened_on_purpose: None,
outdated_view_closed_since_tick: false,
review_session_baseline: Vec::new(),
active_review_comment_id: None,
outdated_reconstruction_cache: FxHashMap::default(),
outdated_reconstruction_tx: None,
outdated_reconstruction_rx: None,
pending_outdated_reconstruction: None,
last_outdated_reconstruction_idle_enqueue: Instant::now(),
review_editor: None,
review_editor_wrap_width: 0,
review_dir_path: None,
review_db_path: None,
review_db_stamp: FileDiskStamp::default(),
last_review_db_check: Instant::now(),
review_base_dir_override: None,
review_workspace_root_override: None,
review_persist_enabled: true,
review_filter_to_current_diff: false,
review_diff_fingerprint: String::new(),
review_storage_key: String::new(),
review_repo_root: None,
review_target_metadata: None,
review_pull_request_target: None,
review_author: None,
review_session_created_at: 0,
review_next_comment_id: 1,
review_preview_boxes: Vec::new(),
review_preview_hover: None,
review_preview_hover_id: None,
review_preview_edit_hover: None,
review_preview_reply_hover: None,
review_preview_resolve_hover: None,
review_preview_delete_hover: None,
review_preview_overflow_hover: None,
review_comment_context_menu: None,
review_comment_context_menu_hits: Vec::new(),
review_comment_context_menu_hover: None,
review_preview_flash: None,
settings_selection: 0,
settings_hits: Vec::new(),
settings_hover: None,
settings_scroll: 0,
settings_config_path_override: None,
settings_open_state: None,
settings_saved_state: None,
settings_leave_confirmation: None,
settings_reset_confirmation: None,
settings_reset_hits: Vec::new(),
settings_reset_hover: None,
settings_leave_hits: Vec::new(),
settings_leave_hover: None,
settings_leave_replaying: false,
quit_confirmation: false,
review_delete_confirmation: None,
review_delete_confirmation_hits: Vec::new(),
review_delete_confirmation_hover: None,
comments_sidebar_sync_hit: None,
comments_sidebar_discard_hit: None,
comments_sidebar_sync_hover: false,
comments_sidebar_discard_hover: false,
comments_sidebar_overflow_hit: None,
comments_sidebar_overflow_hover: false,
comments_sidebar_overflow_open: false,
comments_sidebar_overflow_hits: Vec::new(),
comments_sidebar_overflow_menu_hover: None,
review_remote_picker: None,
review_remote_picker_hits: Vec::new(),
review_remote_picker_hover: None,
pr_comment_focus: None,
outdated_comment_focus: None,
pr_comment_hits: Vec::new(),
pr_comment_add_hit: None,
pr_comment_action_hover: None,
pr_comment_action_hover_key: None,
pr_comment_add_hover: false,
pr_reply_prefix: false,
review_edit_prefix: false,
review_reply_prefix: false,
review_resolve_prefix: false,
review_delete_prefix: false,
review_overflow_prefix: false,
review_editor_toolbar_hits: Vec::new(),
review_editor_toolbar_rect: None,
review_editor_toolbar_scroll: 0,
review_editor_toolbar_hover: None,
review_line_add_row: None,
review_line_add_side: None,
last_hovered_review_anchor: None,
review_unified_line_rows: Vec::new(),
review_split_line_rows: Vec::new(),
review_line_add_hit: None,
review_line_add_hover: false,
preview_link_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,
review_hooks: Vec::new(),
review_actions: Vec::new(),
review_sync_requested: None,
review_sync_status: None,
selection_actions: Vec::new(),
review_hook_warnings: Vec::new(),
search_last_target: None,
needs_scroll_to_search: false,
search_target: None,
search_regex: None,
search_revision: 0,
preview_search_lines: Vec::new(),
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,
diff_revision: 0,
unified_render_cache: None,
view_window_start: 0,
view_window_total_len: None,
};
app.rebuild_file_disk_baseline();
app.git_index_baseline = app.git_index_stamp();
app
}
pub(crate) fn request_quit(&mut self) {
if !self.confirm_quit {
self.submit_review_and_quit();
return;
}
self.quit_confirmation = true;
self.review_delete_confirmation_hits.clear();
self.review_delete_confirmation_hover = None;
}
pub(crate) fn force_quit(&mut self) {
self.should_quit = true;
}
pub(crate) fn quit_confirmation_active(&self) -> bool {
self.quit_confirmation
}
pub(crate) fn cancel_quit_confirmation(&mut self) {
self.quit_confirmation = false;
self.review_delete_confirmation_hits.clear();
self.review_delete_confirmation_hover = None;
}
fn confirm_pending_quit(&mut self) {
self.cancel_quit_confirmation();
self.submit_review_and_quit();
}
pub(crate) fn handle_quit_confirmation_key(&mut self, key: crossterm::event::KeyEvent) -> bool {
match key.code {
crossterm::event::KeyCode::Enter
| crossterm::event::KeyCode::Char('y' | 'Y' | 'q' | 'Q') => self.confirm_pending_quit(),
_ => self.cancel_quit_confirmation(),
}
true
}
pub(crate) fn handle_quit_confirmation_click(&mut self, column: u16, row: u16) -> bool {
let Some(action) = self.review_delete_confirmation_hits.iter().find_map(|hit| {
(column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height))
.then_some(hit.action)
}) else {
return true;
};
match action {
review::ReviewDeleteConfirmationAction::Confirm => self.confirm_pending_quit(),
review::ReviewDeleteConfirmationAction::Cancel => self.cancel_quit_confirmation(),
}
true
}
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(crate) fn notify(&mut self, event: ToastEvent) {
if self.toasts_enabled {
self.toast_engine.push(event);
}
}
pub(crate) fn toast_tick(&mut self) -> bool {
self.toasts_enabled && self.toast_engine.tick()
}
pub(crate) fn handle_toast_click(
&mut self,
column: u16,
row: u16,
button: ToastMouseButton,
) -> bool {
if !self.toasts_enabled {
return false;
}
match self.toast_engine.handle_click(column, row, button) {
ToastInteraction::Dismissed => true,
ToastInteraction::CopyRequested(text) => {
if copy_to_clipboard(&text) {
self.notify(ToastEvent::CopiedToast);
} else {
self.notify(ToastEvent::CopyFailed);
}
true
}
ToastInteraction::None => false,
}
}
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;
self.notify(ToastEvent::Zen(self.zen_mode));
}
pub fn toggle_syntax(&mut self) {
self.syntax_mode = match self.syntax_mode {
SyntaxMode::On => SyntaxMode::Off,
SyntaxMode::Off => SyntaxMode::On,
};
self.notify(ToastEvent::Syntax(matches!(
self.syntax_mode,
SyntaxMode::On
)));
if matches!(self.syntax_mode, SyntaxMode::Off) {
self.syntax_engine = None;
self.syntax_engine_rx = None;
self.syntax_caches = vec![None; self.multi_diff.file_count()];
} else {
self.start_syntax_engine_load();
}
}
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,
};
self.notify(ToastEvent::EvoSyntaxFull(matches!(
self.evo_syntax,
crate::config::EvoSyntaxMode::Full
)));
}
pub fn scroll_up(&mut self) {
self.centered_once = false;
if self.view_mode == ViewMode::Preview && self.active_preview_rendered() {
if let Some(state) = self.active_structured_preview_mut() {
state.scroll_up(1);
self.sync_scroll_from_structured_preview();
return;
}
}
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
self.centered_once = false;
if self.view_mode == ViewMode::Preview && self.active_preview_rendered() {
if let Some(state) = self.active_structured_preview_mut() {
state.scroll_down(1);
self.sync_scroll_from_structured_preview();
return;
}
}
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
pub(crate) fn begin_scrollbar_frame(&mut self) {
self.diff_scrollbar = None;
self.file_panel_scrollbar = None;
}
pub(crate) fn set_diff_scrollbar(&mut self, scrollbar: DiffScrollbarState) {
self.diff_scrollbar = Some(scrollbar);
}
pub(crate) fn set_file_panel_scrollbar(&mut self, scrollbar: FilePanelScrollbarState) {
self.file_panel_scrollbar = Some(scrollbar);
}
pub(crate) fn start_diff_scrollbar_drag(&mut self, column: u16, row: u16) -> bool {
let Some(scrollbar) = self.diff_scrollbar else {
return false;
};
if column != scrollbar.x
|| row < scrollbar.y
|| row >= scrollbar.y.saturating_add(scrollbar.height)
{
return false;
}
self.diff_scrollbar_dragging = true;
self.scroll_to_diff_scrollbar_row(row)
}
pub(crate) fn drag_diff_scrollbar(&mut self, row: u16) -> bool {
if !self.diff_scrollbar_dragging {
return false;
}
self.scroll_to_diff_scrollbar_row(row)
}
pub(crate) fn finish_diff_scrollbar_drag(&mut self) -> bool {
let was_dragging = self.diff_scrollbar_dragging;
self.diff_scrollbar_dragging = false;
was_dragging
}
fn scroll_to_diff_scrollbar_row(&mut self, row: u16) -> bool {
let Some(scrollbar) = self.diff_scrollbar else {
self.diff_scrollbar_dragging = false;
return false;
};
let max_scroll = scrollbar
.total_lines
.saturating_sub(scrollbar.visible_lines);
if max_scroll == 0 || scrollbar.height == 0 {
return true;
}
let Some(next_scroll) = scrollbar_row_to_offset(
row,
scrollbar.y,
scrollbar.height,
scrollbar.thumb_height,
max_scroll,
) else {
return true;
};
self.centered_once = false;
self.scroll_offset = next_scroll;
self.clear_diff_selection();
true
}
pub(crate) fn start_file_panel_scrollbar_drag(&mut self, column: u16, row: u16) -> bool {
let Some(scrollbar) = self.file_panel_scrollbar else {
return false;
};
if column != scrollbar.x
|| row < scrollbar.y
|| row >= scrollbar.y.saturating_add(scrollbar.height)
{
return false;
}
self.file_list_focused = true;
self.file_filter_active = false;
self.file_panel_scrollbar_dragging = true;
self.scroll_to_file_panel_scrollbar_row(row)
}
pub(crate) fn drag_file_panel_scrollbar(&mut self, row: u16) -> bool {
if !self.file_panel_scrollbar_dragging {
return false;
}
self.scroll_to_file_panel_scrollbar_row(row)
}
pub(crate) fn finish_file_panel_scrollbar_drag(&mut self) -> bool {
let was_dragging = self.file_panel_scrollbar_dragging;
self.file_panel_scrollbar_dragging = false;
was_dragging
}
fn scroll_to_file_panel_scrollbar_row(&mut self, row: u16) -> bool {
let Some(scrollbar) = self.file_panel_scrollbar else {
self.file_panel_scrollbar_dragging = false;
return false;
};
let max_scroll = scrollbar
.total_items
.saturating_sub(scrollbar.visible_items);
if max_scroll == 0 || scrollbar.height == 0 {
return true;
}
let Some(next_scroll) = scrollbar_row_to_offset(
row,
scrollbar.y,
scrollbar.height,
scrollbar.thumb_height,
max_scroll,
) else {
return true;
};
self.file_list_scroll = next_scroll;
true
}
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,
) {
let maximum = if self.remote_scroll_preserved() {
total_lines.saturating_sub(1)
} else {
max_scroll(total_lines, viewport_height, allow_overscroll)
};
self.scroll_offset = self.scroll_offset.min(maximum);
}
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 = self.scroll_offset.saturating_add(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 = self.horizontal_scroll.saturating_add(4);
}
}
pub(crate) fn mouse_over_diff_view(&self, column: u16, row: u16) -> bool {
self.diff_view_area.is_some_and(|(x, y, width, height)| {
column >= x
&& column < x.saturating_add(width)
&& row >= y
&& row < y.saturating_add(height)
})
}
pub(crate) fn scroll_diff_horizontally(&mut self, delta: isize) -> bool {
if self.line_wrap || self.view_mode == ViewMode::Preview {
return false;
}
if self.diff_view_area.is_none() {
return false;
}
let old = self.horizontal_scroll;
let amount = delta.unsigned_abs().saturating_mul(4);
if delta.is_negative() {
self.horizontal_scroll = self.horizontal_scroll.saturating_sub(amount);
} else {
self.horizontal_scroll = self.horizontal_scroll.saturating_add(amount);
}
old != self.horizontal_scroll
}
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;
self.notify(ToastEvent::LineWrap(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(crate) fn mark_diff_changed(&mut self) {
self.diff_revision = self.diff_revision.wrapping_add(1);
self.view_cache = None;
self.unified_render_cache = None;
}
pub(crate) fn diff_revision(&self) -> u64 {
self.diff_revision
}
fn clear_fold_context_caches(&mut self) {
self.fold_context_revision = self.fold_context_revision.wrapping_add(1);
self.fold_context_regions.clear();
self.fold_context_hits.clear();
self.fold_context_screen_rows.clear();
self.fold_context_hover = None;
self.fold_context_prefix = None;
self.view_cache = None;
self.unified_render_cache = None;
self.hunk_starts_unified_cache = None;
self.hunk_bounds_unified_cache = None;
self.hunk_starts_split_cache = None;
self.hunk_bounds_split_cache = None;
self.last_wrap_display_len = None;
self.last_wrap_active_idx = None;
}
fn invalidate_fold_context_view(&mut self) {
self.clear_fold_context_caches();
self.reset_search_for_file_switch();
self.needs_scroll_to_active = true;
self.centered_once = false;
self.blame_render_cache = None;
}
pub fn toggle_fold_context(&mut self) {
self.request_remote_content(self.multi_diff.selected_index);
self.fold_context = self.fold_context.next();
self.fold_context_expansions.clear();
self.fold_context_fully_expanded_files.clear();
self.invalidate_fold_context_view();
self.notify(ToastEvent::FoldContext(self.fold_context));
}
pub fn set_fold_context_mode(&mut self, mode: FoldContextMode) {
if !mode.is_enabled() {
self.request_remote_content(self.multi_diff.selected_index);
}
self.fold_context = mode;
self.fold_context_expansions.clear();
self.fold_context_fully_expanded_files.clear();
self.invalidate_fold_context_view();
}
pub(crate) fn set_fold_context_lines(&mut self, lines: usize) {
self.fold_context_lines = lines;
self.invalidate_fold_context_view();
}
pub(crate) fn fold_context_region_for_line(
&self,
line: &ViewLine,
) -> Option<FoldContextRegion> {
if !self.fold_context.is_enabled() || !is_fold_line(line) {
return None;
}
self.fold_context_regions
.iter()
.find(|region| {
region.key.file_index == self.multi_diff.selected_index
&& region.key.start_change_id == line.change_id
})
.cloned()
}
pub(crate) fn clear_fold_context_hits(&mut self) {
self.fold_context_hits.clear();
self.fold_context_screen_rows.clear();
}
pub(crate) fn add_fold_context_render_rows(
&mut self,
area: ratatui::layout::Rect,
rows: &[FoldContextRenderRow],
viewport_start: usize,
) {
let viewport_end = viewport_start.saturating_add(area.height as usize);
for row in rows {
if row.row < viewport_start || row.row >= viewport_end {
continue;
}
let y = area
.y
.saturating_add(row.row.saturating_sub(viewport_start) as u16);
if !self.fold_context_screen_rows.contains(&y) {
self.fold_context_screen_rows.push(y);
}
for (x, width, direction) in [
(row.top_x, row.top_width, FoldContextDirection::Top),
(row.bottom_x, row.bottom_width, FoldContextDirection::Bottom),
] {
if x >= area.width {
continue;
}
self.fold_context_hits.push(FoldContextHit {
x: area.x.saturating_add(x),
y,
width: width.min(area.width.saturating_sub(x)),
height: 1,
key: row.key,
direction,
});
}
}
}
pub(crate) fn handle_fold_context_click(&mut self, column: u16, row: u16) -> bool {
let Some(hit) = self
.fold_context_hits
.iter()
.find(|hit| {
column >= hit.x
&& column < hit.x.saturating_add(hit.width)
&& row >= hit.y
&& row < hit.y.saturating_add(hit.height)
})
.copied()
else {
return false;
};
self.expand_context_fold(hit.key, hit.direction)
}
fn expand_context_fold(
&mut self,
key: FoldContextKey,
direction: FoldContextDirection,
) -> bool {
let Some(hidden_lines) = self
.fold_context_regions
.iter()
.find(|region| region.key == key)
.map(|region| region.hidden_lines)
else {
return false;
};
let amount = hidden_lines.min(20);
let expansion = self.fold_context_expansions.entry(key).or_default();
match direction {
FoldContextDirection::Top => {
expansion.top = expansion.top.saturating_add(amount);
self.scroll_offset = self.scroll_offset.saturating_add(amount);
}
FoldContextDirection::Bottom => {
expansion.bottom = expansion.bottom.saturating_add(amount);
}
}
self.invalidate_fold_context_view();
self.needs_scroll_to_active = false;
true
}
fn visible_context_fold_keys(&self) -> Vec<FoldContextKey> {
let mut keys = Vec::new();
for hit in &self.fold_context_hits {
if !keys.contains(&hit.key) {
keys.push(hit.key);
}
}
keys
}
pub(crate) fn has_visible_context_folds(&self) -> bool {
!self.fold_context_hits.is_empty()
}
fn expand_visible_context_fold(&mut self, idx: usize, direction: FoldContextDirection) -> bool {
let Some(key) = self.visible_context_fold_keys().get(idx).copied() else {
return false;
};
self.expand_context_fold(key, direction)
}
pub(crate) fn expand_visible_context_fold_letter(
&mut self,
letter: char,
direction: FoldContextDirection,
) -> bool {
let letter = letter.to_ascii_lowercase();
if !letter.is_ascii_lowercase() {
return false;
}
self.expand_visible_context_fold((letter as u8 - b'a') as usize, direction)
}
pub(crate) fn expand_visible_context_fold_number(
&mut self,
number: usize,
direction: FoldContextDirection,
) -> bool {
if number == 0 {
return false;
}
self.expand_visible_context_fold(number - 1, direction)
}
pub(crate) fn expand_all_context_folds(&mut self) -> bool {
if self
.multi_diff
.file_is_sparse(self.multi_diff.selected_index)
{
if let Some(path) = self.multi_diff.current_file().map(|f| f.path.clone()) {
self.fold_context_fully_expanded_files.insert(path);
}
self.request_remote_content(self.multi_diff.selected_index);
return true;
}
if !self.fold_context.is_enabled() {
return false;
}
self.current_view_with_frame(AnimationFrame::Idle);
if self.fold_context_regions.is_empty() {
return false;
}
for region in self.fold_context_regions.clone() {
self.fold_context_expansions.insert(
region.key,
FoldContextExpansion {
top: usize::MAX,
bottom: 0,
},
);
}
if let Some(path) = self.multi_diff.current_file().map(|file| file.path.clone()) {
self.fold_context_fully_expanded_files.insert(path);
}
self.invalidate_fold_context_view();
true
}
pub fn toggle_strikethrough_deletions(&mut self) {
self.strikethrough_deletions = !self.strikethrough_deletions;
self.notify(ToastEvent::Strikethrough(self.strikethrough_deletions));
}
fn wrap_to_file_hunk(&mut self, forward: bool, stepping: bool) -> bool {
let (order, navigable, current) =
if self.file_filter.is_empty() && !self.hides_reviewed_files() {
let every_file: Vec<usize> = (0..self.multi_diff.file_count()).collect();
(
every_file.clone(),
every_file,
self.multi_diff.selected_index,
)
} else {
(
self.file_order_for_query(&self.file_filter),
self.navigable_file_indices(),
self.review_live_diff().selected_index,
)
};
let step = if forward {
review_queue::FileStep::NextWrapping
} else {
review_queue::FileStep::PrevWrapping
};
let mut cursor = current;
for _ in 0..navigable.len() {
let Some(index) = review_queue::neighbour_file_index(&order, &navigable, cursor, step)
else {
break;
};
if index == current {
break;
}
cursor = index;
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) {
if index == self.multi_diff.selected_index {
self.remember_remote_viewport();
}
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 fold_comment_revision = self.review_fold_anchor_revision();
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,
fold_context_revision: self.fold_context_revision,
fold_comment_revision,
search_revision: self.search_revision,
diff_revision: self.diff_revision,
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 scroll_to_render_end_pending(&self) -> bool {
self.scroll_to_render_end
}
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 || self.fold_context.is_enabled() {
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>> {
if self.view_mode == ViewMode::Preview {
self.view_window_start = 0;
self.view_window_total_len = None;
self.view_build_pending = false;
return std::sync::Arc::new(Vec::new());
}
let window = if self
.multi_diff
.has_patch_display(self.multi_diff.selected_index)
{
None
} else {
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);
}
}
}
}
if self
.multi_diff
.has_patch_display(self.multi_diff.selected_index)
{
view = remote_stream::with_unloaded_context(view);
}
if self.is_remote_session() {
for line in &mut view {
line.content = oyo_core::patch::terminal_text(&line.content);
for span in &mut line.spans {
span.text = oyo_core::patch::terminal_text(&span.text);
}
}
}
let fold_comment_anchors = self.review_fold_anchor_change_ids(&view);
let expand_all_context = self
.multi_diff
.current_file()
.is_some_and(|file| self.fold_context_fully_expanded_files.contains(&file.path));
let (view, mut fold_context_regions) = utils::fold_context_view_with_expand_all(
view,
self.fold_context,
self.multi_diff.selected_index,
self.fold_context_lines,
&self.fold_context_expansions,
&fold_comment_anchors,
expand_all_context,
);
if !fold_context_regions.is_empty() {
self.ensure_current_fold_scope_cache();
if let Some(ranges) = self
.fold_scope_caches
.get(self.multi_diff.selected_index)
.and_then(|cache| cache.as_deref())
{
for region in &mut fold_context_regions {
region.scope_hint = region.scope_line.and_then(|line| {
crate::syntax::enclosing_scope_definition(ranges, line.saturating_sub(1))
.map(str::to_string)
});
}
}
}
self.fold_context_regions = fold_context_regions;
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,
viewport_width: usize,
) {
if !self.current_file_diff_ready() {
return;
}
if self.handle_search_scroll_if_needed(viewport_height, viewport_width) {
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 redraw_interval(&self) -> Duration {
if self.remote_stream.is_some() {
return Duration::from_millis(16);
}
if self.animation_phase != AnimationPhase::Idle || self.snap_frame.is_some() {
Duration::from_millis(33)
} else if self.autoplay
|| self.diff_inflight.is_some()
|| !self.diff_queue.is_empty()
|| self.syntax_warmup_pending()
|| self.review_grep_searching()
|| self.step_edge_hint.is_some()
|| self.hunk_edge_hint.is_some()
|| self.review_sync_status.is_some()
|| self.content_worker_rx.is_some()
|| self.remote_refresh_rx.is_some()
{
Duration::from_millis(100)
} else {
Duration::from_millis(250)
}
}
pub fn tick(&mut self) -> bool {
let now = Instant::now();
let mut dirty = false;
if let Some(hint) = self.step_edge_hint {
if now >= hint.until {
self.step_edge_hint = None;
dirty = true;
}
}
if let Some(hint) = self.hunk_edge_hint {
if now >= hint.until {
self.hunk_edge_hint = None;
dirty = true;
}
}
let text_cursor_active = self.remote_open.is_some()
|| self.file_filter_active
|| self.command_palette_active
|| self.file_search_active
|| self.review_grep_active()
|| self.comment_picker_active
|| self.theme_picker_active
|| self.session_rename_active
|| self.review_remote_picker.is_some();
if text_cursor_active
&& now.duration_since(self.file_filter_cursor_last_blink) >= Duration::from_millis(500)
{
self.file_filter_cursor_visible = !self.file_filter_cursor_visible;
self.file_filter_cursor_last_blink = now;
dirty = true;
}
if self.search_active
&& now.duration_since(self.search_cursor_last_blink) >= Duration::from_millis(500)
{
self.search_cursor_visible = !self.search_cursor_visible;
self.search_cursor_last_blink = now;
dirty = true;
}
if self.review_sync_status.is_some() {
dirty = true;
}
if self
.review_preview_flash
.as_ref()
.is_some_and(|(_, _, until)| now >= *until)
{
self.review_preview_flash = None;
dirty = true;
}
dirty |= self.expire_recent_file_changes(now);
dirty |= self.toast_tick();
dirty |= self.poll_remote_refresh();
let mut reviewed_state_inputs_changed = self.poll_content_responses();
dirty |= reviewed_state_inputs_changed;
dirty |= self.poll_review_grep();
dirty |= self.poll_diff_responses();
dirty |= self.poll_review_grep_jump();
dirty |= self.poll_outdated_reconstruction_responses();
dirty |= self.outdated_reconstruction_pending();
dirty |= self.maybe_queue_idle_diff();
dirty |= self.maybe_check_file_changes();
reviewed_state_inputs_changed |= self.maybe_watch_refresh_git_files();
reviewed_state_inputs_changed |= self.maybe_watch_refresh_jj_files();
reviewed_state_inputs_changed |= self.maybe_watch_refresh_changed_files();
reviewed_state_inputs_changed |= self.maybe_watch_reload_review_state();
reviewed_state_inputs_changed |= std::mem::take(&mut self.outdated_view_closed_since_tick);
dirty |= reviewed_state_inputs_changed;
if reviewed_state_inputs_changed {
self.move_selection_off_hidden_reviewed_file();
}
if self.multi_diff.file_count() == 0 {
return dirty;
}
if let Some(frame) = self.snap_frame {
dirty = true;
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 {
dirty = true;
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 {
dirty = true;
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;
}
}
dirty |= self.maybe_warm_syntax_cache();
dirty |= self.maybe_preload_idle_outdated_reconstruction();
dirty
}
}
#[cfg(test)]
mod tests;