Files
vamp/src/diff.rs
T

2392 lines
84 KiB
Rust

use crate::editor::Input;
use crate::files::{FilePicker, FilePresentation, FileView};
use std::{
collections::BTreeSet,
path::{Path, PathBuf},
};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RepoKind {
Jujutsu,
Git,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Category {
Revisions,
Bookmarks,
Commits,
Branches,
Tags,
}
impl Category {
pub fn label(self) -> &'static str {
match self {
Self::Revisions => "Revisions",
Self::Bookmarks => "Bookmarks",
Self::Commits => "Commits",
Self::Branches => "Branches",
Self::Tags => "Tags",
}
}
}
impl RepoKind {
pub fn categories(self) -> &'static [Category] {
match self {
Self::Jujutsu => &[Category::Revisions, Category::Bookmarks],
Self::Git => &[Category::Commits, Category::Branches, Category::Tags],
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RevisionTarget {
Working,
Commit(String),
Remote { remote: String, reference: String },
FetchRemote(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Revision {
pub target: RevisionTarget,
pub label: String,
pub description: String,
pub category: Category,
}
#[derive(Debug)]
pub struct Catalog {
pub kind: RepoKind,
pub entries: Vec<Revision>,
pub notice: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileStatus {
Added,
Removed,
Modified,
Renamed,
}
#[derive(Debug)]
pub struct Hunk {
pub before_start: usize,
pub before_len: usize,
pub after_start: usize,
pub after_len: usize,
}
#[derive(Debug)]
pub struct ChangedFile {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
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>,
}
#[derive(Debug)]
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,
buffers: Box<[(PathBuf, String)]>,
},
}
#[derive(Debug)]
pub enum Outcome {
Catalog(Catalog),
Comparison(Comparison),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Phase {
Before,
After,
View,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Focus {
Files,
Before,
After,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PaneLayout {
Split,
Before,
After,
Empty,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RowKind {
Equal,
Added,
Removed,
Changed,
}
#[derive(Debug)]
pub struct AlignedRow {
pub before: Option<usize>,
pub after: Option<usize>,
pub kind: RowKind,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DisplayRow {
Line(usize),
Fold { start: usize, end: usize },
}
#[derive(Debug)]
struct SelectorPosition {
category: usize,
query: String,
selected: RevisionTarget,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestKind {
Catalog,
Comparison,
}
#[derive(Debug)]
pub struct DiffState {
pub catalog: Option<Catalog>,
pub phase: Phase,
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
pub query: String,
pub selected: usize,
pub loading: bool,
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub comparison_id: Option<u64>,
pub files: Option<FilePicker>,
pub files_visible: bool,
pub file: usize,
pub focus: Focus,
pub top: usize,
pub left_before: usize,
pub left_after: usize,
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>,
request_kind: Option<RequestKind>,
visible: Vec<usize>,
search_labels: Vec<String>,
before_position: Option<SelectorPosition>,
max_before: usize,
max_after: usize,
}
impl DiffState {
pub(crate) fn new() -> Self {
Self {
catalog: None,
phase: Phase::Before,
before: None,
after: None,
category: 0,
query: String::new(),
selected: 0,
loading: true,
error: None,
comparison: None,
comparison_id: None,
files: None,
files_visible: true,
file: 0,
focus: Focus::Files,
top: 0,
left_before: 0,
left_after: 0,
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),
request_kind: Some(RequestKind::Catalog),
visible: Vec::new(),
search_labels: Vec::new(),
before_position: None,
max_before: 0,
max_after: 0,
}
}
pub fn entries(&self) -> impl ExactSizeIterator<Item = &Revision> {
self.visible
.iter()
.map(|&index| &self.catalog.as_ref().expect("visible catalog").entries[index])
}
pub(crate) fn take_request(&mut self) -> Option<Operation> {
self.pending.take()
}
fn queue(&mut self, operation: Operation) {
self.request_kind = Some(match operation {
Operation::Compare { .. } => RequestKind::Comparison,
_ => RequestKind::Catalog,
});
self.pending = Some(operation);
self.loading = true;
self.error = None;
}
pub(crate) fn apply_result(&mut self, id: u64, result: Result<Outcome, String>) {
let expected = self.request_kind.take();
self.loading = false;
self.pending = None;
match result {
Ok(Outcome::Catalog(catalog)) if expected == Some(RequestKind::Catalog) => {
let selected = self
.entries()
.nth(self.selected)
.map(|entry| entry.target.clone());
self.search_labels = catalog
.entries
.iter()
.map(|entry| {
let id = match &entry.target {
RevisionTarget::Commit(id) => id.as_str(),
_ => "",
};
format!("{} {} {id}", entry.label, entry.description).to_lowercase()
})
.collect();
self.category = self.category.min(catalog.kind.categories().len() - 1);
self.catalog = Some(catalog);
self.refilter();
if let Some(target) = selected {
self.select_target(&target);
}
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(
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);
}
self.files = Some(files);
self.comparison = Some(comparison);
self.comparison_id = Some(id);
self.phase = Phase::View;
self.file = 0;
self.focus = if self.files_visible {
Focus::Files
} else {
self.first_text_focus()
};
self.align_file();
self.error = None;
}
Ok(_) => self.error = Some("Unexpected repository response".into()),
Err(error) => self.error = Some(error),
}
}
fn refilter(&mut self) {
self.visible.clear();
if let Some(catalog) = &self.catalog {
let category = catalog.kind.categories()[self.category];
let query = self.query.to_lowercase();
let tokens: Vec<_> = query.split_whitespace().collect();
// Working is always pinned, even outside its category or query.
self.visible.extend(
catalog
.entries
.iter()
.enumerate()
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
.map(|(index, _)| index),
);
self.visible.extend(
catalog
.entries
.iter()
.enumerate()
.filter(|(index, entry)| {
entry.target != RevisionTarget::Working
&& entry.category == category
&& tokens
.iter()
.all(|token| self.search_labels[*index].contains(token))
})
.map(|(index, _)| index),
);
}
self.selected = 0;
}
fn select_target(&mut self, target: &RevisionTarget) {
let selected = self.entries().position(|entry| &entry.target == target);
if let Some(index) = selected {
self.selected = index;
}
}
pub(crate) fn navigate(&mut self, forward: bool) {
if self.phase == Phase::View {
if self.focus == Focus::Files {
if let Some(files) = &mut self.files {
files.navigate(forward);
}
} else {
self.top = if forward {
self.top
.saturating_add(1)
.min(self.display_rows.len().saturating_sub(1))
} else {
self.top.saturating_sub(1)
};
}
} else {
let count = self.visible.len();
self.selected = if count == 0 {
0
} else if forward {
(self.selected + 1) % count
} else if self.selected == 0 {
count - 1
} else {
self.selected - 1
};
}
}
/// 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
&& let Some(files) = &mut self.files
&& files.searching
&& files.view == FileView::Tree
{
files.finish_search();
return;
}
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 = self.first_text_focus();
}
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;
if let Some(position) = self.before_position.take() {
self.category = position.category;
self.query = position.query;
self.refilter();
self.select_target(&position.selected);
}
return;
}
if self.phase == Phase::View {
self.handle_view(input);
return;
}
match input {
Input::Char('\t') | Input::BackTab => {
if let Some(catalog) = &self.catalog {
let count = catalog.kind.categories().len();
self.category = if input == Input::BackTab {
(self.category + count - 1) % count
} else {
(self.category + 1) % count
};
self.refilter();
}
}
Input::Up | Input::Ctrl('k') => self.navigate(false),
Input::Down | Input::Ctrl('j') => self.navigate(true),
Input::Home => self.selected = 0,
Input::End => self.selected = self.visible.len().saturating_sub(1),
Input::Enter if !self.loading => self.choose(),
Input::Backspace => {
if let Some((byte, _)) = self.query.grapheme_indices(true).next_back() {
self.query.truncate(byte);
self.refilter();
}
}
Input::Ctrl('u') => {
self.query.clear();
self.refilter();
}
Input::Char(ch) if !ch.is_control() => {
self.query.push(ch);
self.refilter();
}
_ => {}
}
}
fn choose(&mut self) {
let revision = self.entries().nth(self.selected).cloned();
let Some(revision) = revision else {
// Discovery can be retried without closing the modal.
if self.catalog.is_none() {
self.queue(Operation::Discover);
}
return;
};
if let RevisionTarget::FetchRemote(remote) = &revision.target {
self.queue(Operation::FetchRemote(remote.clone()));
} else if self.phase == Phase::Before {
self.before_position = Some(SelectorPosition {
category: self.category,
query: self.query.clone(),
selected: revision.target.clone(),
});
self.before = Some(revision);
self.after = None;
self.phase = Phase::After;
self.query.clear();
self.refilter();
self.error = None;
} else if let Some(before) = &self.before {
self.after = Some(revision.clone());
self.queue(Operation::Compare {
before: before.clone(),
after: revision,
buffers: Box::new([]),
});
}
}
fn handle_view(&mut self, input: Input) {
if self.focus == Focus::Files {
let files = self.files.as_mut().expect("comparison file picker");
match input {
Input::Char('\t') | Input::BackTab => {}
Input::Ctrl('t') => {
files.toggle_view();
return;
}
Input::Home => {
files.selected = 0;
return;
}
Input::End => {
let last = files.entries().len().saturating_sub(1);
files.selected = last;
return;
}
Input::Up | Input::Ctrl('k') => {
files.navigate(false);
return;
}
Input::Down | Input::Ctrl('j') => {
files.navigate(true);
return;
}
Input::Left | Input::Ctrl('h') => {
files.left();
return;
}
Input::Right | Input::Ctrl('l') => {
files.right();
return;
}
Input::Enter => {
if let Some(path) = files.accept()
&& self.select_file(&path)
{
self.focus = self.first_text_focus();
let files = self.files.as_mut().expect("comparison picker");
files.focused = false;
self.files_visible = files.presentation == FilePresentation::Docked;
}
return;
}
_ => {
files.handle_filter_input(&input);
return;
}
}
}
match input {
Input::Char('\t') => self.cycle_focus(true),
Input::BackTab => self.cycle_focus(false),
Input::Up | Input::Ctrl('k') => self.navigate(false),
Input::Down | Input::Ctrl('j') => self.navigate(true),
Input::Left | Input::Ctrl('h') => match self.focus {
Focus::Before => self.left_before = self.left_before.saturating_sub(1),
Focus::After => self.left_after = self.left_after.saturating_sub(1),
Focus::Files => {}
},
Input::Right | Input::Ctrl('l') => match self.focus {
Focus::Before => {
self.left_before = self.left_before.saturating_add(1).min(self.max_before)
}
Focus::After => {
self.left_after = self.left_after.saturating_add(1).min(self.max_after)
}
Focus::Files => {}
},
Input::Home | Input::End => {
self.top = if input == Input::Home {
0
} else {
self.display_rows.len().saturating_sub(1)
};
}
Input::Char('[') => self.expand_excerpt(false),
Input::Char(']') => self.expand_excerpt(true),
_ => {}
}
if let Some(files) = &mut self.files {
files.focused = self.files_visible && self.focus == Focus::Files;
}
}
pub(crate) fn pane_layout(&self) -> PaneLayout {
let Some(file) = self
.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(self.file))
else {
return PaneLayout::Empty;
};
match file.status {
FileStatus::Added => PaneLayout::After,
FileStatus::Removed => PaneLayout::Before,
FileStatus::Renamed
if file.before == file.after && file.before_eof == file.after_eof =>
{
PaneLayout::Empty
}
FileStatus::Modified | FileStatus::Renamed => PaneLayout::Split,
}
}
pub(crate) fn bound_horizontal(&self, focus: Focus, left: usize, width: usize) -> usize {
let columns = match focus {
Focus::Before => self.max_before,
Focus::After => self.max_after,
Focus::Files => return 0,
};
left.min(columns.saturating_add(1).saturating_sub(width.max(1)))
}
pub(crate) fn scroll_horizontal(&mut self, focus: Focus, right: bool, width: usize) {
let left = match focus {
Focus::Before => self.left_before,
Focus::After => self.left_after,
Focus::Files => return,
};
let left = if right {
left.saturating_add(3)
} else {
left.saturating_sub(3)
};
let left = self.bound_horizontal(focus, left, width);
match focus {
Focus::Before => self.left_before = left,
Focus::After => self.left_after = left,
Focus::Files => unreachable!(),
}
self.follow_caret = false;
}
pub(crate) fn first_text_focus(&self) -> Focus {
if self.pane_layout() == PaneLayout::After {
Focus::After
} else {
Focus::Before
}
}
pub(crate) fn normalize_focus(&mut self) {
self.focus = match (self.focus, self.pane_layout()) {
(Focus::Files, _) if self.files_visible => Focus::Files,
(Focus::Before | Focus::After, PaneLayout::Split) => self.focus,
_ => self.first_text_focus(),
};
if let Some(files) = &mut self.files {
files.focused = self.files_visible && self.focus == Focus::Files;
}
}
fn cycle_focus(&mut self, forward: bool) {
let first = self.first_text_focus();
let last = if self.pane_layout() == PaneLayout::Split {
Focus::After
} else {
first
};
self.focus = match self.focus {
Focus::Files => {
if forward {
first
} else {
last
}
}
focus if forward && focus != last => last,
focus if !forward && focus != first => first,
_ if self.files_visible => Focus::Files,
_ => {
if forward {
first
} else {
last
}
}
};
}
pub fn after_editable(&self) -> bool {
self.phase == Phase::View
&& matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
&& 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.normalize_focus();
self.live_buffer = None;
self.live_revision = None;
self.follow_caret = false;
self.align_file();
true
}
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
self.display_rows.iter().position(|display| {
matches!(*display, DisplayRow::Line(index) if self.source_row(focus, index) == Some(source_row))
})
}
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
self.source_display_row(Focus::After, source_row)
}
fn source_row(&self, focus: Focus, index: usize) -> Option<usize> {
match focus {
Focus::Before => self.rows[index].before,
Focus::After => self.rows[index].after,
Focus::Files => None,
}
}
pub(crate) fn source_rows(&self, focus: Focus) -> impl DoubleEndedIterator<Item = usize> + '_ {
self.excerpt_ranges.iter().flat_map(move |&(start, end)| {
(start..end).filter_map(move |index| self.source_row(focus, index))
})
}
pub(crate) fn snapshot_lines(&self, focus: Focus) -> Vec<String> {
let file = &self.comparison.as_ref().expect("snapshot comparison").files[self.file];
let (source, eof, live) = match focus {
Focus::Before => (&file.before, file.before_eof, false),
Focus::After => (
&file.after,
file.after_eof,
self.live_files.contains(&self.file),
),
Focus::Files => unreachable!("snapshot belongs to a text pane"),
};
let mut lines = source.clone();
if !live && (eof || lines.is_empty()) {
lines.push(String::new());
}
lines
}
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
let aligned = self.rows.iter().position(|aligned| match from {
Focus::Before => aligned.before == Some(row),
Focus::After => aligned.after == Some(row),
Focus::Files => false,
})?;
self.display_rows
.iter()
.filter_map(|display| match *display {
DisplayRow::Line(index) => self
.source_row(to, index)
.map(|row| (index.abs_diff(aligned), row)),
DisplayRow::Fold { .. } => None,
})
.min_by_key(|&(distance, row)| (distance, row))
.map(|(_, row)| row)
}
pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option<usize> {
let mut previous = None;
for visible in self.source_rows(focus) {
if visible >= row {
return Some(match previous {
Some(previous) if row - previous < visible - row => previous,
_ => visible,
});
}
previous = Some(visible);
}
previous
}
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) {
if !self.after_editable() {
return;
}
if self.live_revision == Some(revision) {
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)));
}
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.normalize_focus();
self.rows.clear();
self.display_rows.clear();
self.excerpt_ranges.clear();
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
.as_ref()
.and_then(|comparison| comparison.files.get(self.file))
else {
return;
};
let width = |lines: &[String]| {
lines
.iter()
.map(|line| {
line.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
4 - column % 4
} else {
UnicodeWidthStr::width(grapheme)
}
})
})
.max()
.unwrap_or(0)
};
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(after_len).max(after);
append_rows(
&mut self.rows,
before,
start_before,
after,
start_after,
false,
);
before = start_before;
after = start_after;
let end_before = before
.saturating_add(hunk.before_len)
.min(file.before.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;
}
append_rows(
&mut self.rows,
before,
file.before.len(),
after,
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();
}
pub(crate) fn set_context_options(&mut self, context_lines: usize, expand_lines: usize) {
if self.context_lines == context_lines && self.expand_lines == expand_lines {
return;
}
let context_changed = self.context_lines != context_lines;
self.context_lines = context_lines;
self.expand_lines = expand_lines.max(1);
if context_changed {
self.reset_excerpts();
self.top = 0;
}
}
fn reset_excerpts(&mut self) {
self.excerpt_ranges.clear();
for (index, row) in self.rows.iter().enumerate() {
if row.kind == RowKind::Equal {
continue;
}
let start = index.saturating_sub(self.context_lines);
let end = index
.saturating_add(1)
.saturating_add(self.context_lines)
.min(self.rows.len());
if let Some(last) = self.excerpt_ranges.last_mut()
&& start <= last.1
{
last.1 = end;
} else {
self.excerpt_ranges.push((start, end));
}
}
self.rebuild_display_rows();
}
fn rebuild_display_rows(&mut self) {
let mut count = 0;
for index in 0..self.excerpt_ranges.len() {
let range = self.excerpt_ranges[index];
if count > 0 && range.0 <= self.excerpt_ranges[count - 1].1 {
self.excerpt_ranges[count - 1].1 = self.excerpt_ranges[count - 1].1.max(range.1);
} else {
self.excerpt_ranges[count] = range;
count += 1;
}
}
self.excerpt_ranges.truncate(count);
self.display_rows.clear();
if self.excerpt_ranges.is_empty() {
return;
}
let mut end = 0;
for &(start, next_end) in &self.excerpt_ranges {
if end < start {
self.display_rows.push(DisplayRow::Fold {
start: end,
end: start,
});
}
self.display_rows
.extend((start..next_end).map(DisplayRow::Line));
end = next_end;
}
if end < self.rows.len() {
self.display_rows.push(DisplayRow::Fold {
start: end,
end: self.rows.len(),
});
}
}
pub(crate) fn expand_source_excerpt(&mut self, focus: Focus, below: bool, source_row: usize) {
let Some(display_row) = self.source_display_row(focus, source_row) else {
return;
};
self.expand_excerpt_at(below, self.display_rows[display_row]);
}
fn expand_excerpt(&mut self, below: bool) {
let Some(target) = self.display_rows.get(self.top).copied() else {
return;
};
self.expand_excerpt_at(below, target);
}
fn expand_excerpt_at(&mut self, below: bool, target: DisplayRow) {
let Some(top) = self.display_rows.get(self.top).copied() else {
return;
};
let index = match target {
DisplayRow::Line(row) => self
.excerpt_ranges
.iter()
.position(|&(start, end)| start <= row && row < end),
DisplayRow::Fold { end, .. } => self
.excerpt_ranges
.iter()
.position(|&(start, _)| start == end)
.or_else(|| self.excerpt_ranges.len().checked_sub(1)),
};
let Some(index) = index else {
return;
};
let range = &mut self.excerpt_ranges[index];
let previous = *range;
if below {
range.1 = range
.1
.saturating_add(self.expand_lines)
.min(self.rows.len());
} else {
range.0 = range.0.saturating_sub(self.expand_lines);
}
if *range == previous {
return;
}
let anchor = match top {
DisplayRow::Line(row) => row,
DisplayRow::Fold { end, .. } if !below && end == previous.0 => range.0,
DisplayRow::Fold { start, .. } => start,
};
self.rebuild_display_rows();
self.top = self
.display_rows
.iter()
.position(|row| match *row {
DisplayRow::Line(index) => index == anchor,
DisplayRow::Fold { start, end } => start <= anchor && anchor < end,
})
.unwrap_or(0);
}
}
fn append_rows(
rows: &mut Vec<AlignedRow>,
before: usize,
before_end: usize,
after: usize,
after_end: usize,
changed: bool,
) {
for offset in 0..(before_end - before).max(after_end - after) {
let before = (offset < before_end - before).then_some(before + offset);
let after = (offset < after_end - after).then_some(after + offset);
let kind = match (before, after) {
(Some(_), Some(_)) if changed => RowKind::Changed,
(Some(_), Some(_)) => RowKind::Equal,
(Some(_), None) => RowKind::Removed,
(None, Some(_)) => RowKind::Added,
(None, None) => unreachable!("alignment has at least one line"),
};
rows.push(AlignedRow {
before,
after,
kind,
});
}
}
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::*;
use crate::editor::{Editor, Effect, Mode};
fn excerpt_state(len: usize, changed: &[usize]) -> DiffState {
let mut state = DiffState::new();
state.phase = Phase::View;
state.focus = Focus::Before;
state.rows = (0..len)
.map(|index| AlignedRow {
before: Some(index),
after: Some(index + 3),
kind: if changed.contains(&index) {
RowKind::Changed
} else {
RowKind::Equal
},
})
.collect();
state.reset_excerpts();
state
}
#[test]
fn excerpts_cover_context_and_hidden_rows_once_without_reindexing() {
let state = excerpt_state(100, &[30, 70]);
assert_eq!(state.excerpt_ranges, [(20, 41), (60, 81)]);
assert_eq!(
state.display_rows[0],
DisplayRow::Fold { start: 0, end: 20 }
);
assert_eq!(
state.display_rows[22],
DisplayRow::Fold { start: 41, end: 60 }
);
assert_eq!(
state.display_rows[44],
DisplayRow::Fold {
start: 81,
end: 100
}
);
let mut covered = Vec::new();
for row in &state.display_rows {
match *row {
DisplayRow::Line(index) => {
covered.push(index);
assert_eq!(state.rows[index].before, Some(index));
assert_eq!(state.rows[index].after, Some(index + 3));
}
DisplayRow::Fold { start, end } => {
assert!(
state.rows[start..end]
.iter()
.all(|row| row.kind == RowKind::Equal)
);
covered.extend(start..end);
}
}
}
assert_eq!(covered, (0..100).collect::<Vec<_>>());
for index in [30, 70] {
assert_eq!(
state
.display_rows
.iter()
.filter(|&&row| row == DisplayRow::Line(index))
.count(),
1
);
}
}
#[test]
fn zero_context_boundaries_adjacent_changes_and_touching_ranges() {
let mut state = excerpt_state(60, &[0, 1, 30, 59]);
state.set_context_options(0, 15);
assert_eq!(state.excerpt_ranges, [(0, 2), (30, 31), (59, 60)]);
assert_eq!(
state.display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Fold { start: 2, end: 30 },
DisplayRow::Line(30),
DisplayRow::Fold { start: 31, end: 59 },
DisplayRow::Line(59),
]
);
assert_eq!(excerpt_state(60, &[20, 41]).excerpt_ranges, [(10, 52)]);
assert_eq!(
excerpt_state(3, &[0, 1, 2]).display_rows,
[
DisplayRow::Line(0),
DisplayRow::Line(1),
DisplayRow::Line(2),
]
);
}
#[test]
fn expansion_anchors_source_rows_merges_and_clamps_both_directions() {
let mut state = excerpt_state(100, &[30, 70]);
state.top = 11;
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (60, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(0, 41), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 56), (60, 81)]);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 96)]);
state.handle(Input::Char(']'));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(0, 100)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(30));
}
#[test]
fn folds_choose_following_excerpt_or_previous_at_end() {
let mut state = excerpt_state(100, &[30, 70]);
state.handle(Input::Char('['));
assert_eq!(state.display_rows[state.top], DisplayRow::Line(5));
state.top = state
.display_rows
.iter()
.position(|&row| row == (DisplayRow::Fold { start: 41, end: 60 }))
.unwrap();
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 81)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(45));
state.handle(Input::End);
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(5, 41), (45, 96)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(81));
}
#[test]
fn navigation_uses_display_length_and_custom_increment() {
let mut state = excerpt_state(100, &[30]);
state.handle(Input::End);
state.handle(Input::Down);
assert_eq!(state.top, 22);
state.handle(Input::Up);
assert_eq!(state.top, 21);
state.handle(Input::Home);
state.handle(Input::Up);
assert_eq!(state.top, 0);
state.set_context_options(0, 2);
state.handle(Input::Char('['));
assert_eq!(state.excerpt_ranges, [(28, 31)]);
assert_eq!(state.display_rows[state.top], DisplayRow::Line(28));
state.handle(Input::Char(']'));
assert_eq!(state.excerpt_ranges, [(28, 33)]);
}
#[test]
fn metadata_empty_and_wholly_added_removed_files_preserve_alignment() {
let mut state = DiffState::new();
let mut comparison = comparison();
comparison.files[0].hunks.clear();
comparison.files[0].after = comparison.files[0].before.clone();
state.comparison = Some(comparison);
state.align_file();
assert!(state.excerpt_ranges.is_empty());
assert!(state.display_rows.is_empty());
assert_eq!(state.rows.len(), 5);
state.file = 1;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Added && row.before.is_none())
);
let file = &mut state.comparison.as_mut().unwrap().files[1];
std::mem::swap(&mut file.before, &mut file.after);
file.hunks[0].before_len = 2;
file.hunks[0].after_len = 0;
state.align_file();
assert_eq!(
state.display_rows,
[DisplayRow::Line(0), DisplayRow::Line(1)]
);
assert!(
state
.rows
.iter()
.all(|row| row.kind == RowKind::Removed && row.after.is_none())
);
state.file = 2;
state.align_file();
assert!(state.rows.is_empty());
assert!(state.display_rows.is_empty());
assert!(state.comparison.as_ref().unwrap().files[2].notice.is_some());
}
fn revision(
target: RevisionTarget,
label: &str,
description: &str,
category: Category,
) -> Revision {
Revision {
target,
label: label.into(),
description: description.into(),
category,
}
}
fn catalog() -> Catalog {
Catalog {
kind: RepoKind::Git,
entries: vec![
revision(
RevisionTarget::Working,
"Working checkout",
"",
Category::Commits,
),
revision(
RevisionTarget::Commit("abcdef123".into()),
"abcdef123",
"Fix 界 e\u{301}",
Category::Commits,
),
revision(
RevisionTarget::Commit("fedcba321".into()),
"fedcba321",
"Another change",
Category::Commits,
),
revision(
RevisionTarget::FetchRemote("origin".into()),
"Load history from origin",
"",
Category::Commits,
),
revision(
RevisionTarget::Commit("branch-tip".into()),
"topic/branch",
"",
Category::Branches,
),
revision(
RevisionTarget::Remote {
remote: "origin".into(),
reference: "refs/heads/topic".into(),
},
"topic@origin",
"",
Category::Branches,
),
revision(
RevisionTarget::Commit("tag-tip".into()),
"release",
"",
Category::Tags,
),
],
notice: None,
}
}
fn editor() -> Editor {
Editor::open(None, vec![]).unwrap()
}
fn open(editor: &mut Editor) {
assert!(editor.execute("diff").is_none());
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Discover));
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog()))));
}
fn text(editor: &mut Editor, text: &str) {
for ch in text.chars() {
editor.handle(Input::Char(ch));
}
}
fn choose_pair(editor: &mut Editor) -> u64 {
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().phase, Phase::After);
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
match operation {
Operation::Compare { before, after, .. } => {
assert_eq!(before.target, RevisionTarget::Commit("abcdef123".into()));
assert_eq!(after.target, RevisionTarget::Working);
}
_ => panic!("expected compare"),
}
id
}
fn comparison() -> Comparison {
Comparison {
before: "abcdef123".into(),
after: "Working checkout".into(),
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
files: vec![
ChangedFile {
path: "renamed.txt".into(),
old_path: Some("old.txt".into()),
status: FileStatus::Renamed,
before: vec![
"same".into(),
"old".into(),
"tail".into(),
"deleted".into(),
"end".into(),
],
after: vec![
"same".into(),
"new".into(),
"extra".into(),
"tail".into(),
"end".into(),
],
before_eof: false,
after_eof: false,
hunks: vec![
Hunk {
before_start: 1,
before_len: 1,
after_start: 1,
after_len: 2,
},
Hunk {
before_start: 3,
before_len: 1,
after_start: 4,
after_len: 0,
},
],
notice: None,
},
ChangedFile {
path: "added.txt".into(),
old_path: None,
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,
after_start: 0,
after_len: 2,
}],
notice: None,
},
ChangedFile {
path: "removed.bin".into(),
old_path: None,
status: FileStatus::Removed,
before: vec![],
after: vec![],
before_eof: false,
after_eof: false,
hunks: vec![],
notice: Some("Binary file".into()),
},
],
}
}
#[test]
fn status_layout_cycles_only_present_panes_and_normalizes_selection() {
let mut state = live_state(vec!["old".into()], vec!["new".into()], true);
state.comparison = Some(comparison());
state.files = Some(FilePicker::from_paths(
PathBuf::new(),
[
(0, "renamed.txt".into()),
(1, "added.txt".into()),
(2, "removed.bin".into()),
],
));
for (path, layout, first, last) in [
(
"renamed.txt",
PaneLayout::Split,
Focus::Before,
Focus::After,
),
("added.txt", PaneLayout::After, Focus::After, Focus::After),
(
"removed.bin",
PaneLayout::Before,
Focus::Before,
Focus::Before,
),
] {
state.files_visible = true;
state.focus = Focus::Files;
assert!(state.select_file(Path::new(path)));
assert_eq!(state.pane_layout(), layout);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, first);
state.handle(Input::BackTab);
assert_eq!(state.focus, Focus::Files);
state.handle(Input::BackTab);
assert_eq!(state.focus, last);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, Focus::Files);
state.files_visible = false;
state.normalize_focus();
assert_eq!(state.focus, first);
state.handle(Input::BackTab);
assert_eq!(state.focus, last);
state.handle(Input::Char('\t'));
assert_eq!(state.focus, first);
}
state.focus = Focus::Before;
assert!(state.select_file(Path::new("added.txt")));
assert_eq!(state.focus, Focus::After);
assert!(state.select_file(Path::new("removed.bin")));
assert_eq!(state.focus, Focus::Before);
assert!(state.select_file(Path::new("renamed.txt")));
assert_eq!(state.focus, Focus::Before);
}
#[test]
fn rename_layout_and_editability_include_eof_changes() {
let mut state = live_state(vec!["same".into()], vec!["same".into()], true);
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Renamed;
assert_eq!(state.pane_layout(), PaneLayout::Empty);
assert!(!state.after_editable());
state.normalize_focus();
assert_eq!(state.focus, Focus::Before);
state.files_visible = false;
for input in [Input::Char('\t'), Input::BackTab] {
state.handle(input);
assert_eq!(state.focus, Focus::Before);
}
state.comparison.as_mut().unwrap().files[0].after_eof = false;
assert_eq!(state.pane_layout(), PaneLayout::Split);
assert!(state.after_editable());
state.comparison.as_mut().unwrap().files[0].after[0] = "changed".into();
assert!(state.after_editable());
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Removed;
assert!(!state.after_editable());
state.comparison.as_mut().unwrap().files[0].status = FileStatus::Added;
assert!(state.after_editable());
let file = &mut state.comparison.as_mut().unwrap().files[0];
file.before.clear();
file.after.clear();
file.notice = Some("empty file".into());
state.normalize_focus();
assert_eq!(state.pane_layout(), PaneLayout::After);
assert_eq!(state.focus, Focus::After);
assert!(state.after_editable());
assert_eq!(
state.comparison.as_ref().unwrap().files[0]
.notice
.as_deref(),
Some("empty file")
);
}
#[test]
fn added_file_picker_cancel_close_and_mouse_focus_use_after() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files = vec![changes.files.remove(1)];
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
editor.handle(Input::Escape);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
text(&mut editor, " e");
editor.handle(Input::Ctrl('w'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
assert!(!editor.diff().unwrap().files_visible);
editor.scroll_diff(Focus::Before, true);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
}
#[test]
fn selector_filters_literal_unicode_tokens_and_pins_working_in_each_category() {
let mut editor = editor();
open(&mut editor);
text(&mut editor, "FIX 界 e\u{301}");
assert_eq!(
editor
.diff()
.unwrap()
.entries()
.map(|entry| entry.label.as_str())
.collect::<Vec<_>>(),
["Working checkout", "abcdef123"]
);
editor.handle(Input::Backspace);
assert_eq!(editor.diff().unwrap().query, "FIX 界 ");
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().category, 1);
assert_eq!(editor.diff().unwrap().entries().len(), 1);
editor.handle(Input::Ctrl('u'));
assert_eq!(editor.diff().unwrap().entries().len(), 3);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().category, 0);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().category, 2);
assert_eq!(editor.diff().unwrap().entries().len(), 2);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().category, 0);
editor
.bind("selection", "x", Some("core.down".into()))
.unwrap();
editor.handle(Input::Char('x'));
assert_eq!(editor.diff().unwrap().selected, 1);
assert!(editor.diff().unwrap().query.is_empty());
editor.bind("selection", "ctrl+j", None).unwrap();
editor.handle(Input::Ctrl('j'));
assert_eq!(editor.diff().unwrap().selected, 1);
}
#[test]
fn loader_preserves_after_step_query_category_and_selection_then_compares() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Down);
editor.handle(Input::Enter);
text(&mut editor, "origin");
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::FetchRemote(remote) if remote == "origin"));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert!(editor.diff().unwrap().loading);
editor.handle(Input::Enter);
assert!(editor.take_diff_request().is_none());
let mut loaded = catalog();
loaded.entries.push(revision(
RevisionTarget::Commit("remote-commit".into()),
"remote-commit",
"origin remote history",
Category::Commits,
));
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(loaded))));
let state = editor.diff().unwrap();
assert_eq!(state.phase, Phase::After);
assert_eq!(state.query, "origin");
assert_eq!(state.category, 0);
assert_eq!(state.selected, 1);
assert_eq!(state.entries().len(), 3);
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (id, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Compare { after, .. }
if after.target == RevisionTarget::Commit("remote-commit".into())));
assert!(editor.apply_diff_result(id, Err("comparison failed".into())));
assert_eq!(editor.diff().unwrap().phase, Phase::After);
assert_eq!(editor.diff().unwrap().query, "origin");
assert!(editor.diff().unwrap().error.is_some());
editor.handle(Input::Enter);
assert!(matches!(
editor.take_diff_request(),
Some((_, Operation::Compare { .. }))
));
}
#[test]
fn after_escape_restores_before_query_and_selected_revision_and_cancels_request() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Char('\t'));
text(&mut editor, "topic");
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert!(editor.diff().unwrap().query.is_empty());
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
editor.handle(Input::Escape);
let state = editor.diff().unwrap();
assert_eq!(state.phase, Phase::Before);
assert_eq!(state.category, 1);
assert_eq!(state.query, "topic");
assert_eq!(state.selected, 1);
assert!(!state.loading);
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
editor.handle(Input::Enter);
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());
}
#[test]
fn file_to_directory_change_opens_both_historical_paths() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
let mut changes = comparison();
changes.files[0].path = "x".into();
changes.files[1].path = "x/y".into();
changes.files.truncate(2);
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::BackTab);
editor.handle(Input::Char('i'));
text(&mut editor, "x/y");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 1);
assert_eq!(editor.diff().unwrap().focus, Focus::After);
}
#[test]
fn changed_tree_filters_navigates_and_opens_without_touching_buffers() {
let mut editor = editor();
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();
changes.files[2].path = "archive/gone/removed.bin".into();
editor.apply_diff_result(id, Ok(Outcome::Comparison(changes)));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.focused && !picker.searching);
assert_eq!(
picker.entries().nth(picker.selected).unwrap().path,
std::path::Path::new("src/deep/renamed.txt")
);
assert!(picker.entries().any(|row| row.label == "src/deep"));
editor.bind("tree", "z", Some("core.left".into())).unwrap();
editor.handle(Input::Char('z'));
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(picker.entries().nth(picker.selected).unwrap().is_dir);
editor.handle(Input::Enter);
let picker = editor.diff().unwrap().files.as_ref().unwrap();
assert!(!picker.entries().nth(picker.selected).unwrap().expanded);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('l'));
editor.handle(Input::Char('l'));
editor
.bind("selection", "ctrl+n", Some("core.down".into()))
.unwrap();
editor.handle(Input::Ctrl('n'));
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 2);
assert_eq!(state.focus, Focus::Before);
assert!(!state.files.as_ref().unwrap().focused);
assert!(state.rows.is_empty());
editor.handle(Input::BackTab);
editor.handle(Input::Escape);
assert!(!editor.diff().unwrap().files.as_ref().unwrap().searching);
editor.handle(Input::Ctrl('u'));
assert!(
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.query
.is_empty()
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::List
);
editor.handle(Input::Ctrl('t'));
assert_eq!(
editor.diff().unwrap().files.as_ref().unwrap().view,
FileView::Tree
);
editor.handle(Input::Ctrl('r'));
editor.handle(Input::Char('i'));
text(&mut editor, "removed.bin");
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
assert_eq!(editor.context().text, original);
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_actions_preserve_review_and_ctrl_e_uses_normal_bindings() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
editor
.bind("tree", "ctrl+e", Some("core.down".into()))
.unwrap();
editor.handle(Input::Ctrl('e'));
assert!(editor.diff().unwrap().files_visible);
assert_eq!(
editor
.diff()
.unwrap()
.files
.as_ref()
.unwrap()
.entries()
.nth(editor.diff().unwrap().files.as_ref().unwrap().selected)
.unwrap()
.path,
Path::new("added.txt")
);
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!(
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);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert!(editor.diff().is_none());
}
#[test]
fn explorer_toggle_is_not_a_revision_selector_control() {
let mut editor = editor();
open(&mut editor);
editor.handle(Input::Ctrl('e'));
assert_eq!(editor.diff().unwrap().selected, 0);
editor
.bind("selection", "ctrl+e", Some("core.down".into()))
.unwrap();
for phase in [Phase::Before, Phase::After] {
assert_eq!(editor.diff().unwrap().phase, phase);
editor.handle(Input::Ctrl('e'));
let state = editor.diff().unwrap();
assert!(state.files_visible);
assert_eq!(state.selected, 1);
assert_eq!(state.phase, phase);
editor.handle(Input::Enter);
}
}
#[test]
fn aligned_hunks_navigation_shared_top_independent_horizontal_and_file_reset() {
let mut editor = editor();
open(&mut editor);
let id = choose_pair(&mut editor);
assert!(editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
assert_eq!(
editor
.diff()
.unwrap()
.rows
.iter()
.map(|row| (row.before, row.after, row.kind))
.collect::<Vec<_>>(),
[
(Some(0), Some(0), RowKind::Equal),
(Some(1), Some(1), RowKind::Changed),
(None, Some(2), RowKind::Added),
(Some(2), Some(3), RowKind::Equal),
(Some(3), None, RowKind::Removed),
(Some(4), Some(4), RowKind::Equal),
]
);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::Down);
editor.handle(Input::Right);
assert_eq!(editor.cursor().row, 1);
assert_eq!(editor.cursor().column, 1);
editor.scroll_diff(Focus::Before, true);
editor.set_diff_viewport(id, 0, 1, 1, 0);
editor.handle(Input::Char('\t'));
assert_eq!(editor.diff().unwrap().focus, Focus::After);
editor.handle(Input::Down);
editor.handle(Input::Right);
assert_eq!(editor.cursor().row, 2);
assert_eq!(editor.diff().unwrap().top, 1);
editor.set_diff_viewport(id, 0, 2, 1, 1);
assert_eq!(editor.diff().unwrap().left_after, 1);
assert_eq!(editor.diff().unwrap().left_before, 1);
editor.handle(Input::End);
assert_eq!(editor.diff().unwrap().top, 2);
editor.handle(Input::BackTab);
assert_eq!(editor.diff().unwrap().focus, Focus::Before);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
assert_eq!(editor.diff().unwrap().file, 0);
editor.handle(Input::Enter);
let state = editor.diff().unwrap();
assert_eq!(state.file, 1);
assert_eq!((state.top, state.left_before, state.left_after), (0, 0, 0));
assert!(
state
.rows
.iter()
.all(|row| row.before.is_none() && row.kind == RowKind::Added)
);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 2);
assert!(editor.diff().unwrap().rows.is_empty());
assert!(
editor.diff().unwrap().comparison.as_ref().unwrap().files[2]
.notice
.is_some()
);
editor.handle(Input::BackTab);
editor.handle(Input::Down);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().file, 0);
}
#[test]
fn comparison_identity_comes_from_accepted_requests_not_working_labels() {
let mut editor = editor();
open(&mut editor);
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.execute("diff.close");
open(&mut editor);
assert_eq!(editor.diff().unwrap().comparison_id, None);
let second = choose_pair(&mut editor);
assert!(second > first);
assert!(!editor.apply_diff_result(first, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, None);
assert!(editor.apply_diff_result(second, Ok(Outcome::Comparison(comparison()))));
assert_eq!(editor.diff().unwrap().comparison_id, Some(second));
}
#[test]
fn discovery_and_fetch_errors_are_retryable_and_stale_responses_cannot_reopen() {
let mut editor = editor();
editor.execute("diff");
let (old, _) = editor.take_diff_request().unwrap();
editor.handle(Input::Escape);
assert!(!editor.apply_diff_result(old, Ok(Outcome::Catalog(catalog()))));
editor.execute("diff");
let (id, _) = editor.take_diff_request().unwrap();
assert!(id > old);
assert!(!editor.apply_diff_result(old, Err("stale".into())));
assert!(editor.diff().unwrap().loading);
assert!(editor.apply_diff_result(id, Err("discovery failed".into())));
assert!(editor.diff().unwrap().error.is_some());
editor.handle(Input::Enter);
let (retry, operation) = editor.take_diff_request().unwrap();
assert!(matches!(operation, Operation::Discover));
assert!(retry > id);
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(catalog()))));
text(&mut editor, "origin");
editor.handle(Input::Down);
editor.handle(Input::Enter);
let (fetch, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(fetch, Err("fetch failed".into())));
assert_eq!(editor.diff().unwrap().entries().len(), 2);
assert_eq!(editor.diff().unwrap().query, "origin");
editor.handle(Input::Ctrl('u'));
editor.handle(Input::Home);
editor.handle(Input::Enter);
assert_eq!(editor.diff().unwrap().phase, Phase::After);
}
#[test]
fn immutable_diff_and_selectors_preserve_dirty_buffers_carets_and_undo() {
let mut editor = editor();
editor
.apply(vec![Effect::Replace("original\\nsecond".into())])
.unwrap();
editor.handle(Input::Char('i'));
text(&mut editor, "unsaved");
editor.handle(Input::Escape);
editor.handle(Input::Right);
let original = editor.context().text;
let carets = editor.carets();
let revision = editor.text_revision();
let buffers = editor
.buffers()
.map(|buffer| (buffer.id, buffer.dirty))
.collect::<Vec<_>>();
editor
.bind("normal", "x", Some("core.diff".into()))
.unwrap();
assert!(editor.handle(Input::Char('x')).is_none());
let (id, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog()))));
for input in [
Input::Char('i'),
Input::Char('d'),
Input::Delete,
Input::Paste("bad\\n:q!".into()),
] {
assert!(editor.handle(input).is_none());
}
assert!(editor.execute("bdelete!").is_none());
assert!(editor.apply(vec![Effect::Replace("bad".into())]).is_err());
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
assert_eq!(editor.text_revision(), revision);
assert_eq!(
editor
.buffers()
.map(|buffer| (buffer.id, buffer.dirty))
.collect::<Vec<_>>(),
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);
editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison())));
for input in [
Input::Char('i'),
Input::Char('u'),
Input::Char('d'),
Input::Paste("bad".into()),
Input::Delete,
Input::Backspace,
Input::Enter,
] {
editor.handle(input);
}
editor.handle(Input::Escape);
editor.handle(Input::Escape);
assert!(editor.diff().is_some());
editor.execute("diff.close");
assert_eq!(editor.context().text, original);
assert_eq!(editor.carets(), carets);
editor.handle(Input::Char('u'));
assert_eq!(editor.context().text, "original\\nsecond");
}
#[test]
fn jujutsu_categories_empty_comparison_and_command_mode_entry_are_complete() {
let mut editor = editor();
editor.handle(Input::Char(':'));
text(&mut editor, "diff");
editor.handle(Input::Enter);
assert_eq!(editor.mode(), Mode::Normal);
let (id, _) = editor.take_diff_request().unwrap();
let mut catalog = catalog();
catalog.kind = RepoKind::Jujutsu;
catalog.entries[0].category = Category::Revisions;
catalog.entries[1].category = Category::Revisions;
catalog.entries[4].category = Category::Bookmarks;
assert!(editor.apply_diff_result(id, Ok(Outcome::Catalog(catalog))));
assert_eq!(editor.diff().unwrap().entries().len(), 2);
editor.handle(Input::Char('\t'));
assert_eq!(
editor.diff().unwrap().entries().nth(1).unwrap().category,
Category::Bookmarks
);
editor.handle(Input::Home);
editor.handle(Input::Enter);
editor.handle(Input::Enter);
let (id, _) = editor.take_diff_request().unwrap();
assert!(editor.apply_diff_result(
id,
Ok(Outcome::Comparison(Comparison {
before: "Working".into(),
after: "Working".into(),
files: vec![],
root: PathBuf::new(),
editable_paths: BTreeSet::new(),
}))
));
assert_eq!(editor.diff().unwrap().phase, Phase::View);
assert!(editor.diff().unwrap().rows.is_empty());
for input in [Input::Down, Input::End, Input::Up, Input::Right, Input::End] {
editor.handle(input);
}
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_and_expanded_context_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);
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);
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);
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(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);
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);
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(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]
.notice
.as_ref()
.unwrap()
.contains("After missing")
);
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);
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);
assert_eq!(empty.comparison.as_ref().unwrap().files[0].after, [""]);
empty.handle(Input::Escape);
assert_eq!(empty.phase, Phase::View);
}
}