fix: skip omitted diff lines instead of expanding excerpts during navigation
This commit is contained in:
+31
-35
@@ -629,28 +629,24 @@ impl DiffState {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reveal_after(&mut self, caret_row: usize) {
|
||||
let Some(index) = self
|
||||
.rows
|
||||
pub(crate) fn after_rows(&self) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
self.excerpt_ranges
|
||||
.iter()
|
||||
.position(|row| row.after == Some(caret_row))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if self.after_display_row(caret_row).is_some() {
|
||||
return;
|
||||
.flat_map(|&(start, end)| self.rows[start..end].iter().filter_map(|row| row.after))
|
||||
}
|
||||
|
||||
pub(crate) fn clamp_after_row(&self, row: usize) -> Option<usize> {
|
||||
let mut previous = None;
|
||||
for visible in self.after_rows() {
|
||||
if visible >= row {
|
||||
return Some(match previous {
|
||||
Some(previous) if row - previous < visible - row => previous,
|
||||
_ => visible,
|
||||
});
|
||||
}
|
||||
previous = Some(visible);
|
||||
}
|
||||
let anchor = self.display_rows.get(self.top).copied();
|
||||
self.excerpt_ranges.push((
|
||||
index.saturating_sub(self.context_lines),
|
||||
index
|
||||
.saturating_add(self.context_lines)
|
||||
.saturating_add(1)
|
||||
.min(self.rows.len()),
|
||||
));
|
||||
self.excerpt_ranges.sort_unstable();
|
||||
self.rebuild_display_rows();
|
||||
self.restore_top(anchor);
|
||||
previous
|
||||
}
|
||||
|
||||
fn restore_top(&mut self, anchor: Option<DisplayRow>) {
|
||||
@@ -670,12 +666,11 @@ impl DiffState {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64, caret_row: usize) {
|
||||
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
||||
if !self.after_editable() {
|
||||
return;
|
||||
}
|
||||
if self.live_revision == Some(revision) {
|
||||
self.reveal_after(caret_row);
|
||||
return;
|
||||
}
|
||||
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
||||
@@ -761,7 +756,6 @@ impl DiffState {
|
||||
{
|
||||
self.restore_top(Some(DisplayRow::Line(index)));
|
||||
}
|
||||
self.reveal_after(caret_row);
|
||||
if was_changed != changed {
|
||||
self.refresh_files();
|
||||
}
|
||||
@@ -2039,7 +2033,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_reflow_keeps_separate_hunks_expansion_and_caret_after_revert() {
|
||||
fn live_reflow_keeps_separate_hunks_and_expanded_context_after_revert() {
|
||||
let base = (0..100)
|
||||
.map(|row| format!("line {row}"))
|
||||
.collect::<Vec<_>>();
|
||||
@@ -2049,7 +2043,7 @@ mod tests {
|
||||
let mut state = live_state(base.clone(), changed.clone(), true);
|
||||
state.set_context_options(2, 5);
|
||||
changed.push(String::new());
|
||||
state.refresh_after(&changed, 1, 30);
|
||||
state.refresh_after(&changed, 1);
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2);
|
||||
assert!(
|
||||
state
|
||||
@@ -2061,7 +2055,7 @@ mod tests {
|
||||
state.handle(Input::Char('['));
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
changed.insert(10, "inserted outside excerpts".into());
|
||||
state.refresh_after(&changed, 2, 31);
|
||||
state.refresh_after(&changed, 2);
|
||||
assert!(state.after_display_row(24).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3);
|
||||
assert!(
|
||||
@@ -2071,14 +2065,15 @@ mod tests {
|
||||
);
|
||||
let mut reverted = base;
|
||||
reverted.push(String::new());
|
||||
state.refresh_after(&reverted, 3, 90);
|
||||
state.refresh_after(&reverted, 3);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(90).is_some());
|
||||
assert!(state.after_display_row(23).is_some());
|
||||
assert!(state.after_display_row(90).is_none());
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
||||
assert_eq!(state.file, 0);
|
||||
reverted[50] = "changed again".into();
|
||||
state.refresh_after(&reverted, 4, 50);
|
||||
state.refresh_after(&reverted, 4);
|
||||
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
||||
assert!(state.after_display_row(50).is_some());
|
||||
}
|
||||
@@ -2086,12 +2081,13 @@ mod tests {
|
||||
#[test]
|
||||
fn live_empty_and_eof_rows_match_buffer_without_false_eof_highlights() {
|
||||
let mut state = live_state(vec!["same".into()], vec!["changed".into()], true);
|
||||
state.refresh_after(&["same".into(), "".into()], 1, 1);
|
||||
state.refresh_after(&["same".into(), "".into()], 1);
|
||||
assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(state.rows.len(), 2);
|
||||
assert!(state.rows.iter().all(|row| row.kind == RowKind::Equal));
|
||||
assert!(state.after_display_row(1).is_some());
|
||||
state.refresh_after(&["same".into()], 2, 0);
|
||||
assert!(state.after_display_row(0).is_some());
|
||||
assert!(state.after_display_row(1).is_none());
|
||||
state.refresh_after(&["same".into()], 2);
|
||||
assert_eq!(state.rows[0].kind, RowKind::Changed);
|
||||
assert!(
|
||||
state.comparison.as_ref().unwrap().files[0]
|
||||
@@ -2100,17 +2096,17 @@ mod tests {
|
||||
.unwrap()
|
||||
.contains("After missing")
|
||||
);
|
||||
state.refresh_after(&["".into()], 3, 0);
|
||||
state.refresh_after(&["".into()], 3);
|
||||
assert!(state.after_display_row(0).is_some());
|
||||
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
let mut empty = live_state(vec![], vec!["text".into()], false);
|
||||
empty.refresh_after(&["".into()], 1, 0);
|
||||
empty.refresh_after(&["".into()], 1);
|
||||
assert!(empty.comparison.as_ref().unwrap().files[0].hunks.is_empty());
|
||||
assert_eq!(empty.rows.len(), 1);
|
||||
assert_eq!(empty.rows[0].kind, RowKind::Equal);
|
||||
assert_eq!(empty.after_display_row(0), Some(0));
|
||||
empty.after.as_mut().unwrap().target = RevisionTarget::Commit("historic".into());
|
||||
empty.refresh_after(&["must not apply".into()], 2, 0);
|
||||
empty.refresh_after(&["must not apply".into()], 2);
|
||||
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
|
||||
empty.handle(Input::Escape);
|
||||
assert_eq!(empty.phase, Phase::View);
|
||||
|
||||
+129
-43
@@ -1420,16 +1420,33 @@ impl Editor {
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_live_diff(&mut self) {
|
||||
let row = self.cursor().row;
|
||||
let Some(diff) = &mut self.diff else { return };
|
||||
if diff.live_buffer != Some(self.active.id) || !diff.after_editable() {
|
||||
return;
|
||||
}
|
||||
if diff.live_revision != Some(self.active.text_revision) {
|
||||
diff.refresh_after(&self.active.lines, self.active.text_revision, row);
|
||||
diff.refresh_after(&self.active.lines, self.active.text_revision);
|
||||
diff.follow_caret = true;
|
||||
}
|
||||
diff.reveal_after(row);
|
||||
let mut moved = false;
|
||||
for index in 0..self.active.targets.len() {
|
||||
let cursor = self.active.targets[index].active;
|
||||
let Some(row) = self.diff.as_ref().unwrap().clamp_after_row(cursor.row) else {
|
||||
continue;
|
||||
};
|
||||
if row != cursor.row {
|
||||
self.active.targets[index].active = Cursor {
|
||||
row,
|
||||
column: cursor.column.min(self.max_column(row)),
|
||||
};
|
||||
self.active.targets[index].preferred_columns = None;
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
self.active.search_match = None;
|
||||
self.normalize_targets();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scroll_diff(&mut self, focus: Focus, down: bool) {
|
||||
@@ -3805,11 +3822,9 @@ impl Editor {
|
||||
let query = self.active.search_query.as_str();
|
||||
// ponytail: O(buffer graphemes × query bytes) scan; index matches if large buffers need it.
|
||||
let matches: Vec<_> = self
|
||||
.active
|
||||
.lines
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(row, line)| {
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
.flat_map(|row| {
|
||||
let line = &self.active.lines[row];
|
||||
line.grapheme_indices(true).filter_map(move |(byte, _)| {
|
||||
(line[byte..].starts_with(query)
|
||||
&& GraphemeCursor::new(byte + query.len(), line.len(), true)
|
||||
@@ -4749,10 +4764,10 @@ impl Editor {
|
||||
let target = self.active.targets[index];
|
||||
let shift = |row: usize| {
|
||||
if down {
|
||||
row.checked_add(1)
|
||||
.filter(|row| *row < self.active.lines.len())
|
||||
self.navigable_rows(row.saturating_add(1), self.active.lines.len())
|
||||
.next()
|
||||
} else {
|
||||
row.checked_sub(1)
|
||||
self.navigable_rows(0, row).next_back()
|
||||
}
|
||||
};
|
||||
let Some(anchor_row) = shift(target.anchor.row) else {
|
||||
@@ -4831,14 +4846,28 @@ impl Editor {
|
||||
})
|
||||
.collect();
|
||||
// ponytail: literal scan is O(buffer graphemes × phrase bytes); index only for large buffers.
|
||||
let candidates: Vec<_> = text
|
||||
.grapheme_indices(true)
|
||||
.filter_map(|(byte, _)| {
|
||||
let diff = self.diff.as_ref().filter(|_| self.diff_editable());
|
||||
let candidates: Vec<_> = self
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
.flat_map(|row| {
|
||||
let offset = offsets[row];
|
||||
let line = &self.active.lines[row];
|
||||
line.grapheme_indices(true)
|
||||
.map(move |(byte, _)| offset + byte)
|
||||
.chain(std::iter::once(offset + line.len()))
|
||||
})
|
||||
.filter_map(|byte| {
|
||||
let end = byte + phrase.len();
|
||||
(text[byte..].starts_with(phrase)
|
||||
&& GraphemeCursor::new(end, text.len(), true)
|
||||
.is_boundary(&text, 0)
|
||||
.expect("complete buffer supplies grapheme context"))
|
||||
.expect("complete buffer supplies grapheme context")
|
||||
&& diff.is_none_or(|diff| {
|
||||
let last = offsets
|
||||
.partition_point(|offset| *offset < end)
|
||||
.saturating_sub(1);
|
||||
diff.after_display_row(last).is_some()
|
||||
}))
|
||||
.then_some((byte, end))
|
||||
})
|
||||
.collect();
|
||||
@@ -5784,6 +5813,19 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
|
||||
fn navigable_rows(
|
||||
&self,
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> impl DoubleEndedIterator<Item = usize> + '_ {
|
||||
let diff = self.diff.as_ref().filter(|_| self.diff_editable());
|
||||
(start..if diff.is_none() { end } else { start }).chain(
|
||||
diff.into_iter()
|
||||
.flat_map(DiffState::after_rows)
|
||||
.filter(move |row| start <= *row && *row < end),
|
||||
)
|
||||
}
|
||||
|
||||
fn move_targets(&mut self, action: Action, count: usize) {
|
||||
self.reveal_caret();
|
||||
for index in 0..self.active.targets.len() {
|
||||
@@ -5797,30 +5839,54 @@ impl Editor {
|
||||
.min(self.max_column(cursor.row))
|
||||
}
|
||||
Action::Down | Action::Up => {
|
||||
let row = if matches!(action, Action::Down) {
|
||||
cursor
|
||||
.row
|
||||
.saturating_add(count)
|
||||
.min(self.active.lines.len() - 1)
|
||||
if self.diff_editable() {
|
||||
let step = |cursor: Cursor, row| Cursor {
|
||||
row,
|
||||
column: cursor.column.min(self.max_column(row)),
|
||||
};
|
||||
cursor = if action == Action::Down {
|
||||
self.navigable_rows(
|
||||
cursor.row.saturating_add(1),
|
||||
self.active.lines.len(),
|
||||
)
|
||||
.take(count)
|
||||
.fold(cursor, step)
|
||||
} else {
|
||||
self.navigable_rows(0, cursor.row)
|
||||
.rev()
|
||||
.take(count)
|
||||
.fold(cursor, step)
|
||||
};
|
||||
} else {
|
||||
cursor.row.saturating_sub(count)
|
||||
};
|
||||
let first = cursor.row.min(row);
|
||||
let last = cursor.row.max(row);
|
||||
cursor.column = (first..=last)
|
||||
.map(|row| self.max_column(row))
|
||||
.min()
|
||||
.unwrap_or(0)
|
||||
.min(cursor.column);
|
||||
cursor.row = row;
|
||||
let row = if matches!(action, Action::Down) {
|
||||
cursor
|
||||
.row
|
||||
.saturating_add(count)
|
||||
.min(self.active.lines.len() - 1)
|
||||
} else {
|
||||
cursor.row.saturating_sub(count)
|
||||
};
|
||||
let first = cursor.row.min(row);
|
||||
let last = cursor.row.max(row);
|
||||
cursor.column = (first..=last)
|
||||
.map(|row| self.max_column(row))
|
||||
.min()
|
||||
.unwrap_or(0)
|
||||
.min(cursor.column);
|
||||
cursor.row = row;
|
||||
}
|
||||
}
|
||||
Action::LineStart => cursor.column = 0,
|
||||
Action::LineEnd => cursor.column = self.max_column(cursor.row),
|
||||
Action::DocumentStart | Action::DocumentEnd => {
|
||||
cursor.row = if action == Action::DocumentStart {
|
||||
0
|
||||
self.navigable_rows(0, self.active.lines.len())
|
||||
.next()
|
||||
.unwrap_or(cursor.row)
|
||||
} else {
|
||||
self.active.lines.len() - 1
|
||||
self.navigable_rows(0, self.active.lines.len())
|
||||
.next_back()
|
||||
.unwrap_or(cursor.row)
|
||||
};
|
||||
cursor.column = first_nonblank(&self.active.lines[cursor.row]);
|
||||
}
|
||||
@@ -5838,17 +5904,34 @@ impl Editor {
|
||||
action,
|
||||
Action::BigWordNext | Action::BigWordPrevious | Action::BigWordEnd
|
||||
);
|
||||
let backward = matches!(action, Action::WordPrevious | Action::BigWordPrevious);
|
||||
let start = if backward { 0 } else { row };
|
||||
let end = if backward {
|
||||
row + 1
|
||||
} else {
|
||||
self.active.lines.len()
|
||||
};
|
||||
let tokens = self.navigable_rows(start, end).flat_map(move |source_row| {
|
||||
word_tokens(std::slice::from_ref(&self.active.lines[source_row]), big)
|
||||
.map(move |(_, byte, kind)| (source_row, byte, kind))
|
||||
});
|
||||
let destination = match action {
|
||||
Action::WordPrevious | Action::BigWordPrevious => {
|
||||
let mut tokens = word_tokens(&self.active.lines[..=row], big)
|
||||
let mut tokens = tokens
|
||||
.rev()
|
||||
.skip_while(|(r, b, _)| (*r, *b) >= (row, byte))
|
||||
.peekable();
|
||||
let mut destination = (0, 0);
|
||||
let fallback = (
|
||||
self.navigable_rows(0, self.active.lines.len())
|
||||
.next()
|
||||
.unwrap_or(row),
|
||||
0,
|
||||
);
|
||||
let mut destination = fallback;
|
||||
for _ in 0..count {
|
||||
let Some(mut first) = tokens.find(|(_, _, kind)| *kind != WordKind::Whitespace)
|
||||
else {
|
||||
destination = (0, 0);
|
||||
destination = fallback;
|
||||
break;
|
||||
};
|
||||
if first.2 != WordKind::EmptyLine {
|
||||
@@ -5861,14 +5944,17 @@ impl Editor {
|
||||
destination
|
||||
}
|
||||
_ => {
|
||||
let mut tokens = word_tokens(&self.active.lines[row..], big)
|
||||
.skip_while(|(r, b, _)| (*r, *b) < (0, byte))
|
||||
let mut tokens = tokens
|
||||
.skip_while(|(r, b, _)| (*r, *b) < (row, byte))
|
||||
.peekable();
|
||||
let mut current = tokens
|
||||
.next()
|
||||
.expect("cursor has a grapheme or line-end token")
|
||||
.2;
|
||||
let last = self.active.lines.len() - 1;
|
||||
let Some(first) = tokens.next() else {
|
||||
return cursor;
|
||||
};
|
||||
let mut current = first.2;
|
||||
let last = self
|
||||
.navigable_rows(0, self.active.lines.len())
|
||||
.next_back()
|
||||
.unwrap_or(row);
|
||||
let mut destination = (last, self.active.lines[last].len());
|
||||
for _ in 0..count {
|
||||
let next_start = matches!(action, Action::WordNext | Action::BigWordNext);
|
||||
@@ -5890,7 +5976,7 @@ impl Editor {
|
||||
first = tokens.next().expect("peeked token");
|
||||
}
|
||||
}
|
||||
destination = (row + first.0, first.1);
|
||||
destination = (first.0, first.1);
|
||||
}
|
||||
destination
|
||||
}
|
||||
|
||||
+2
-1
@@ -44,7 +44,8 @@ Buffers: :edit PATH, :buffers, :buffer N, :buffer_next, :buffer_previous, :buffe
|
||||
Markdown links: :follow_link (primary caret; local files only)
|
||||
Diff: :diff (Normal); choose Before then After; Working checkout After edits live unsaved buffers
|
||||
Before and historical/remote After stay read-only; Tab/Shift+Tab categories/Normal focus
|
||||
Type to search IDs/refs; Normal [/] expands excerpts; Insert brackets remain literal text
|
||||
Type to search IDs/refs; navigation skips omitted lines without expanding excerpts
|
||||
Normal [/] explicitly expands context; Insert brackets remain literal text
|
||||
:diff.close exits explicitly; Esc cancels modes/prompts/filtering without closing the diff
|
||||
Remote refs/history load into temporary comparison repos without changing active refs/checkout
|
||||
Changed files: normal picker/explorer bindings, Ctrl+w close; A/M/D/R status and rename paths
|
||||
|
||||
Reference in New Issue
Block a user