use super::utils::{
copy_to_clipboard, inline_text_for_change, is_conflict_marker, is_fold_line,
modified_only_text_for_change, old_text_for_change,
};
use super::{
display_metrics, AnimationPhase, App, HunkBounds, HunkEdge, HunkEdgeHint, HunkStart, PeekMode,
PeekScope, PeekState, StepEdge, StepEdgeHint, ViewMode,
};
use crate::config::{FoldContextMode, HunkWrapMode, ModifiedStepMode, StepWrapMode};
use crate::toasts::ToastEvent;
use oyo_core::{
git::FileStatus, AnimationFrame, ChangeKind, DiffNavigator, LineKind, StepState, ViewLine,
};
use std::path::PathBuf;
use std::time::{Duration, Instant};
const STEP_EDGE_HINT_MS: u64 = 700;
#[derive(Debug, Clone, Copy)]
struct ConflictMarker {
display_idx: usize,
change_id: usize,
}
fn change_has_conflict_marker(change: &oyo_core::change::Change) -> bool {
fn text_has_marker(text: &str) -> bool {
text.lines().any(|line| {
let trimmed = line.trim_start();
trimmed.starts_with("<<<<<<<")
|| trimmed.starts_with("=======")
|| trimmed.starts_with(">>>>>>>")
})
}
let old_text = old_text_for_change(change);
if text_has_marker(&old_text) {
return true;
}
let new_text = modified_only_text_for_change(change);
text_has_marker(&new_text)
}
fn build_hunk_change_index_map(nav: &DiffNavigator) -> Vec<Option<usize>> {
let mut map = vec![None; nav.diff().changes.len()];
for hunk in nav.hunks() {
let hidx = hunk.id;
for change_id in &hunk.change_ids {
if let Some(idx) = nav.change_index_for(*change_id) {
if idx < map.len() {
map[idx] = Some(hidx);
}
}
}
}
map
}
impl App {
fn hunk_cache_key_unified(
&mut self,
) -> (usize, ViewMode, FoldContextMode, bool, usize, usize, usize) {
let file_index = self.multi_diff.selected_index;
let view_mode = self.view_mode;
let fold_context = self.fold_context;
let state = self.multi_diff.current_navigator().state();
(
file_index,
view_mode,
fold_context,
self.stepping,
state.current_step,
state.total_steps,
state.total_hunks,
)
}
fn hunk_cache_key_split(
&mut self,
) -> (usize, FoldContextMode, bool, bool, usize, usize, usize) {
let file_index = self.multi_diff.selected_index;
let fold_context = self.fold_context;
let split_align = self.split_align_lines;
let state = self.multi_diff.current_navigator().state();
(
file_index,
fold_context,
split_align,
self.stepping,
state.current_step,
state.total_steps,
state.total_hunks,
)
}
pub fn toggle_peek_old_change(&mut self) {
self.cycle_peek_change();
}
pub fn toggle_peek_old_hunk(&mut self) {
self.toggle_peek_hunk();
}
fn clear_peek(&mut self) {
self.peek_state = None;
}
fn cycle_peek_change(&mut self) {
if !self.stepping {
return;
}
let base = self.base_modified_view_mode();
let current = match self.peek_state {
Some(PeekState {
scope: PeekScope::Change,
mode,
}) => mode,
_ => base,
};
let next = match current {
PeekMode::Modified => PeekMode::Old,
PeekMode::Old => PeekMode::Mixed,
PeekMode::Mixed => PeekMode::Modified,
};
if next == base {
self.peek_state = None;
} else {
self.peek_state = Some(PeekState {
scope: PeekScope::Change,
mode: next,
});
}
}
fn toggle_peek_hunk(&mut self) {
if !self.stepping {
return;
}
let next = PeekState {
scope: PeekScope::Hunk,
mode: PeekMode::Old,
};
if self.peek_state == Some(next) {
self.peek_state = None;
} else {
self.peek_state = Some(next);
}
}
fn base_modified_view_mode(&self) -> PeekMode {
if self.unified_modified_step_mode == ModifiedStepMode::Modified {
PeekMode::Modified
} else {
PeekMode::Mixed
}
}
pub fn is_peek_override_for_line(&mut self, view_line: &ViewLine) -> bool {
if !self.stepping {
return false;
}
let Some(peek) = self.peek_state else {
return false;
};
match peek.scope {
PeekScope::Change => view_line.is_primary_active,
PeekScope::Hunk => {
let current_hunk = self.multi_diff.current_navigator().state().current_hunk;
view_line.hunk_index == Some(current_hunk)
}
}
}
pub fn peek_mode_for_line(&mut self, view_line: &ViewLine) -> Option<PeekMode> {
if !self.stepping {
return None;
}
if let Some(peek) = self.peek_state {
match peek.scope {
PeekScope::Change => {
if view_line.is_primary_active {
return Some(peek.mode);
}
}
PeekScope::Hunk => {
let current_hunk = self.multi_diff.current_navigator().state().current_hunk;
if view_line.hunk_index == Some(current_hunk) {
return Some(PeekMode::Old);
}
}
}
return None;
}
if view_line.is_primary_active
&& matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify)
{
return Some(self.base_modified_view_mode());
}
None
}
pub fn yank_current_change(&mut self) {
let frame = self.animation_frame();
let view_lines = self.current_view_with_frame(frame);
let Some(line) = view_lines.iter().find(|line| line.is_primary_active) else {
return;
};
if let Some(text) = self.text_for_yank(line) {
if copy_to_clipboard(&text) {
self.notify(ToastEvent::CopiedLine);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
}
pub fn yank_current_hunk(&mut self) {
let frame = self.animation_frame();
let view_lines = self.current_view_with_frame(frame);
let current_hunk = self.multi_diff.current_navigator().state().current_hunk;
let mut lines: Vec<String> = Vec::new();
for line in view_lines
.iter()
.filter(|line| line.hunk_index == Some(current_hunk))
{
if let Some(text) = self.text_for_yank(line) {
lines.push(text);
}
}
if lines.is_empty() {
return;
}
if copy_to_clipboard(&lines.join("\n")) {
self.notify(ToastEvent::CopiedHunk);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
pub fn yank_current_change_patch(&mut self) {
let frame = self.animation_frame();
let view_lines = self.current_view_with_frame(frame);
let Some(line) = view_lines.iter().find(|line| line.is_primary_active) else {
return;
};
if let Some(text) = self.patch_for_hunk(Some(line.change_id)) {
if copy_to_clipboard(&text) {
self.notify(ToastEvent::CopiedPatch);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
}
pub fn yank_current_hunk_patch(&mut self) {
if let Some(text) = self.patch_for_hunk(None) {
if copy_to_clipboard(&text) {
self.notify(ToastEvent::CopiedPatch);
} else {
self.notify(ToastEvent::CopyFailed);
}
}
}
fn patch_for_hunk(&mut self, change_filter: Option<usize>) -> Option<String> {
if self.current_file_is_binary() {
return None;
}
let (diff, current_hunk) = {
let nav = self.multi_diff.current_navigator();
(nav.diff().clone(), nav.state().current_hunk)
};
let mut indices = Vec::new();
let change_ids: Vec<usize> = match change_filter {
Some(change_id) => vec![change_id],
None => {
let hunk = diff.hunks.get(current_hunk)?;
hunk.change_ids.clone()
}
};
for change_id in change_ids {
if let Some(idx) = diff.changes.iter().position(|c| c.id == change_id) {
indices.push(idx);
}
}
let start_idx = *indices.iter().min()?;
let end_idx = *indices.iter().max()?;
let file = self.multi_diff.current_file()?;
let (old_path, new_path) = match file.status {
FileStatus::Added | FileStatus::Untracked => (None, Some(file.path.clone())),
FileStatus::Deleted => (Some(file.path.clone()), None),
FileStatus::Renamed => (
file.old_path.clone().or_else(|| Some(file.path.clone())),
Some(file.path.clone()),
),
_ => (
file.old_path.clone().or_else(|| Some(file.path.clone())),
Some(file.path.clone()),
),
};
let diff_old = file.old_path.clone().unwrap_or_else(|| file.path.clone());
let diff_new = file.path.clone();
let (lines, old_start, new_start, old_count, new_count) =
self.build_unified_hunk_lines(&diff.changes, start_idx, end_idx)?;
let mut out = String::new();
out.push_str(&format!(
"diff --git a/{} b/{}\n",
diff_old.display(),
diff_new.display()
));
out.push_str(&format!(
"--- {}\n",
self.patch_header_path(old_path.as_ref(), "a")
));
out.push_str(&format!(
"+++ {}\n",
self.patch_header_path(new_path.as_ref(), "b")
));
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
old_start, old_count, new_start, new_count
));
for line in lines {
out.push_str(&line);
out.push('\n');
}
Some(out.trim_end_matches('\n').to_string())
}
fn patch_header_path(&self, path: Option<&PathBuf>, prefix: &str) -> String {
match path {
Some(path) => format!("{}/{}", prefix, path.display()),
None => "/dev/null".to_string(),
}
}
fn build_unified_hunk_lines(
&self,
all_changes: &[oyo_core::Change],
start_idx: usize,
end_idx: usize,
) -> Option<(Vec<String>, usize, usize, usize, usize)> {
let changes = all_changes.get(start_idx..=end_idx)?;
let mut lines: Vec<String> = Vec::new();
let mut old_start: Option<usize> = None;
let mut new_start: Option<usize> = None;
let mut old_count = 0usize;
let mut new_count = 0usize;
for change in changes {
if old_start.is_none() {
old_start = change.spans.iter().find_map(|span| span.old_line);
}
if new_start.is_none() {
new_start = change.spans.iter().find_map(|span| span.new_line);
}
let has_old = change.spans.iter().any(|span| span.old_line.is_some());
let has_new = change.spans.iter().any(|span| span.new_line.is_some());
let is_context = !change.has_changes();
if is_context {
let text = old_text_for_change(change);
lines.push(format!(" {}", text));
if has_old {
old_count += 1;
}
if has_new {
new_count += 1;
}
continue;
}
if has_old {
let text = old_text_for_change(change);
lines.push(format!("-{}", text));
old_count += 1;
}
if has_new {
let text = modified_only_text_for_change(change);
lines.push(format!("+{}", text));
new_count += 1;
}
}
let mut old_start = old_start.unwrap_or(0);
let mut new_start = new_start.unwrap_or(0);
let prior_line_delta: isize = all_changes[..start_idx]
.iter()
.map(|change| {
let has_old = change.spans.iter().any(|span| span.old_line.is_some());
let has_new = change.spans.iter().any(|span| span.new_line.is_some());
has_new as isize - has_old as isize
})
.sum();
if old_count == 0 {
old_start = (new_start as isize - prior_line_delta - 1).max(0) as usize;
}
if new_count == 0 {
new_start = (old_start as isize + prior_line_delta - 1).max(0) as usize;
}
Some((lines, old_start, new_start, old_count, new_count))
}
fn text_for_yank(&mut self, view_line: &ViewLine) -> Option<String> {
if let Some(mode) = self.peek_mode_for_line(view_line) {
match mode {
PeekMode::Old => {
if let Some(text) = self.peek_text_for_line(view_line) {
return Some(text);
}
}
PeekMode::Modified => {
if let Some(text) = self.modified_only_text_for_line(view_line) {
return Some(text);
}
}
PeekMode::Mixed => {
let change = self
.multi_diff
.current_navigator()
.diff()
.changes
.get(view_line.change_id);
if let Some(change) = change {
let text = inline_text_for_change(change);
if !text.is_empty() {
return Some(text);
}
}
}
}
}
Some(view_line.content.clone())
}
pub(super) fn peek_text_for_line(&mut self, view_line: &ViewLine) -> Option<String> {
if !matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify) {
return None;
}
let change = self
.multi_diff
.current_navigator()
.diff()
.changes
.get(view_line.change_id)?;
let text = old_text_for_change(change);
if text.is_empty() {
None
} else {
Some(text)
}
}
pub(super) fn modified_only_text_for_line(&mut self, view_line: &ViewLine) -> Option<String> {
if !matches!(view_line.kind, LineKind::Modified | LineKind::PendingModify) {
return None;
}
let change = self
.multi_diff
.current_navigator()
.diff()
.changes
.get(view_line.change_id)?;
let text = modified_only_text_for_change(change);
if text.is_empty() {
None
} else {
Some(text)
}
}
pub fn state(&mut self) -> StepState {
self.multi_diff.current_navigator().state().clone()
}
pub fn next_step(&mut self) {
let moved = self.step_forward();
crate::views::log_view_nav_event(self, "step_down", moved);
}
pub fn prev_step(&mut self) {
let moved = self.step_backward();
crate::views::log_view_nav_event(self, "step_up", moved);
}
pub fn replay_step(&mut self) {
if !self.stepping {
return;
}
let count = self.take_count();
if self.animation_phase != AnimationPhase::Idle || self.snap_frame.is_some() {
return;
}
let current_step = self.multi_diff.current_navigator().state().current_step;
if current_step == 0 {
return;
}
let back_steps = count.min(current_step);
if back_steps == 0 {
return;
}
self.autoplay = false;
self.autoplay_remaining = None;
let target_step = current_step.saturating_sub(back_steps);
self.clear_peek();
self.snap_frame = None;
self.snap_frame_started_at = None;
self.clear_active_on_next_render = false;
self.multi_diff.current_navigator().goto(target_step);
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = true;
self.autoplay = true;
self.autoplay_reverse = false;
self.autoplay_remaining = Some(back_steps);
self.last_autoplay_tick = Instant::now();
}
pub(super) fn clear_step_edge_hint(&mut self) {
self.step_edge_hint = None;
}
pub(super) fn clear_hunk_edge_hint(&mut self) {
self.hunk_edge_hint = None;
}
pub(crate) fn step_edge_hint_active(&self) -> bool {
self.step_edge_hint.is_some()
}
pub(crate) fn hunk_edge_hint_active(&self) -> bool {
self.hunk_edge_hint.is_some()
}
pub(crate) fn step_edge_hint_for_change(&self, change_id: usize) -> Option<&'static str> {
let hint = self.step_edge_hint?;
if Instant::now() > hint.until {
return None;
}
if hint.change_id == Some(change_id) {
Some(match hint.edge {
StepEdge::Start => "No more steps",
StepEdge::End => "No more steps",
})
} else {
None
}
}
pub(crate) fn hunk_edge_hint_text(&self) -> Option<&'static str> {
let hint = self.hunk_edge_hint?;
if Instant::now() > hint.until {
return None;
}
Some(match hint.edge {
HunkEdge::First => "First hunk",
HunkEdge::Last => "Last hunk",
})
}
pub(crate) fn hunk_hint_overflow(
&mut self,
hunk_idx: usize,
viewport_height: usize,
) -> Option<(bool, bool)> {
let bounds = match self.view_mode {
ViewMode::Split => {
let (old_bounds, new_bounds) = self.compute_hunk_bounds_split();
let old = old_bounds.get(hunk_idx).copied().flatten();
let new = new_bounds.get(hunk_idx).copied().flatten();
self.pick_split_bounds(old, new)
}
_ => self
.compute_hunk_bounds_unified()
.get(hunk_idx)
.copied()
.flatten(),
}?;
let visible_start = self.scroll_offset;
let visible_end = self
.scroll_offset
.saturating_add(viewport_height.saturating_sub(1));
let overflow_above = bounds.start.idx < visible_start;
let overflow_below = bounds.end.idx > visible_end;
Some((overflow_above, overflow_below))
}
pub(crate) fn last_step_hint_text(&mut self) -> Option<&'static str> {
if !self.stepping {
return None;
}
let state = self.multi_diff.current_navigator().state();
if state.total_steps < 2 {
return None;
}
let remaining = state
.total_steps
.saturating_sub(1)
.saturating_sub(state.current_step);
if remaining != 1 {
return None;
}
Some("Last step next")
}
pub fn next_conflict(&mut self) {
self.goto_conflict(true);
}
pub fn prev_conflict(&mut self) {
self.goto_conflict(false);
}
fn goto_conflict(&mut self, forward: bool) {
if self.stepping {
let steps = self.collect_conflict_steps();
if steps.is_empty() {
let markers = self.collect_conflict_markers();
if markers.is_empty() {
return;
}
self.goto_conflict_scroll(forward, markers);
} else {
self.goto_conflict_step(forward, steps);
}
} else {
let markers = self.collect_conflict_markers();
if markers.is_empty() {
return;
}
self.goto_conflict_scroll(forward, markers);
}
}
fn goto_conflict_step(&mut self, forward: bool, mut steps: Vec<usize>) {
steps.sort_unstable();
let current_step = self.multi_diff.current_navigator().state().current_step;
let target_step = if forward {
steps
.iter()
.find(|step_idx| **step_idx > current_step)
.copied()
.unwrap_or(steps[0])
} else {
steps
.iter()
.rev()
.find(|step_idx| **step_idx < current_step)
.copied()
.unwrap_or(*steps.last().unwrap())
};
self.jump_to_step(target_step);
}
fn goto_conflict_scroll(&mut self, forward: bool, mut markers: Vec<ConflictMarker>) {
markers.sort_by_key(|marker| marker.display_idx);
let frame = self.animation_frame();
let view = self.current_view_with_frame(frame);
let step_direction = self.multi_diff.current_step_direction();
let (_, active_idx) = display_metrics(
&view,
self.view_mode,
self.animation_phase,
self.scroll_offset,
step_direction,
self.split_align_lines,
);
let start = active_idx.unwrap_or(self.scroll_offset);
let target = if forward {
markers
.iter()
.find(|marker| marker.display_idx > start)
.unwrap_or(&markers[0])
} else {
markers
.iter()
.rev()
.find(|marker| marker.display_idx < start)
.unwrap_or(markers.last().unwrap())
};
self.scroll_offset = target.display_idx;
{
let nav = self.multi_diff.current_navigator();
let hunk_id = nav
.diff()
.hunk_for_change(target.change_id)
.map(|hunk| hunk.id);
if let Some(id) = hunk_id {
nav.set_cursor_hunk(id, Some(target.change_id));
}
nav.set_cursor_override(Some(target.change_id));
nav.set_hunk_scope(hunk_id.is_some());
}
self.centered_once = true;
self.needs_scroll_to_active = self.auto_center;
self.clear_hunk_edge_hint();
self.clear_step_edge_hint();
}
fn collect_conflict_markers(&mut self) -> Vec<ConflictMarker> {
let frame = self.animation_frame();
let view = self.current_view_with_frame(frame);
let mut matches = Vec::new();
match self.view_mode {
ViewMode::UnifiedPane | ViewMode::Blame => {
for (display_idx, line) in view.iter().enumerate() {
if is_conflict_marker(line) {
matches.push(ConflictMarker {
display_idx,
change_id: line.change_id,
});
}
}
}
ViewMode::Evolution => {
let mut display_idx = 0usize;
for line in view.iter() {
let visible = match line.kind {
LineKind::Deleted => false,
LineKind::PendingDelete => {
line.is_active && self.animation_phase != AnimationPhase::Idle
}
_ => true,
};
if !visible {
continue;
}
if is_conflict_marker(line) {
matches.push(ConflictMarker {
display_idx,
change_id: line.change_id,
});
}
display_idx += 1;
}
}
ViewMode::Split => {
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for line in view.iter() {
let has_new = line.new_line.is_some()
&& !matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete);
let has_old = line.old_line.is_some();
if has_new {
if is_conflict_marker(line) {
matches.push(ConflictMarker {
display_idx: new_idx,
change_id: line.change_id,
});
}
new_idx += 1;
} else if has_old {
if is_conflict_marker(line) {
matches.push(ConflictMarker {
display_idx: old_idx,
change_id: line.change_id,
});
}
old_idx += 1;
}
}
}
ViewMode::Preview => {}
}
matches
}
fn collect_conflict_steps(&mut self) -> Vec<usize> {
let nav = self.multi_diff.current_navigator();
let diff = nav.diff();
let mut out = Vec::new();
for (idx, change_id) in diff.significant_changes.iter().enumerate() {
let Some(change_idx) = nav.change_index_for(*change_id) else {
continue;
};
let change = &diff.changes[change_idx];
if change_has_conflict_marker(change) {
out.push(idx + 1);
}
}
out
}
fn jump_to_step(&mut self, target_step: usize) {
let current_step = self.multi_diff.current_navigator().state().current_step;
if current_step == target_step {
return;
}
self.clear_peek();
self.clear_hunk_edge_hint();
self.clear_blame_hunk_hint();
self.clear_blame_step_hint();
self.snap_frame = None;
self.snap_frame_started_at = None;
let forward = target_step > current_step;
if !self.animation_enabled && !forward {
self.snap_frame = Some(AnimationFrame::FadeOut);
self.snap_frame_started_at = Some(Instant::now());
self.clear_active_on_next_render = false;
}
if forward {
for _ in current_step..target_step {
if !self.multi_diff.current_navigator().next() {
break;
}
}
} else {
for _ in target_step..current_step {
if !self.multi_diff.current_navigator().prev() {
break;
}
}
}
self.clear_step_edge_hint();
if self.animation_enabled {
self.start_animation();
} else if !forward && self.snap_frame.is_none() {
self.clear_active_on_next_render = true;
}
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
}
fn trigger_hunk_edge_hint(&mut self, edge: HunkEdge) {
self.hunk_edge_hint = Some(HunkEdgeHint {
edge,
until: Instant::now() + Duration::from_millis(STEP_EDGE_HINT_MS),
});
}
fn trigger_step_edge_hint(&mut self, edge: StepEdge) {
let state = self.multi_diff.current_navigator().state();
let change_id = match edge {
StepEdge::End => state
.applied_changes
.last()
.copied()
.or(state.active_change),
StepEdge::Start => state
.applied_changes
.first()
.copied()
.or(state.active_change),
};
self.step_edge_hint = Some(StepEdgeHint {
change_id,
edge,
until: Instant::now() + Duration::from_millis(STEP_EDGE_HINT_MS),
});
}
pub(super) fn step_forward(&mut self) -> bool {
if !self.current_file_diff_ready() {
return false;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_peek();
self.clear_hunk_edge_hint();
self.clear_blame_hunk_hint();
self.clear_blame_step_hint();
self.snap_frame = None;
self.snap_frame_started_at = None;
if self.multi_diff.current_navigator().next() {
self.clear_step_edge_hint();
if self.animation_enabled {
self.start_animation();
}
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
true
} else {
match self.step_wrap {
StepWrapMode::File => {
if self.next_file_wrapped() {
self.goto_first_step();
return true;
}
}
StepWrapMode::Step => {
self.goto_first_step();
return true;
}
StepWrapMode::None => {}
}
self.trigger_step_edge_hint(StepEdge::End);
false
}
}
pub(super) fn step_backward(&mut self) -> bool {
if !self.current_file_diff_ready() {
return false;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_peek();
self.clear_hunk_edge_hint();
self.clear_blame_hunk_hint();
self.clear_blame_step_hint();
self.snap_frame = None;
self.snap_frame_started_at = None;
if !self.animation_enabled {
self.snap_frame = Some(AnimationFrame::FadeOut);
self.snap_frame_started_at = Some(Instant::now());
self.clear_active_on_next_render = false;
}
if self.multi_diff.current_navigator().prev() {
self.clear_step_edge_hint();
if self.animation_enabled {
self.start_animation();
} else if self.snap_frame.is_none() {
self.clear_active_on_next_render = true;
}
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
true
} else {
match self.step_wrap {
StepWrapMode::File => {
if self.prev_file_wrapped() {
self.goto_last_step();
return true;
}
}
StepWrapMode::Step => {
self.goto_last_step();
return true;
}
StepWrapMode::None => {}
}
self.trigger_step_edge_hint(StepEdge::Start);
false
}
}
fn compute_hunk_starts_unified(&mut self) -> Vec<Option<HunkStart>> {
let key = self.hunk_cache_key_unified();
if let Some((cache_key, starts)) = self.hunk_starts_unified_cache.as_ref() {
if *cache_key == key {
return starts.clone();
}
}
let starts = self.compute_hunk_starts_unified_uncached();
self.hunk_starts_unified_cache = Some((key, starts.clone()));
starts
}
fn compute_hunk_starts_unified_uncached(&mut self) -> Vec<Option<HunkStart>> {
if self.multi_diff.current_file_is_large() && !self.fold_context.is_enabled() {
return self.compute_hunk_starts_unified_fast();
}
let view = self.current_view_with_frame(AnimationFrame::Idle);
let (_, total_hunks) = self.hunk_info();
let mut hunk_starts = vec![None; total_hunks];
let mut display_idx = 0;
for line in view.iter() {
let is_visible = match self.view_mode {
ViewMode::Evolution => {
!matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete)
}
_ => true,
};
if !is_visible {
continue;
}
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks && hunk_starts[hidx].is_none() {
hunk_starts[hidx] = Some(HunkStart {
idx: display_idx,
change_id: Some(line.change_id),
});
}
}
display_idx += 1;
}
hunk_starts
}
fn compute_hunk_bounds_unified(&mut self) -> Vec<Option<HunkBounds>> {
let key = self.hunk_cache_key_unified();
if let Some((cache_key, bounds)) = self.hunk_bounds_unified_cache.as_ref() {
if *cache_key == key {
return bounds.clone();
}
}
let bounds = self.compute_hunk_bounds_unified_uncached();
self.hunk_bounds_unified_cache = Some((key, bounds.clone()));
bounds
}
fn compute_hunk_bounds_unified_uncached(&mut self) -> Vec<Option<HunkBounds>> {
if self.multi_diff.current_file_is_large() && !self.fold_context.is_enabled() {
return self.compute_hunk_bounds_unified_fast();
}
let view = self.current_view_with_frame(AnimationFrame::Idle);
let (_, total_hunks) = self.hunk_info();
let mut bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let mut display_idx = 0;
for line in view.iter() {
let is_visible = match self.view_mode {
ViewMode::Evolution => {
!matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete)
}
_ => true,
};
if !is_visible {
continue;
}
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks {
let start = HunkStart {
idx: display_idx,
change_id: Some(line.change_id),
};
if let Some(existing) = bounds[hidx] {
bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
}
display_idx += 1;
}
bounds
}
fn compute_hunk_starts_split(&mut self) -> (Vec<Option<HunkStart>>, Vec<Option<HunkStart>>) {
let key = self.hunk_cache_key_split();
if let Some((cache_key, starts)) = self.hunk_starts_split_cache.as_ref() {
if *cache_key == key {
return starts.clone();
}
}
let starts = if self.multi_diff.current_file_is_large() && !self.fold_context.is_enabled() {
self.compute_hunk_starts_split_fast()
} else {
let view = self.current_view_with_frame(AnimationFrame::Idle);
let (_, total_hunks) = self.hunk_info();
let mut old_starts = vec![None; total_hunks];
let mut new_starts = vec![None; total_hunks];
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for line in view.iter() {
let fold_line = is_fold_line(line);
if line.old_line.is_some() || fold_line {
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks && old_starts[hidx].is_none() {
old_starts[hidx] = Some(HunkStart {
idx: old_idx,
change_id: Some(line.change_id),
});
}
}
old_idx += 1;
}
if line.new_line.is_some() || fold_line {
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks && new_starts[hidx].is_none() {
new_starts[hidx] = Some(HunkStart {
idx: new_idx,
change_id: Some(line.change_id),
});
}
}
new_idx += 1;
}
}
(old_starts, new_starts)
};
self.hunk_starts_split_cache = Some((key, starts.clone()));
starts
}
fn compute_hunk_bounds_split(&mut self) -> (Vec<Option<HunkBounds>>, Vec<Option<HunkBounds>>) {
let key = self.hunk_cache_key_split();
if let Some((cache_key, bounds)) = self.hunk_bounds_split_cache.as_ref() {
if *cache_key == key {
return bounds.clone();
}
}
let bounds = if self.multi_diff.current_file_is_large() && !self.fold_context.is_enabled() {
self.compute_hunk_bounds_split_fast()
} else {
let view = self.current_view_with_frame(AnimationFrame::Idle);
let (_, total_hunks) = self.hunk_info();
let mut old_bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let mut new_bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for line in view.iter() {
let fold_line = is_fold_line(line);
if line.old_line.is_some() || fold_line {
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks {
let start = HunkStart {
idx: old_idx,
change_id: Some(line.change_id),
};
if let Some(existing) = old_bounds[hidx] {
old_bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
old_bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
}
old_idx += 1;
}
if line.new_line.is_some() || fold_line {
if let Some(hidx) = line.hunk_index {
if hidx < total_hunks {
let start = HunkStart {
idx: new_idx,
change_id: Some(line.change_id),
};
if let Some(existing) = new_bounds[hidx] {
new_bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
new_bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
}
new_idx += 1;
}
}
(old_bounds, new_bounds)
};
self.hunk_bounds_split_cache = Some((key, bounds.clone()));
bounds
}
fn change_visible_in_evolution(change: &oyo_core::change::Change) -> bool {
let mut has_old = false;
let mut has_new = false;
for span in &change.spans {
match span.kind {
ChangeKind::Insert => has_new = true,
ChangeKind::Delete => has_old = true,
ChangeKind::Replace => {
has_old = true;
has_new = true;
}
ChangeKind::Equal => {}
}
}
!has_old || has_new
}
fn compute_hunk_starts_unified_fast(&mut self) -> Vec<Option<HunkStart>> {
let nav = self.multi_diff.current_navigator();
let total_hunks = nav.state().total_hunks;
let mut starts = vec![None; total_hunks];
let hunk_map = build_hunk_change_index_map(nav);
let mut display_idx = 0usize;
for (change_idx, change) in nav.diff().changes.iter().enumerate() {
let is_visible = match self.view_mode {
ViewMode::Evolution => Self::change_visible_in_evolution(change),
_ => true,
};
if !is_visible {
continue;
}
if let Some(hidx) = hunk_map.get(change_idx).copied().flatten() {
if hidx < total_hunks && starts[hidx].is_none() {
starts[hidx] = Some(HunkStart {
idx: display_idx,
change_id: Some(change.id),
});
}
}
display_idx += 1;
}
starts
}
fn compute_hunk_bounds_unified_fast(&mut self) -> Vec<Option<HunkBounds>> {
let nav = self.multi_diff.current_navigator();
let total_hunks = nav.state().total_hunks;
let mut bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let hunk_map = build_hunk_change_index_map(nav);
let mut display_idx = 0usize;
for (change_idx, change) in nav.diff().changes.iter().enumerate() {
let is_visible = match self.view_mode {
ViewMode::Evolution => Self::change_visible_in_evolution(change),
_ => true,
};
if !is_visible {
continue;
}
if let Some(hidx) = hunk_map.get(change_idx).copied().flatten() {
if hidx < total_hunks {
let start = HunkStart {
idx: display_idx,
change_id: Some(change.id),
};
if let Some(existing) = bounds[hidx] {
bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
}
display_idx += 1;
}
bounds
}
fn compute_hunk_starts_split_fast(
&mut self,
) -> (Vec<Option<HunkStart>>, Vec<Option<HunkStart>>) {
let nav = self.multi_diff.current_navigator();
let total_hunks = nav.state().total_hunks;
let mut old_starts = vec![None; total_hunks];
let mut new_starts = vec![None; total_hunks];
let hunk_map = build_hunk_change_index_map(nav);
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for (change_idx, change) in nav.diff().changes.iter().enumerate() {
let mut has_old = false;
let mut has_new = false;
for span in &change.spans {
if span.old_line.is_some() {
has_old = true;
}
if span.new_line.is_some() {
has_new = true;
}
}
let old_visible = has_old || (self.split_align_lines && has_new);
let new_visible = has_new || (self.split_align_lines && has_old);
if let Some(hidx) = hunk_map.get(change_idx).copied().flatten() {
if hidx < total_hunks {
if old_visible && old_starts[hidx].is_none() {
old_starts[hidx] = Some(HunkStart {
idx: old_idx,
change_id: Some(change.id),
});
}
if new_visible && new_starts[hidx].is_none() {
new_starts[hidx] = Some(HunkStart {
idx: new_idx,
change_id: Some(change.id),
});
}
}
}
if old_visible {
old_idx += 1;
}
if new_visible {
new_idx += 1;
}
}
(old_starts, new_starts)
}
fn compute_hunk_bounds_split_fast(
&mut self,
) -> (Vec<Option<HunkBounds>>, Vec<Option<HunkBounds>>) {
let nav = self.multi_diff.current_navigator();
let total_hunks = nav.state().total_hunks;
let mut old_bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let mut new_bounds: Vec<Option<HunkBounds>> = vec![None; total_hunks];
let hunk_map = build_hunk_change_index_map(nav);
let mut old_idx = 0usize;
let mut new_idx = 0usize;
for (change_idx, change) in nav.diff().changes.iter().enumerate() {
let mut has_old = false;
let mut has_new = false;
for span in &change.spans {
if span.old_line.is_some() {
has_old = true;
}
if span.new_line.is_some() {
has_new = true;
}
}
let old_visible = has_old || (self.split_align_lines && has_new);
let new_visible = has_new || (self.split_align_lines && has_old);
if let Some(hidx) = hunk_map.get(change_idx).copied().flatten() {
if hidx < total_hunks {
if old_visible {
let start = HunkStart {
idx: old_idx,
change_id: Some(change.id),
};
if let Some(existing) = old_bounds[hidx] {
old_bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
old_bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
if new_visible {
let start = HunkStart {
idx: new_idx,
change_id: Some(change.id),
};
if let Some(existing) = new_bounds[hidx] {
new_bounds[hidx] = Some(HunkBounds {
start: existing.start,
end: start,
});
} else {
new_bounds[hidx] = Some(HunkBounds { start, end: start });
}
}
}
}
if old_visible {
old_idx += 1;
}
if new_visible {
new_idx += 1;
}
}
(old_bounds, new_bounds)
}
fn pick_split_start(
&self,
old: Option<HunkStart>,
new: Option<HunkStart>,
) -> Option<HunkStart> {
match (old, new) {
(Some(o), Some(n)) => {
let old_dist = (o.idx as isize - self.scroll_offset as isize).abs();
let new_dist = (n.idx as isize - self.scroll_offset as isize).abs();
if old_dist < new_dist {
Some(o)
} else {
Some(n)
}
}
(Some(o), None) => Some(o),
(None, Some(n)) => Some(n),
(None, None) => None,
}
}
fn pick_split_index(&self, old: Option<usize>, new: Option<usize>) -> Option<usize> {
match (old, new) {
(Some(o), Some(n)) => {
let old_dist = (o as isize - self.scroll_offset as isize).abs();
let new_dist = (n as isize - self.scroll_offset as isize).abs();
if old_dist < new_dist {
Some(o)
} else {
Some(n)
}
}
(Some(o), None) => Some(o),
(None, Some(n)) => Some(n),
(None, None) => None,
}
}
fn pick_split_bounds(
&self,
old: Option<HunkBounds>,
new: Option<HunkBounds>,
) -> Option<HunkBounds> {
match (old, new) {
(Some(o), Some(n)) => {
let old_dist = (o.start.idx as isize - self.scroll_offset as isize).abs();
let new_dist = (n.start.idx as isize - self.scroll_offset as isize).abs();
if old_dist < new_dist {
Some(o)
} else {
Some(n)
}
}
(Some(o), None) => Some(o),
(None, Some(n)) => Some(n),
(None, None) => None,
}
}
fn next_hunk_from_starts(
&self,
starts: &[Option<HunkStart>],
inclusive: bool,
) -> Option<(usize, HunkStart)> {
let current_hunk_idx = starts
.iter()
.enumerate()
.filter_map(|(idx, start)| start.map(|s| (idx, s.idx)))
.filter(|&(_, start)| {
if inclusive {
start <= self.scroll_offset
} else {
start < self.scroll_offset
}
})
.map(|(idx, _)| idx)
.next_back();
let mut target_idx = match current_hunk_idx {
Some(curr) => curr + 1,
None => 0,
};
while target_idx < starts.len() {
if let Some(start) = starts[target_idx] {
return Some((target_idx, start));
}
target_idx += 1;
}
None
}
fn next_hunk_from_index(
&self,
starts: &[Option<HunkStart>],
current_hunk: usize,
) -> Option<(usize, HunkStart)> {
let mut target_idx = current_hunk.saturating_add(1);
while target_idx < starts.len() {
if let Some(start) = starts[target_idx] {
return Some((target_idx, start));
}
target_idx += 1;
}
None
}
fn first_hunk_from_starts(&self, starts: &[Option<HunkStart>]) -> Option<(usize, HunkStart)> {
starts
.iter()
.enumerate()
.find_map(|(idx, start)| start.map(|s| (idx, s)))
}
fn last_hunk_from_starts(&self, starts: &[Option<HunkStart>]) -> Option<(usize, HunkStart)> {
starts
.iter()
.enumerate()
.rev()
.find_map(|(idx, start)| start.map(|s| (idx, s)))
}
fn unified_hunk_fallback(&self, starts: &[Option<HunkStart>]) -> Option<(usize, HunkStart)> {
let mut only: Option<(usize, HunkStart)> = None;
for (idx, start) in starts.iter().enumerate() {
if let Some(start) = start {
if only.is_some() {
return None;
}
only = Some((idx, *start));
}
}
only
}
fn prev_hunk_from_starts(&self, starts: &[Option<HunkStart>]) -> Option<(usize, HunkStart)> {
starts
.iter()
.enumerate()
.filter_map(|(idx, start)| start.map(|s| (idx, s)))
.rfind(|&(_, start)| start.idx < self.scroll_offset)
}
fn prev_hunk_from_index(
&self,
starts: &[Option<HunkStart>],
current_hunk: usize,
) -> Option<(usize, HunkStart)> {
if starts.is_empty() {
return None;
}
let mut idx = current_hunk.min(starts.len() - 1);
while idx > 0 {
idx -= 1;
if let Some(start) = starts[idx] {
return Some((idx, start));
}
}
None
}
fn current_hunk_from_bounds(&self, bounds: &[Option<HunkBounds>]) -> Option<usize> {
bounds.iter().enumerate().rev().find_map(|(idx, bound)| {
bound.and_then(|b| (b.start.idx <= self.scroll_offset).then_some(idx))
})
}
fn first_available_hunk(bounds: &[Option<HunkBounds>]) -> Option<(usize, HunkBounds)> {
bounds
.iter()
.enumerate()
.find_map(|(idx, bound)| bound.map(|b| (idx, b)))
}
pub(super) fn set_cursor_for_current_scroll(&mut self) -> bool {
let view = self.current_view_with_frame(AnimationFrame::Idle);
let target_offset = if self.view_windowed() {
self.render_scroll_offset()
} else {
self.scroll_offset
};
let mut display_idx = 0usize;
let mut cursor_line = None;
for line in view.iter() {
let visible = match self.view_mode {
ViewMode::Evolution => {
!matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete)
}
_ => true,
};
if !visible {
continue;
}
if display_idx >= target_offset {
cursor_line = Some(line);
break;
}
display_idx += 1;
}
if let Some(line) = cursor_line {
if let Some(hidx) = line.hunk_index {
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, Some(line.change_id));
true
} else {
self.multi_diff
.current_navigator()
.set_cursor_change(Some(line.change_id));
false
}
} else {
self.multi_diff.current_navigator().clear_cursor_change();
false
}
}
pub fn next_hunk_scroll(&mut self) {
let mut moved = false;
if !self.current_file_diff_ready() {
crate::views::log_view_nav_event(self, "hunk_down", moved);
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_blame_hunk_hint();
let auto_center = self.auto_center;
let (current_hunk, cursor_set) = {
let state = self.multi_diff.current_navigator().state();
(state.current_hunk, state.cursor_change.is_some())
};
let in_hunk_scope = self
.multi_diff
.current_navigator()
.state()
.last_nav_was_hunk;
let use_cursor = auto_center && cursor_set && in_hunk_scope;
let inclusive = in_hunk_scope;
let target = match self.view_mode {
ViewMode::Split => {
let (old_starts, new_starts) = self.compute_hunk_starts_split();
let effective: Vec<Option<HunkStart>> = old_starts
.into_iter()
.zip(new_starts)
.map(|(old, new)| self.pick_split_start(old, new))
.collect();
let mut target = if use_cursor && current_hunk < effective.len() {
self.next_hunk_from_index(&effective, current_hunk)
} else {
self.next_hunk_from_starts(&effective, inclusive)
};
if target.is_none() {
target = self.unified_hunk_fallback(&effective);
}
if target.is_none() && matches!(self.hunk_wrap, HunkWrapMode::Hunk) {
target = self.first_hunk_from_starts(&effective);
}
target
}
_ => {
let hunk_starts = self.compute_hunk_starts_unified();
let mut target = if use_cursor && current_hunk < hunk_starts.len() {
self.next_hunk_from_index(&hunk_starts, current_hunk)
} else {
self.next_hunk_from_starts(&hunk_starts, inclusive)
};
if target.is_none() {
target = self.unified_hunk_fallback(&hunk_starts);
}
if target.is_none() && matches!(self.hunk_wrap, HunkWrapMode::Hunk) {
target = self.first_hunk_from_starts(&hunk_starts);
}
target
}
};
if let Some((hidx, start)) = target {
self.scroll_offset = start.idx;
self.centered_once = false;
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, start.change_id);
self.multi_diff.current_navigator().set_hunk_scope(true);
if self.auto_center {
self.needs_scroll_to_active = true;
}
self.clear_hunk_edge_hint();
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
moved = true;
} else if matches!(self.hunk_wrap, HunkWrapMode::File) {
if self.wrap_to_file_hunk(true, false) {
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::Last);
}
} else {
self.trigger_hunk_edge_hint(HunkEdge::Last);
}
crate::views::log_view_nav_event(self, "hunk_down", moved);
}
pub fn prev_hunk_scroll(&mut self) {
let mut moved = false;
if !self.current_file_diff_ready() {
crate::views::log_view_nav_event(self, "hunk_up", moved);
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_blame_hunk_hint();
let auto_center = self.auto_center;
let (current_hunk, cursor_set) = {
let state = self.multi_diff.current_navigator().state();
(state.current_hunk, state.cursor_change.is_some())
};
let in_hunk_scope = self
.multi_diff
.current_navigator()
.state()
.last_nav_was_hunk;
let use_cursor = auto_center && cursor_set && in_hunk_scope;
let target = match self.view_mode {
ViewMode::Split => {
let (old_starts, new_starts) = self.compute_hunk_starts_split();
let effective: Vec<Option<HunkStart>> = old_starts
.into_iter()
.zip(new_starts)
.map(|(old, new)| self.pick_split_start(old, new))
.collect();
let mut target = if use_cursor && current_hunk < effective.len() {
self.prev_hunk_from_index(&effective, current_hunk)
} else {
self.prev_hunk_from_starts(&effective)
};
if target.is_none() {
target = self.unified_hunk_fallback(&effective);
}
if target.is_none() && matches!(self.hunk_wrap, HunkWrapMode::Hunk) {
target = self.last_hunk_from_starts(&effective);
}
target
}
_ => {
let hunk_starts = self.compute_hunk_starts_unified();
let mut target = if use_cursor && current_hunk < hunk_starts.len() {
self.prev_hunk_from_index(&hunk_starts, current_hunk)
} else {
self.prev_hunk_from_starts(&hunk_starts)
};
if target.is_none() {
target = self.unified_hunk_fallback(&hunk_starts);
}
if target.is_none() && matches!(self.hunk_wrap, HunkWrapMode::Hunk) {
target = self.last_hunk_from_starts(&hunk_starts);
}
target
}
};
if let Some((hidx, start)) = target {
self.scroll_offset = start.idx;
self.centered_once = false;
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, start.change_id);
self.multi_diff.current_navigator().set_hunk_scope(true);
if self.auto_center {
self.needs_scroll_to_active = true;
}
self.clear_hunk_edge_hint();
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
moved = true;
} else if matches!(self.hunk_wrap, HunkWrapMode::File) {
if self.wrap_to_file_hunk(false, false) {
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::First);
}
} else {
self.trigger_hunk_edge_hint(HunkEdge::First);
}
crate::views::log_view_nav_event(self, "hunk_up", moved);
}
pub fn next_hunk(&mut self) {
let mut moved = false;
if !self.current_file_diff_ready() {
crate::views::log_view_nav_event(self, "hunk_down", moved);
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.cancel_view_build_defer();
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if self.multi_diff.current_navigator().next_hunk() {
if self.animation_enabled {
self.start_animation();
}
self.needs_scroll_to_active = true;
self.clear_hunk_edge_hint();
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
moved = true;
} else {
match self.hunk_wrap {
HunkWrapMode::Hunk => {
let total = self.multi_diff.current_navigator().state().total_hunks;
if total > 0 {
self.goto_hunk_index(0);
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::Last);
}
}
HunkWrapMode::File => {
if self.wrap_to_file_hunk(true, true) {
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::Last);
}
}
HunkWrapMode::None => {
self.trigger_hunk_edge_hint(HunkEdge::Last);
}
}
}
crate::views::log_view_nav_event(self, "hunk_down", moved);
}
pub fn prev_hunk(&mut self) {
let mut moved = false;
if !self.current_file_diff_ready() {
crate::views::log_view_nav_event(self, "hunk_up", moved);
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.cancel_view_build_defer();
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if self.multi_diff.current_navigator().prev_hunk() {
if self.animation_enabled {
self.start_animation();
} else {
self.clear_active_on_next_render = true;
}
self.needs_scroll_to_active = true;
self.clear_hunk_edge_hint();
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
moved = true;
} else {
match self.hunk_wrap {
HunkWrapMode::Hunk => {
let total = self.multi_diff.current_navigator().state().total_hunks;
if total > 0 {
self.goto_hunk_index(total.saturating_sub(1));
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::First);
}
}
HunkWrapMode::File => {
if self.wrap_to_file_hunk(false, true) {
self.clear_hunk_edge_hint();
moved = true;
} else {
self.trigger_hunk_edge_hint(HunkEdge::First);
}
}
HunkWrapMode::None => {
self.trigger_hunk_edge_hint(HunkEdge::First);
}
}
}
crate::views::log_view_nav_event(self, "hunk_up", moved);
}
pub fn hunk_info(&mut self) -> (usize, usize) {
let state = self.multi_diff.current_navigator().state();
(state.current_hunk + 1, state.total_hunks) }
pub fn hunk_step_info(&mut self) -> Option<(usize, usize)> {
let nav = self.multi_diff.current_navigator();
let state = nav.state();
let (start, total) = nav.hunk_step_range(state.current_hunk)?;
if total == 0 {
return None;
}
let applied = state.current_step.saturating_sub(start).min(total);
Some((applied, total))
}
pub fn pending_insert_only_in_current_hunk(&mut self) -> usize {
let nav = self.multi_diff.current_navigator();
let state = nav.state();
let hunk = match nav.current_hunk() {
Some(hunk) => hunk,
None => return 0,
};
let cursor_id = state
.cursor_change
.or(state.active_change)
.or_else(|| state.applied_changes.last().copied());
let cursor_id = match cursor_id {
Some(id) => id,
None => return 0,
};
let cursor_idx = match hunk.change_ids.iter().position(|id| *id == cursor_id) {
Some(idx) => idx,
None => return 0,
};
let get_change = |id| {
nav.change_index_for(id)
.and_then(|idx| nav.diff().changes.get(idx))
};
let is_insert_only = |change: &oyo_core::Change| {
change
.spans
.iter()
.all(|span| span.kind == ChangeKind::Insert)
};
let cursor_change = match get_change(cursor_id) {
Some(change) => change,
None => return 0,
};
if !is_insert_only(cursor_change) {
return 0;
}
let mut pending = 0usize;
for change_id in hunk.change_ids.iter().skip(cursor_idx + 1) {
let change = match get_change(*change_id) {
Some(change) => change,
None => continue,
};
if !is_insert_only(change) {
break;
}
if state.is_applied(*change_id) {
continue;
}
pending += 1;
}
pending
}
pub fn goto_hunk_start(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.cancel_view_build_defer();
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if self.multi_diff.current_navigator().goto_hunk_start() {
if self.animation_enabled {
self.start_animation();
}
self.needs_scroll_to_active = true;
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
}
pub fn goto_hunk_start_scroll(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_blame_hunk_hint();
let (current_hunk, in_hunk_scope) = {
let state = self.multi_diff.current_navigator().state();
(
state.current_hunk,
state.last_nav_was_hunk && state.cursor_change.is_some(),
)
};
let target = match self.view_mode {
ViewMode::Split => {
let (old_bounds, new_bounds) = self.compute_hunk_bounds_split();
let effective: Vec<Option<HunkBounds>> = old_bounds
.into_iter()
.zip(new_bounds)
.map(|(old, new)| self.pick_split_bounds(old, new))
.collect();
let hidx = if in_hunk_scope {
Some(current_hunk)
} else {
self.current_hunk_from_bounds(&effective)
};
hidx.and_then(|idx| effective.get(idx).copied().flatten().map(|b| (idx, b)))
.or_else(|| Self::first_available_hunk(&effective))
}
_ => {
let bounds = self.compute_hunk_bounds_unified();
let hidx = if in_hunk_scope {
Some(current_hunk)
} else {
self.current_hunk_from_bounds(&bounds)
};
hidx.and_then(|idx| bounds.get(idx).copied().flatten().map(|b| (idx, b)))
.or_else(|| Self::first_available_hunk(&bounds))
}
};
if let Some((hidx, bound)) = target {
self.scroll_offset = bound.start.idx;
self.centered_once = false;
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, bound.start.change_id);
self.multi_diff.current_navigator().set_hunk_scope(true);
if self.auto_center {
self.needs_scroll_to_active = true;
}
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
}
pub fn goto_hunk_end(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.cancel_view_build_defer();
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if self.multi_diff.current_navigator().goto_hunk_end() {
if self.animation_enabled {
self.start_animation();
}
self.needs_scroll_to_active = true;
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
}
pub fn goto_hunk_end_scroll(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_blame_hunk_hint();
let (current_hunk, in_hunk_scope) = {
let state = self.multi_diff.current_navigator().state();
(
state.current_hunk,
state.last_nav_was_hunk && state.cursor_change.is_some(),
)
};
let target = match self.view_mode {
ViewMode::Split => {
let (old_bounds, new_bounds) = self.compute_hunk_bounds_split();
let effective: Vec<Option<HunkBounds>> = old_bounds
.into_iter()
.zip(new_bounds)
.map(|(old, new)| self.pick_split_bounds(old, new))
.collect();
let hidx = if in_hunk_scope {
Some(current_hunk)
} else {
self.current_hunk_from_bounds(&effective)
};
hidx.and_then(|idx| effective.get(idx).copied().flatten().map(|b| (idx, b)))
.or_else(|| Self::first_available_hunk(&effective))
}
_ => {
let bounds = self.compute_hunk_bounds_unified();
let hidx = if in_hunk_scope {
Some(current_hunk)
} else {
self.current_hunk_from_bounds(&bounds)
};
hidx.and_then(|idx| bounds.get(idx).copied().flatten().map(|b| (idx, b)))
.or_else(|| Self::first_available_hunk(&bounds))
}
};
if let Some((hidx, bound)) = target {
self.scroll_offset = bound.end.idx;
self.centered_once = false;
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, bound.end.change_id);
self.multi_diff.current_navigator().set_hunk_scope(true);
if self.auto_center {
self.needs_scroll_to_active = true;
}
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
}
pub fn enter_no_step_mode(&mut self) {
if self.multi_diff.file_count() == 0 {
self.peek_state = None;
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.needs_scroll_to_active = false;
return;
}
if self.view_mode == ViewMode::Evolution {
self.view_mode = ViewMode::UnifiedPane;
}
self.peek_state = None;
self.multi_diff.current_navigator().goto_end();
self.multi_diff.current_navigator().clear_active_change();
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.needs_scroll_to_active = false;
let index = self.multi_diff.selected_index;
if !self.restore_no_step_state_snapshot(index) {
if self.no_step_auto_jump_on_enter && !self.no_step_visited[index] {
self.goto_hunk_index_scroll(0);
} else {
self.set_cursor_for_current_scroll();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
}
self.no_step_visited[index] = true;
}
pub fn toggle_stepping(&mut self) {
if self.multi_diff.file_count() == 0 {
return;
}
if self.view_mode == ViewMode::Preview {
self.stepping = !self.stepping;
self.notify(ToastEvent::Stepping(self.stepping));
return;
}
let current_index = self.multi_diff.selected_index;
if self.stepping {
self.save_scroll_position_for(current_index);
self.save_step_state_snapshot(current_index);
self.step_peek_state = self.peek_state.take();
self.step_view_mode = self.view_mode;
self.stepping = false;
self.clear_step_edge_hint();
self.clear_hunk_edge_hint();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if !self.no_step_visited[current_index] {
self.scroll_offsets_no_step[current_index] = self.scroll_offset;
self.horizontal_scrolls_no_step[current_index] = self.horizontal_scroll;
}
self.restore_scroll_position_for(current_index);
self.enter_no_step_mode();
} else {
self.save_no_step_state_snapshot(current_index);
self.save_scroll_position_for(current_index);
self.stepping = true;
self.clear_step_edge_hint();
self.clear_hunk_edge_hint();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
self.peek_state = self.step_peek_state.take();
self.view_mode = self.step_view_mode;
if !self.restore_step_state_snapshot(current_index) {
self.goto_start();
}
self.restore_scroll_position_for(current_index);
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.needs_scroll_to_active = false;
}
self.notify(ToastEvent::Stepping(self.stepping));
}
pub fn goto_start(&mut self) {
self.scroll_to_render_end = false;
if self.stepping && !self.current_file_diff_ready() {
return;
}
if self.stepping {
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
}
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if !self.stepping {
self.scroll_offset = 0;
self.centered_once = false;
self.needs_scroll_to_active = false;
let preserve_scope = self
.multi_diff
.current_navigator()
.state()
.last_nav_was_hunk;
let keep_scope = preserve_scope && self.set_cursor_for_current_scroll();
if !keep_scope {
self.multi_diff.current_navigator().clear_cursor_change();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
return;
}
self.multi_diff.current_navigator().goto_start();
self.scroll_offset = 0;
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
}
pub fn goto_end(&mut self) {
if self.stepping && !self.current_file_diff_ready() {
return;
}
if self.stepping {
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
}
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
if !self.stepping {
self.scroll_offset = usize::MAX;
self.scroll_to_render_end = true;
self.centered_once = false;
self.needs_scroll_to_active = false;
let preserve_scope = self
.multi_diff
.current_navigator()
.state()
.last_nav_was_hunk;
let keep_scope = preserve_scope && self.set_cursor_for_current_scroll();
if !keep_scope {
self.multi_diff.current_navigator().clear_cursor_change();
self.multi_diff.current_navigator().set_hunk_scope(false);
}
return;
}
self.multi_diff.current_navigator().goto_end();
self.scroll_offset = usize::MAX; self.scroll_to_render_end = true;
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = false;
self.refresh_blame_toggle_hint();
}
pub fn goto_first_step(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
self.multi_diff.current_navigator().goto(1);
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
}
pub fn goto_last_step(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
self.multi_diff.current_navigator().goto_end();
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
}
pub(super) fn goto_step_number(&mut self, step_number: usize) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
if !self.stepping {
return;
}
let total_steps = self.multi_diff.current_navigator().state().total_steps;
if total_steps == 0 {
return;
}
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
let clamped = step_number.max(1).min(total_steps);
let target_step = clamped.saturating_sub(1);
self.multi_diff.current_navigator().goto(target_step);
self.animation_phase = AnimationPhase::Idle;
self.animation_progress = 1.0;
self.centered_once = false;
self.needs_scroll_to_active = true;
self.refresh_blame_toggle_hint();
}
pub(super) fn goto_hunk_number(&mut self, hunk_number: usize) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
let total_hunks = self.multi_diff.current_navigator().state().total_hunks;
if total_hunks == 0 {
return;
}
let clamped = hunk_number.max(1).min(total_hunks);
let hunk_idx = clamped - 1;
if self.stepping {
self.goto_hunk_index(hunk_idx);
} else {
self.goto_hunk_index_scroll(hunk_idx);
}
}
pub(super) fn goto_hunk_index(&mut self, hunk_idx: usize) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.cancel_view_build_defer();
self.clear_peek();
self.clear_blame_step_hint();
self.clear_blame_hunk_hint();
self.multi_diff.current_navigator().goto_hunk(hunk_idx);
if self.animation_enabled {
self.start_animation();
} else {
self.clear_active_on_next_render = true;
}
self.needs_scroll_to_active = true;
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
pub fn goto_first_hunk_scroll(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
let total = self.multi_diff.current_navigator().state().total_hunks;
if total == 0 {
return;
}
self.goto_hunk_index_scroll(0);
}
pub fn goto_last_hunk_scroll(&mut self) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
let total = self.multi_diff.current_navigator().state().total_hunks;
if total == 0 {
return;
}
self.goto_hunk_index_scroll(total.saturating_sub(1));
}
pub(super) fn goto_hunk_index_scroll(&mut self, hunk_idx: usize) {
if !self.current_file_diff_ready() {
return;
}
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
self.clear_blame_hunk_hint();
let target = match self.view_mode {
ViewMode::Split => {
let (old_bounds, new_bounds) = self.compute_hunk_bounds_split();
let old = old_bounds.get(hunk_idx).copied().flatten();
let new = new_bounds.get(hunk_idx).copied().flatten();
self.pick_split_bounds(old, new).map(|b| (hunk_idx, b))
}
_ => {
let bounds = self.compute_hunk_bounds_unified();
bounds
.get(hunk_idx)
.copied()
.flatten()
.map(|b| (hunk_idx, b))
}
};
if let Some((hidx, bound)) = target {
self.scroll_offset = bound.start.idx;
self.centered_once = false;
self.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, bound.start.change_id);
self.multi_diff.current_navigator().set_hunk_scope(true);
if self.auto_center {
self.needs_scroll_to_active = true;
}
self.set_blame_hunk_hint();
self.refresh_blame_toggle_hint();
}
}
pub(super) fn goto_line_number(&mut self, line_number: usize) {
self.goto_line_number_for_side(line_number, None);
}
pub(crate) fn goto_line_on_side(&mut self, new_side: bool, line_number: usize) -> bool {
self.goto_line_number_for_side(line_number, Some(new_side))
}
fn goto_line_number_for_side(&mut self, line_number: usize, side: Option<bool>) -> bool {
if self.stepping && !self.current_file_diff_ready() {
return false;
}
if self.stepping {
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
}
self.clear_peek();
let view = self.current_view_with_frame(AnimationFrame::Idle);
let target_idx = match self.view_mode {
ViewMode::Split => {
let mut old_idx = 0usize;
let mut new_idx = 0usize;
let mut old_last = None;
let mut new_last = None;
let mut old_max_line = 0usize;
let mut new_max_line = 0usize;
let mut old_match = None;
let mut new_match = None;
for line in view.iter() {
let fold_line = is_fold_line(line);
if let Some(old_line) = line.old_line {
old_max_line = old_max_line.max(old_line);
if old_line == line_number {
old_match = Some(old_idx);
}
old_idx += 1;
old_last = Some(old_idx - 1);
} else if fold_line || (self.split_align_lines && line.new_line.is_some()) {
old_idx += 1;
old_last = Some(old_idx - 1);
}
if let Some(new_line) = line.new_line {
new_max_line = new_max_line.max(new_line);
if new_line == line_number {
new_match = Some(new_idx);
}
new_idx += 1;
new_last = Some(new_idx - 1);
} else if fold_line || (self.split_align_lines && line.old_line.is_some()) {
new_idx += 1;
new_last = Some(new_idx - 1);
}
}
if let Some(new_side) = side {
if new_side {
new_match
} else {
old_match
}
} else if line_number == 0 {
let first_old = if old_idx > 0 { Some(0) } else { None };
let first_new = if new_idx > 0 { Some(0) } else { None };
self.pick_split_index(first_old, first_new)
} else {
let max_line = old_max_line.max(new_max_line);
if max_line > 0 && line_number > max_line {
if old_max_line > new_max_line {
old_last
} else if new_max_line > old_max_line {
new_last
} else {
self.pick_split_index(old_last, new_last)
}
} else {
self.pick_split_index(old_match, new_match)
}
}
}
ViewMode::Evolution => {
let mut target = None;
let mut last_idx = None;
let mut max_line = 0usize;
for (display_idx, line) in view
.iter()
.filter(|line| {
!matches!(line.kind, LineKind::Deleted | LineKind::PendingDelete)
})
.enumerate()
{
let line_num = match side {
Some(true) => line.new_line,
Some(false) => line.old_line,
None => line.new_line.or(line.old_line),
};
if let Some(num) = line_num {
max_line = max_line.max(num);
}
if line_num == Some(line_number) {
target = Some(display_idx);
break;
}
last_idx = Some(display_idx);
}
if side.is_some() {
target
} else if line_number == 0 {
last_idx.map(|_| 0)
} else if max_line > 0 && line_number > max_line {
last_idx
} else {
target
}
}
_ => {
let mut target = None;
let mut last_idx = None;
let mut max_line = 0usize;
for (display_idx, line) in view.iter().enumerate() {
let line_num = match side {
Some(true) => line.new_line,
Some(false) => line.old_line,
None => line.old_line.or(line.new_line),
};
if let Some(num) = line_num {
max_line = max_line.max(num);
}
if line_num == Some(line_number) {
target = Some(display_idx);
break;
}
last_idx = Some(display_idx);
}
if side.is_some() {
target
} else if line_number == 0 {
last_idx.map(|_| 0)
} else if max_line > 0 && line_number > max_line {
last_idx
} else {
target
}
}
};
if let Some(idx) = target_idx {
let cursor_target = view.get(idx).map(|line| (line.hunk_index, line.change_id));
let viewport_height = self.last_viewport_height.max(1);
if self.auto_center {
let half_viewport = viewport_height / 2;
self.scroll_offset = idx.saturating_sub(half_viewport);
self.centered_once = true;
} else {
self.scroll_offset = idx;
self.centered_once = false;
}
self.needs_scroll_to_active = false;
self.multi_diff.current_navigator().set_hunk_scope(false);
if !self.stepping {
match cursor_target {
Some((Some(hidx), change_id)) => self
.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, Some(change_id)),
Some((None, change_id)) => self
.multi_diff
.current_navigator()
.set_cursor_change(Some(change_id)),
None => self.multi_diff.current_navigator().clear_cursor_change(),
}
}
true
} else {
false
}
}
pub(super) fn goto_review_grep_line(
&mut self,
side: super::review::ReviewSide,
line_number: usize,
) {
let diff_status = self.multi_diff.current_file_diff_status();
if self.stepping
&& !matches!(
diff_status,
oyo_core::multi::DiffStatus::Ready | oyo_core::multi::DiffStatus::Disabled
)
{
return;
}
if self.stepping && diff_status == oyo_core::multi::DiffStatus::Ready {
self.multi_diff
.ensure_full_navigator(self.multi_diff.selected_index);
}
if self.view_mode == ViewMode::Preview
|| (side == super::review::ReviewSide::Old && self.view_mode == ViewMode::Evolution)
{
self.view_mode = ViewMode::UnifiedPane;
self.preview_forced_by_content = false;
}
self.clear_peek();
self.expand_all_context_folds();
let view = self.current_view_with_frame(AnimationFrame::Idle);
let target = if self.view_mode == ViewMode::Split {
let mut pane_idx = 0usize;
let mut target = None;
for line in view.iter() {
let (line_number_for_side, other_side_has_line) = match side {
super::review::ReviewSide::Old => (line.old_line, line.new_line.is_some()),
super::review::ReviewSide::New => (line.new_line, line.old_line.is_some()),
};
if line_number_for_side == Some(line_number) {
target = Some((pane_idx, (line.hunk_index, line.change_id)));
break;
}
if line_number_for_side.is_some()
|| is_fold_line(line)
|| (self.split_align_lines && other_side_has_line)
{
pane_idx += 1;
}
}
target
} else {
view.iter().enumerate().find_map(|(idx, line)| {
let matches = match side {
super::review::ReviewSide::Old => line.old_line == Some(line_number),
super::review::ReviewSide::New => line.new_line == Some(line_number),
};
matches.then_some((idx, (line.hunk_index, line.change_id)))
})
};
let Some((idx, cursor_target)) = target else {
return;
};
let viewport_height = self.last_viewport_height.max(1);
if self.auto_center {
self.scroll_offset = idx.saturating_sub(viewport_height / 2);
self.centered_once = true;
} else {
self.scroll_offset = idx;
self.centered_once = false;
}
self.needs_scroll_to_active = false;
self.multi_diff.current_navigator().set_hunk_scope(false);
if !self.stepping {
match cursor_target {
(Some(hidx), change_id) => self
.multi_diff
.current_navigator()
.set_cursor_hunk(hidx, Some(change_id)),
(None, change_id) => self
.multi_diff
.current_navigator()
.set_cursor_change(Some(change_id)),
}
}
}
pub fn toggle_view_mode(&mut self) {
self.preview_forced_by_content = false;
let allow_blame = self.blame_enabled;
if !self.stepping {
self.view_mode = match self.view_mode {
ViewMode::UnifiedPane => ViewMode::Split,
ViewMode::Split => {
if allow_blame {
ViewMode::Blame
} else {
ViewMode::Preview
}
}
ViewMode::Blame => ViewMode::Preview,
ViewMode::Preview | ViewMode::Evolution => ViewMode::UnifiedPane,
};
} else if allow_blame {
self.view_mode = self.view_mode.next();
} else {
self.view_mode = match self.view_mode {
ViewMode::UnifiedPane => ViewMode::Split,
ViewMode::Split => ViewMode::Evolution,
ViewMode::Evolution => ViewMode::Preview,
ViewMode::Preview | ViewMode::Blame => ViewMode::UnifiedPane,
};
}
if self.stepping && self.view_mode == ViewMode::Evolution {
self.needs_scroll_to_active = true;
}
}
pub fn set_view_mode(&mut self, target: ViewMode) {
self.preview_forced_by_content = false;
if target == ViewMode::Blame && !self.blame_enabled {
return;
}
if target == ViewMode::Evolution && !self.stepping {
self.step_view_mode = ViewMode::Evolution;
self.toggle_stepping();
return;
}
if !self.stepping {
self.view_mode = match target {
ViewMode::Evolution => ViewMode::UnifiedPane,
other => other,
};
self.step_view_mode = self.view_mode;
return;
}
self.view_mode = target;
if self.stepping && self.view_mode == ViewMode::Evolution {
self.needs_scroll_to_active = true;
}
}
pub fn toggle_view_mode_reverse(&mut self) {
self.preview_forced_by_content = false;
let allow_blame = self.blame_enabled;
if !self.stepping {
self.view_mode = match self.view_mode {
ViewMode::UnifiedPane => ViewMode::Preview,
ViewMode::Preview => {
if allow_blame {
ViewMode::Blame
} else {
ViewMode::Split
}
}
ViewMode::Split => ViewMode::UnifiedPane,
ViewMode::Blame => ViewMode::Split,
ViewMode::Evolution => ViewMode::UnifiedPane,
};
} else if allow_blame {
self.view_mode = self.view_mode.prev();
} else {
self.view_mode = match self.view_mode {
ViewMode::UnifiedPane => ViewMode::Preview,
ViewMode::Preview => ViewMode::Evolution,
ViewMode::Split => ViewMode::UnifiedPane,
ViewMode::Evolution => ViewMode::Split,
ViewMode::Blame => ViewMode::UnifiedPane,
};
}
if self.stepping && self.view_mode == ViewMode::Evolution {
self.needs_scroll_to_active = true;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_utils::DiffSettingsGuard;
use crate::ViewMode;
use oyo_core::MultiFileDiff;
fn make_app_with_conflict_markers() -> App {
let old = "line1\nline2\nline3\n".to_string();
let new = "line1\n<<<<<<< ours\nfoo\n=======\nbar\n>>>>>>> theirs\nline3\n".to_string();
let multi_diff = MultiFileDiff::from_file_pair(
std::path::PathBuf::from("a.txt"),
std::path::PathBuf::from("a.txt"),
old,
new,
);
App::new(multi_diff, ViewMode::UnifiedPane, 0, false, None)
}
#[test]
fn test_conflict_navigation_steps_between_markers() {
let _guard = DiffSettingsGuard::default();
let mut app = make_app_with_conflict_markers();
let steps = app.collect_conflict_steps();
assert!(
steps.len() >= 3,
"Expected at least 3 conflict marker steps"
);
app.next_conflict();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.current_step, steps[0]);
app.next_conflict();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.current_step, steps[1]);
app.prev_conflict();
let state = app.multi_diff.current_navigator().state();
assert_eq!(state.current_step, steps[0]);
}
#[test]
fn yanked_insertions_and_deletions_use_the_preceding_line_for_empty_sides() {
let _guard = DiffSettingsGuard::default();
for (old, new, expected_header) in [
("a\nb\nc\nd\ne\n", "a\nb\nc\nX\nd\ne\n", "@@ -3,0 +4,1 @@"),
("a\nb\nc\nX\nd\ne\n", "a\nb\nc\nd\ne\n", "@@ -4,1 +3,0 @@"),
] {
let mut app = App::new(
MultiFileDiff::from_file_pair(
"a.txt".into(),
"a.txt".into(),
old.to_string(),
new.to_string(),
),
ViewMode::UnifiedPane,
0,
false,
None,
);
let patch = app.patch_for_hunk(None).expect("hunk patch");
assert!(patch.contains(expected_header), "patch was:\n{patch}");
}
}
#[test]
fn yanked_insertion_accounts_for_line_changes_in_earlier_hunks() {
let _guard = DiffSettingsGuard::default();
let old = "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n";
let new = "one\nfour\nfive\nsix\nseven\ninserted\neight\n";
let mut app = App::new(
MultiFileDiff::from_file_pair(
"a.txt".into(),
"a.txt".into(),
old.to_string(),
new.to_string(),
),
ViewMode::UnifiedPane,
0,
false,
None,
);
let insertion_id = app
.multi_diff
.current_navigator()
.diff()
.changes
.iter()
.find(|change| {
change
.spans
.iter()
.any(|span| span.kind == ChangeKind::Insert)
})
.expect("insertion change")
.id;
let patch = app
.patch_for_hunk(Some(insertion_id))
.expect("insertion patch");
assert!(patch.contains("@@ -7,0 +6,1 @@"), "patch was:\n{patch}");
}
#[test]
fn yanked_insertion_ignores_token_count_in_earlier_modification() {
let _guard = DiffSettingsGuard::default();
let old = "foo(a, b)\ntwo\nthree\nfour\nfive\nsix\nseven\neight\n";
let new = "foo(c)\ntwo\nthree\nfour\nfive\nsix\nseven\ninserted\neight\n";
let mut app = App::new(
MultiFileDiff::from_file_pair(
"a.txt".into(),
"a.txt".into(),
old.to_string(),
new.to_string(),
),
ViewMode::UnifiedPane,
0,
false,
None,
);
let insertion_id = app
.multi_diff
.current_navigator()
.diff()
.changes
.iter()
.find(|change| {
change.spans.iter().any(|span| span.new_line == Some(8))
&& change.spans.iter().all(|span| span.old_line.is_none())
})
.expect("insertion change")
.id;
let patch = app
.patch_for_hunk(Some(insertion_id))
.expect("insertion patch");
assert!(patch.contains("@@ -7,0 +8,1 @@"), "patch was:\n{patch}");
}
}