feat: edit working-checkout comparisons with normal editor actions

This commit is contained in:
2026-10-11 02:13:16 +02:00
parent 704dce2539
commit 9a34669c08
16 changed files with 2277 additions and 647 deletions
+529 -150
View File
@@ -1,6 +1,9 @@
use crate::editor::Input;
use crate::files::{FilePicker, FileView};
use std::path::PathBuf;
use crate::files::{FilePicker, FilePresentation, FileView};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
@@ -77,6 +80,8 @@ pub struct ChangedFile {
pub status: FileStatus,
pub before: Vec<String>,
pub after: Vec<String>,
pub before_eof: bool,
pub after_eof: bool,
pub hunks: Vec<Hunk>,
pub notice: Option<String>,
}
@@ -85,12 +90,18 @@ pub struct Comparison {
pub before: String,
pub after: String,
pub files: Vec<ChangedFile>,
pub root: PathBuf,
pub editable_paths: BTreeSet<PathBuf>,
}
#[derive(Debug)]
pub enum Operation {
Discover,
FetchRemote(String),
Compare { before: Revision, after: Revision },
Compare {
before: Revision,
after: Revision,
buffers: Box<[(PathBuf, String)]>,
},
}
#[derive(Debug)]
pub enum Outcome {
@@ -164,6 +175,10 @@ pub struct DiffState {
pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
excerpt_ranges: Vec<(usize, usize)>,
pub live_buffer: Option<usize>,
pub live_revision: Option<u64>,
pub follow_caret: bool,
live_files: BTreeSet<usize>,
context_lines: usize,
expand_lines: usize,
pending: Option<Operation>,
@@ -199,6 +214,10 @@ impl DiffState {
rows: Vec::new(),
display_rows: Vec::new(),
excerpt_ranges: Vec::new(),
live_buffer: None,
live_revision: None,
follow_caret: false,
live_files: BTreeSet::new(),
context_lines: 10,
expand_lines: 15,
pending: Some(Operation::Discover),
@@ -261,9 +280,17 @@ impl DiffState {
self.error = None;
}
Ok(Outcome::Comparison(comparison)) if expected == Some(RequestKind::Comparison) => {
self.live_files.clear();
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
let mut files = FilePicker::from_paths(
PathBuf::new(),
comparison.files.iter().map(|file| file.path.clone()),
comparison.root.clone(),
comparison
.files
.iter()
.enumerate()
.map(|(index, file)| (index, file.path.clone())),
);
if let Some(file) = comparison.files.first() {
files.reveal_path(&file.path);
@@ -351,8 +378,8 @@ impl DiffState {
}
}
/// Returns true only when the modal should close. All other inputs are consumed.
pub(crate) fn handle(&mut self, input: Input) -> bool {
/// Handles modal input; closing is an explicit editor action.
pub(crate) fn handle(&mut self, input: Input) {
if input == Input::Escape {
if self.phase == Phase::View
&& self.focus == Focus::Files
@@ -361,28 +388,40 @@ impl DiffState {
&& files.view == FileView::Tree
{
files.finish_search();
return false;
return;
}
if self.phase != Phase::After {
return true;
if self.phase == Phase::View {
if self.focus == Focus::Files
&& let Some(files) = &mut self.files
{
files.focused = false;
self.files_visible = files.presentation == FilePresentation::Docked;
self.focus = Focus::Before;
}
return;
}
self.pending = None;
self.request_kind = None;
self.loading = false;
self.error = None;
if self.phase == Phase::Before {
self.query.clear();
self.refilter();
return;
}
self.phase = Phase::Before;
self.after = None;
self.error = None;
if let Some(position) = self.before_position.take() {
self.category = position.category;
self.query = position.query;
self.refilter();
self.select_target(&position.selected);
}
return false;
return;
}
if self.phase == Phase::View {
self.handle_view(input);
return false;
return;
}
match input {
Input::Char('\t') | Input::BackTab => {
@@ -417,7 +456,6 @@ impl DiffState {
}
_ => {}
}
false
}
fn choose(&mut self) {
@@ -448,23 +486,12 @@ impl DiffState {
self.queue(Operation::Compare {
before: before.clone(),
after: revision,
buffers: Box::new([]),
});
}
}
fn handle_view(&mut self, input: Input) {
if input == Input::Ctrl('e') {
self.files_visible = !self.files_visible;
if self.files_visible {
self.focus = Focus::Files;
} else if self.focus == Focus::Files {
self.focus = Focus::Before;
}
if let Some(files) = &mut self.files {
files.focused = self.files_visible && self.focus == Focus::Files;
}
return;
}
if self.focus == Focus::Files {
let files = self.files.as_mut().expect("comparison file picker");
match input {
@@ -500,14 +527,12 @@ impl DiffState {
}
Input::Enter => {
if let Some(path) = files.accept()
&& let Some(index) = self.comparison.as_ref().and_then(|comparison| {
comparison.files.iter().position(|file| file.path == path)
})
&& self.select_file(&path)
{
self.file = index;
self.focus = Focus::Before;
self.files.as_mut().expect("comparison picker").focused = false;
self.align_file();
let files = self.files.as_mut().expect("comparison picker");
files.focused = false;
self.files_visible = files.presentation == FilePresentation::Docked;
}
return;
}
@@ -566,14 +591,213 @@ impl DiffState {
}
}
pub fn after_editable(&self) -> bool {
self.phase == Phase::View
&& self
.after
.as_ref()
.is_some_and(|revision| revision.target == RevisionTarget::Working)
&& self.comparison.as_ref().is_some_and(|comparison| {
comparison
.files
.get(self.file)
.is_some_and(|file| comparison.editable_paths.contains(&file.path))
})
}
pub(crate) fn select_file(&mut self, path: &Path) -> bool {
let Some(index) = self.comparison.as_ref().and_then(|comparison| {
let relative = path.strip_prefix(&comparison.root).unwrap_or(path);
comparison
.files
.iter()
.position(|file| file.path == relative)
}) else {
return false;
};
self.file = index;
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
self.align_file();
true
}
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
self.display_rows.iter().position(|display| {
matches!(*display, DisplayRow::Line(index) if self.rows[index].after == Some(source_row))
})
}
pub fn reveal_after(&mut self, caret_row: usize) {
let Some(index) = self
.rows
.iter()
.position(|row| row.after == Some(caret_row))
else {
return;
};
if self.after_display_row(caret_row).is_some() {
return;
}
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);
}
fn restore_top(&mut self, anchor: Option<DisplayRow>) {
if let Some(anchor) = anchor {
let index = match anchor {
DisplayRow::Line(index) => index,
DisplayRow::Fold { start, .. } => start,
};
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(row) => row == index,
DisplayRow::Fold { start, end } => start <= index && index < end,
})
.unwrap_or(0);
}
}
pub fn refresh_after(&mut self, lines: &[String], revision: u64, caret_row: usize) {
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];
let was_live = self.live_files.contains(&self.file);
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
if !was_live && (file.after_eof || old.is_empty()) {
old.push("");
}
let new = lines.iter().map(String::as_str).collect::<Vec<_>>();
let ops = similar::capture_diff_slices(similar::Algorithm::Myers, &old, &new);
let mut mapping = vec![new.len().saturating_sub(1); old.len()];
for op in &ops {
let (_, old_range, new_range) = op.as_tag_tuple();
for offset in 0..old_range.len() {
mapping[old_range.start + offset] = (new_range.start
+ offset.min(new_range.len().saturating_sub(1)))
.min(new.len().saturating_sub(1));
}
}
let map_after = |index: usize| mapping.get(index).copied().unwrap_or(0);
let mut before_visible = BTreeSet::new();
let mut after_visible = BTreeSet::new();
for &(start, end) in &self.excerpt_ranges {
for row in &self.rows[start..end] {
before_visible.extend(row.before);
after_visible.extend(row.after.map(map_after));
}
}
let top_before = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].before,
DisplayRow::Fold { start, .. } => self.rows[start].before,
});
let top_after = self
.display_rows
.get(self.top)
.and_then(|display| match *display {
DisplayRow::Line(index) => self.rows[index].after,
DisplayRow::Fold { start, .. } => self.rows[start].after,
})
.map(map_after);
let was_changed = file_is_changed(file);
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
file.after.resize_with(lines.len(), String::new);
for (target, line) in file.after.iter_mut().zip(lines) {
target.clone_from(line);
}
file.after_eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
file.hunks = line_hunks(
&file.before,
file.before_eof,
live_content(lines, file.after_eof),
file.after_eof,
);
update_eof_notice(file);
let changed = file_is_changed(file);
self.live_files.insert(self.file);
self.live_revision = Some(revision);
let left_before = self.left_before;
let left_after = self.left_after;
self.rebuild_file(false);
self.left_before = left_before.min(self.max_before);
self.left_after = left_after.min(self.max_after);
for (index, row) in self.rows.iter().enumerate() {
if row
.before
.is_some_and(|index| before_visible.contains(&index))
|| row
.after
.is_some_and(|index| after_visible.contains(&index))
{
self.excerpt_ranges.push((index, index + 1));
}
}
self.excerpt_ranges.sort_unstable();
self.rebuild_display_rows();
if let Some(row) = top_after {
self.top = self.after_display_row(row).unwrap_or(0);
} else if let Some(source) = top_before
&& let Some(index) = self.rows.iter().position(|row| row.before == Some(source))
{
self.restore_top(Some(DisplayRow::Line(index)));
}
self.reveal_after(caret_row);
if was_changed != changed {
self.refresh_files();
}
}
fn refresh_files(&mut self) {
let Some(comparison) = &self.comparison else {
return;
};
let paths = comparison
.files
.iter()
.enumerate()
.filter(|(_, file)| file_is_changed(file))
.map(|(index, file)| (index, file.path.clone()));
if let Some(files) = &mut self.files {
files.replace_paths(paths);
} else {
self.files = Some(FilePicker::from_paths(comparison.root.clone(), paths));
}
}
fn align_file(&mut self) {
self.rebuild_file(true);
}
fn rebuild_file(&mut self, reset: bool) {
self.rows.clear();
self.display_rows.clear();
self.excerpt_ranges.clear();
self.top = 0;
self.left_before = 0;
self.left_after = 0;
self.max_before = 0;
if reset {
self.top = 0;
self.left_before = 0;
self.left_after = 0;
self.max_before = 0;
}
self.max_after = 0;
let Some(file) = self
.comparison
@@ -598,15 +822,22 @@ impl DiffState {
.max()
.unwrap_or(0)
};
self.max_before = width(&file.before);
if reset {
self.max_before = width(&file.before);
}
self.max_after = width(&file.after);
let after_len = if self.live_files.contains(&self.file) {
live_content(&file.after, file.after_eof).len()
} else {
file.after.len()
};
self.rows.reserve(file.before.len().max(file.after.len()));
let mut before = 0;
let mut after = 0;
for hunk in &file.hunks {
// Clamp invalid ranges rather than indexing historical text unchecked.
let start_before = hunk.before_start.min(file.before.len()).max(before);
let start_after = hunk.after_start.min(file.after.len()).max(after);
let start_after = hunk.after_start.min(after_len).max(after);
append_rows(
&mut self.rows,
before,
@@ -620,7 +851,7 @@ impl DiffState {
let end_before = before
.saturating_add(hunk.before_len)
.min(file.before.len());
let end_after = after.saturating_add(hunk.after_len).min(file.after.len());
let end_after = after.saturating_add(hunk.after_len).min(after_len);
append_rows(&mut self.rows, before, end_before, after, end_after, true);
before = end_before;
after = end_after;
@@ -630,9 +861,16 @@ impl DiffState {
before,
file.before.len(),
after,
file.after.len(),
after_len,
false,
);
if after_len < file.after.len() {
self.rows.push(AlignedRow {
before: None,
after: Some(after_len),
kind: RowKind::Equal,
});
}
self.reset_excerpts();
}
@@ -781,6 +1019,89 @@ fn append_rows(
}
}
fn live_content(lines: &[String], eof: bool) -> &[String] {
if eof || (lines.len() == 1 && lines[0].is_empty()) {
&lines[..lines.len().saturating_sub(1)]
} else {
lines
}
}
pub(crate) fn line_hunks(
before: &[String],
before_eof: bool,
after: &[String],
after_eof: bool,
) -> Vec<Hunk> {
fn tokens(lines: &[String], eof: bool) -> Vec<(&str, bool)> {
lines
.iter()
.enumerate()
.map(|(index, line)| (line.as_str(), index + 1 < lines.len() || eof))
.collect()
}
let before = tokens(before, before_eof);
let after = tokens(after, after_eof);
similar::capture_diff_slices(similar::Algorithm::Myers, &before, &after)
.into_iter()
.filter_map(|op| {
let (tag, before, after) = op.as_tag_tuple();
(tag != similar::DiffTag::Equal).then_some(Hunk {
before_start: before.start,
before_len: before.len(),
after_start: after.start,
after_len: after.len(),
})
})
.collect()
}
fn file_is_changed(file: &ChangedFile) -> bool {
file.status != FileStatus::Modified
|| !file.hunks.is_empty()
|| file.notice.as_ref().is_some_and(|notice| {
notice
.split("; ")
.any(|part| !part.starts_with("EOF newline:"))
})
}
fn update_eof_notice(file: &mut ChangedFile) {
let mut notices = file
.notice
.as_deref()
.unwrap_or("")
.split("; ")
.filter(|part| !part.is_empty() && !part.starts_with("EOF newline:"))
.map(str::to_owned)
.collect::<Vec<_>>();
let before_empty = file.before.is_empty();
let after_empty = live_content(&file.after, file.after_eof).is_empty();
if (!before_empty && !file.before_eof) || (!after_empty && !file.after_eof) {
let label = |absent, empty, eof| {
if absent {
"absent"
} else if empty {
"empty file"
} else if eof {
"present"
} else {
"missing"
}
};
notices.push(format!(
"EOF newline: Before {}, After {}",
label(
file.status == FileStatus::Added,
before_empty,
file.before_eof
),
label(false, after_empty, file.after_eof)
));
}
file.notice = (!notices.is_empty()).then(|| notices.join("; "));
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1079,7 +1400,7 @@ mod tests {
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
match operation {
Operation::Compare { before, after } => {
Operation::Compare { before, after, .. } => {
assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into()));
assert_eq!(after.target, RevisionTarget::Working);
}
@@ -1092,6 +1413,8 @@ mod tests {
Comparison {
before: "abcdef123".into(),
after: "Working checkout".into(),
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
files: vec![
ChangedFile {
path: "renamed.txt".into(),
@@ -1111,6 +1434,8 @@ mod tests {
"tail".into(),
"end".into(),
],
before_eof: false,
after_eof: false,
hunks: vec![
Hunk {
before_start: 1,
@@ -1133,6 +1458,8 @@ mod tests {
status: FileStatus::Added,
before: vec![],
after: vec!["a".into(), "b".into()],
before_eof: false,
after_eof: false,
hunks: vec![Hunk {
before_start: 0,
before_len: 0,
@@ -1147,6 +1474,8 @@ mod tests {
status: FileStatus::Removed,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: Some("Binary file".into()),
},
@@ -1261,6 +1590,9 @@ mod tests {
assert_eq!(editor.diff().unwrap().phase, Phase::After);
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().phase, Phase::Before);
assert!(editor.diff().unwrap().query.is_empty());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
@@ -1362,129 +1694,55 @@ mod tests {
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
#[test]
fn explorer_toggle_preserves_filtered_tree_review_and_source_state() {
fn explorer_actions_preserve_review_and_ctrl_e_uses_normal_bindings() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("unsaved source".into())])
.unwrap();
let original = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "src/deep/renamed.txt".into();
changes.files[1].path = "src/deep/added.txt".into();
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
editor.handle(Input::Char('\t'));
editor.handle(Input::Down);
editor.handle(Input::Right);
editor.handle(Input::Char('\t'));
editor.handle(Input::Right);
editor.handle(Input::Char('\t'));
editor.handle(Input::Char('i'));
text(&mut editor, "src");
editor.handle(Input::Down);
let snapshot = |state: &DiffState| {
let files = state.files.as_ref().unwrap();
(
files.query.clone(),
files.selected,
files.view,
files.searching,
files
.entries()
.map(|row| (row.path.to_owned(), row.expanded))
.collect::<Vec<_>>(),
)
};
let tree = snapshot(editor.diff().unwrap());
let rows = editor
.diff()
.unwrap()
.rows
.iter()
.map(|row| (row.before, row.after, row.kind))
.collect::<Vec<_>>();
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
editor
.bind("tree", "ctrl+e", Some("core.down".into()))
.unwrap();
editor
.bind("selection", "ctrl+e", Some("core.up".into()))
.unwrap();
editor.handle(Input::Ctrl('e'));
let state = editor.diff().unwrap();
assert!(!state.files_visible);
assert_eq!(state.focus, Focus::Before);
assert!(!state.files.as_ref().unwrap().focused);
assert!(editor.diff().unwrap().files_visible);
assert_eq!(
(state.file, state.top, state.left_before, state.left_after),
(0, 1, 1, 1)
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.entries()
.nth(editor.diff().unwrap().files.as_ref().unwrap().selected)
.unwrap()
.path,
Path::new("added.txt")
);
assert_eq!(snapshot(state), tree);
for (input, focus) in [
(Input::Char('\t'), Focus::After),
(Input::Char('\t'), Focus::Before),
(Input::BackTab, Focus::After),
(Input::BackTab, Focus::Before),
] {
editor.handle(input);
let state = editor.diff().unwrap();
assert_eq!(state.focus, focus);
assert!(!state.files.as_ref().unwrap().focused);
}
for input in [
Input::Char('z'),
Input::Ctrl('t'),
Input::Backspace,
Input::Delete,
Input::Paste("not source".into()),
Input::Enter,
] {
editor.handle(input);
}
assert_eq!(snapshot(editor.diff().unwrap()), tree);
editor.handle(Input::Down);
assert_eq!(editor.diff().unwrap().top, 2);
editor.handle(Input::Ctrl('e'));
let state = editor.diff().unwrap();
assert!(state.files_visible && state.files.as_ref().unwrap().focused);
assert_eq!(state.focus, Focus::Files);
assert_eq!(snapshot(state), tree);
assert_eq!(
(state.file, state.top, state.left_before, state.left_after),
(0, 2, 1, 1)
);
assert_eq!(
state
.rows
.iter()
.map(|row| (row.before, row.after, row.kind))
.collect::<Vec<_>>(),
rows
);
editor.handle(Input::Char('\t'));
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::BackTab);
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
assert!(!editor.diff().unwrap().files.as_ref().unwrap().focused);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Ctrl('w'));
assert!(editor.diff().is_some());
assert!(!editor.diff().unwrap().files_visible);
text(&mut editor, " e");
assert!(editor.diff().unwrap().files_visible);
assert_eq!(editor.diff().unwrap().focus, Focus::Files);
assert_eq!(snapshot(editor.diff().unwrap()), tree);
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().presentation,
crate::files::FilePresentation::Docked
);
editor.handle(Input::Ctrl('w'));
text(&mut editor, " o");
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().presentation,
crate::files::FilePresentation::Floating
);
editor.handle(Input::Escape);
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
editor.handle(Input::Ctrl('e'));
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
@@ -1584,7 +1842,7 @@ mod tests {
let first = choose_pair(&mut editor);
assert!(editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(first));
editor.handle(Input::Escape);
editor.execute("diff.close");
open(&mut editor);
assert_eq!(editor.diff().unwrap().comparison_id, None);
let second = choose_pair(&mut editor);
@@ -1628,7 +1886,7 @@ mod tests {
}
#[test]
fn native_and_ex_diff_preserve_dirty_buffers_carets_and_undo_and_gate_all_edits() {
fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("original\\nsecond".into())])
@@ -1671,6 +1929,8 @@ mod tests {
buffers
);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert_eq!(editor.mode(), Mode::Normal);
open(&mut editor);
let id = choose_pair(&mut editor);
@@ -1690,6 +1950,8 @@ mod tests {
assert_eq!(editor.carets(), carets);
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "original\\nsecond");
}
@@ -1724,6 +1986,8 @@ mod tests {
before: "Working".into(),
after: "Working".into(),
files: vec![],
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
}))
));
assert_eq!(editor.diff().unwrap().phase, Phase::View);
@@ -1734,6 +1998,121 @@ mod tests {
assert_eq!(editor.diff().unwrap().top, 0);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
fn live_state(before: Vec<String>, after: Vec<String>, eof: bool) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
state.focus = Focus::After;
state.after = Some(revision(
RevisionTarget::Working,
"Working",
"",
Category::Commits,
));
state.comparison = Some(Comparison {
root: PathBuf::new(),
before: "base".into(),
after: "Working".into(),
editable_paths: BTreeSet::from([PathBuf::from("file")]),
files: vec![ChangedFile {
path: "file".into(),
old_path: None,
status: FileStatus::Modified,
hunks: line_hunks(&before, eof, &after, eof),
before,
after,
before_eof: eof,
after_eof: eof,
notice: None,
}],
});
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[(0, PathBuf::from("file"))],
));
state.align_file();
state
}
#[test]
fn live_reflow_keeps_separate_hunks_expansion_and_caret_after_revert() {
let base = (0..100)
.map(|row| format!("line {row}"))
.collect::<Vec<_>>();
let mut changed = base.clone();
changed[30] = "first change".into();
changed[70] = "second change".into();
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);
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 2);
assert!(
state
.rows
.last()
.is_some_and(|row| row.after == Some(100) && row.kind == RowKind::Equal)
);
state.top = state.after_display_row(30).unwrap();
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);
assert!(state.after_display_row(24).is_some());
assert_eq!(state.comparison.as_ref().unwrap().files[0].hunks.len(), 3);
assert!(
state.display_rows.iter().any(
|row| matches!(row, DisplayRow::Fold { start, end } if *start < 60 && *end > 40)
)
);
let mut reverted = base;
reverted.push(String::new());
state.refresh_after(&reverted, 3, 90);
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_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);
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
assert!(state.after_display_row(50).is_some());
}
#[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);
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_eq!(state.rows[0].kind, RowKind::Changed);
assert!(
state.comparison.as_ref().unwrap().files[0]
.notice
.as_ref()
.unwrap()
.contains("After missing")
);
state.refresh_after(&["".into()], 3, 0);
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);
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);
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
empty.handle(Input::Escape);
assert_eq!(empty.phase, Phase::View);
}
}