4354 lines
158 KiB
Rust
4354 lines
158 KiB
Rust
use crate::editor::{BackspaceKind, Input};
|
|
use crate::files::{FilePicker, FilePresentation, FileView};
|
|
use std::{
|
|
collections::{BTreeMap, 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,
|
|
Open,
|
|
Closed,
|
|
All,
|
|
}
|
|
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",
|
|
Self::Open => "Open",
|
|
Self::Closed => "Closed",
|
|
Self::All => "All",
|
|
}
|
|
}
|
|
}
|
|
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, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum ComparisonMode {
|
|
#[default]
|
|
BranchChanges,
|
|
Snapshots,
|
|
}
|
|
impl ComparisonMode {
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::BranchChanges => "Branch changes",
|
|
Self::Snapshots => "Snapshots",
|
|
}
|
|
}
|
|
}
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum RevisionTarget {
|
|
Working,
|
|
Commit(String),
|
|
Remote {
|
|
remote: String,
|
|
reference: String,
|
|
},
|
|
RemoteCommit {
|
|
remote: String,
|
|
reference: String,
|
|
id: String,
|
|
},
|
|
PullRequest {
|
|
remote: String,
|
|
base_ref: String,
|
|
base_sha: String,
|
|
head_ref: String,
|
|
head_sha: String,
|
|
},
|
|
UnavailablePullRequest(String),
|
|
ListRemote(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,
|
|
/// False means text, EOF flags, hunks and notices have not been prepared.
|
|
pub loaded: bool,
|
|
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,
|
|
/// Eligible regular-file After paths; loaded binary details are excluded.
|
|
pub editable_paths: BTreeSet<PathBuf>,
|
|
}
|
|
#[derive(Debug)]
|
|
pub enum Operation {
|
|
Discover,
|
|
PullRequests(String),
|
|
ListRemote(String),
|
|
FetchRemote(String),
|
|
Compare {
|
|
before: Revision,
|
|
after: Revision,
|
|
mode: ComparisonMode,
|
|
buffers: Box<[(PathBuf, String)]>,
|
|
},
|
|
RefreshCheckout {
|
|
comparison_id: u64,
|
|
follow_working: bool,
|
|
buffers: Box<[(PathBuf, String)]>,
|
|
},
|
|
LoadFile {
|
|
comparison_id: u64,
|
|
file_index: usize,
|
|
},
|
|
}
|
|
#[derive(Debug)]
|
|
pub enum Outcome {
|
|
Catalog(Catalog),
|
|
Comparison(Comparison),
|
|
CheckoutRefresh(Option<Comparison>),
|
|
File {
|
|
comparison_id: u64,
|
|
file_index: usize,
|
|
file: ChangedFile,
|
|
},
|
|
}
|
|
#[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, serde::Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ViewMode {
|
|
Split,
|
|
Unified,
|
|
}
|
|
|
|
#[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,
|
|
/// Changed grapheme ranges, paired as [before, after]; empty on unpaired rows.
|
|
pub changes: Vec<[std::ops::Range<usize>; 2]>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum DisplayRow {
|
|
Line(usize),
|
|
Fold { start: usize, end: usize },
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum RequestKind {
|
|
Catalog,
|
|
Comparison,
|
|
CheckoutRefresh {
|
|
comparison_id: u64,
|
|
},
|
|
File {
|
|
comparison_id: u64,
|
|
file_index: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct DiffState {
|
|
pub catalog: Option<Catalog>,
|
|
pub pull_request_remote: Option<String>,
|
|
pub phase: Phase,
|
|
pub word_wrap: bool,
|
|
pub view_mode: ViewMode,
|
|
pub comparison_mode: ComparisonMode,
|
|
pub split_axis: crate::panes::SplitAxis,
|
|
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 reviewed_files: BTreeSet<usize>,
|
|
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
|
|
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>,
|
|
display_sides: Vec<Option<Focus>>,
|
|
excerpt_ranges: Vec<(usize, usize)>,
|
|
pub live_buffer: Option<usize>,
|
|
pub live_revision: Option<u64>,
|
|
pub follow_caret: bool,
|
|
live_files: BTreeSet<usize>,
|
|
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
|
|
context_lines: usize,
|
|
expand_lines: usize,
|
|
pending: Option<Operation>,
|
|
request_kind: Option<RequestKind>,
|
|
visible: Vec<usize>,
|
|
search_fields: Vec<[String; 3]>,
|
|
search_matches: Vec<([usize; 7], usize)>,
|
|
max_before: usize,
|
|
max_after: usize,
|
|
after_widths: Vec<usize>,
|
|
}
|
|
|
|
impl DiffState {
|
|
pub(crate) fn new() -> Self {
|
|
Self {
|
|
catalog: None,
|
|
pull_request_remote: None,
|
|
phase: Phase::Before,
|
|
word_wrap: false,
|
|
view_mode: ViewMode::Split,
|
|
comparison_mode: ComparisonMode::default(),
|
|
split_axis: crate::panes::SplitAxis::Vertical,
|
|
before: None,
|
|
after: None,
|
|
category: 0,
|
|
query: String::new(),
|
|
selected: 0,
|
|
loading: true,
|
|
error: None,
|
|
comparison: None,
|
|
comparison_id: None,
|
|
reviewed_files: BTreeSet::new(),
|
|
files: None,
|
|
snapshot_roots: [None, None],
|
|
files_visible: true,
|
|
file: 0,
|
|
focus: Focus::Files,
|
|
top: 0,
|
|
left_before: 0,
|
|
left_after: 0,
|
|
rows: Vec::new(),
|
|
display_rows: Vec::new(),
|
|
display_sides: Vec::new(),
|
|
excerpt_ranges: Vec::new(),
|
|
live_buffer: None,
|
|
live_revision: None,
|
|
follow_caret: false,
|
|
live_files: BTreeSet::new(),
|
|
frozen_after: BTreeMap::new(),
|
|
context_lines: 10,
|
|
expand_lines: 15,
|
|
pending: Some(Operation::Discover),
|
|
request_kind: Some(RequestKind::Catalog),
|
|
visible: Vec::new(),
|
|
search_fields: Vec::new(),
|
|
search_matches: Vec::new(),
|
|
max_before: 0,
|
|
max_after: 0,
|
|
after_widths: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn pull_requests(remote: String) -> Self {
|
|
Self {
|
|
pull_request_remote: Some(remote.clone()),
|
|
pending: Some(Operation::PullRequests(remote)),
|
|
..Self::new()
|
|
}
|
|
}
|
|
|
|
pub fn categories(&self) -> &'static [Category] {
|
|
if self.pull_request_remote.is_some() {
|
|
&[Category::Open, Category::Closed, Category::All]
|
|
} else {
|
|
self.catalog
|
|
.as_ref()
|
|
.map_or(&[], |catalog| catalog.kind.categories())
|
|
}
|
|
}
|
|
|
|
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> {
|
|
if !self.request_ready() {
|
|
return None;
|
|
}
|
|
self.pending.take()
|
|
}
|
|
|
|
pub(crate) fn request_queued(&self) -> bool {
|
|
self.pending.is_some()
|
|
}
|
|
|
|
pub(crate) fn request_ready(&self) -> bool {
|
|
self.pending.as_ref().is_some_and(|operation| {
|
|
!self.refresh_pending() || matches!(operation, Operation::RefreshCheckout { .. })
|
|
})
|
|
}
|
|
|
|
pub(crate) fn refresh_pending(&self) -> bool {
|
|
matches!(self.request_kind, Some(RequestKind::CheckoutRefresh { .. }))
|
|
}
|
|
|
|
pub(crate) fn queue_checkout_refresh(&mut self, follow_working: bool) {
|
|
if self.phase != Phase::View
|
|
|| self.loading
|
|
|| self.request_kind.is_some()
|
|
|| self.pending.is_some()
|
|
|| self.comparison.is_none()
|
|
|| self
|
|
.after
|
|
.as_ref()
|
|
.is_none_or(|after| after.target == RevisionTarget::Working && !follow_working)
|
|
{
|
|
return;
|
|
}
|
|
if let Some(comparison_id) = self.comparison_id {
|
|
self.request_kind = Some(RequestKind::CheckoutRefresh { comparison_id });
|
|
self.pending = Some(Operation::RefreshCheckout {
|
|
comparison_id,
|
|
follow_working,
|
|
buffers: Box::new([]),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn queue(&mut self, operation: Operation) {
|
|
if self.refresh_pending()
|
|
&& !matches!(self.pending, Some(Operation::RefreshCheckout { .. }))
|
|
&& matches!(operation, Operation::LoadFile { .. })
|
|
{
|
|
// Finish the refresh before issuing a load against its frozen comparison ID.
|
|
self.pending = Some(operation);
|
|
self.loading = true;
|
|
self.error = None;
|
|
return;
|
|
}
|
|
self.request_kind = Some(match operation {
|
|
Operation::Compare { .. } => RequestKind::Comparison,
|
|
Operation::LoadFile {
|
|
comparison_id,
|
|
file_index,
|
|
} => RequestKind::File {
|
|
comparison_id,
|
|
file_index,
|
|
},
|
|
_ => 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>) -> bool {
|
|
if self.request_kind.is_none() {
|
|
return false;
|
|
}
|
|
if let Some(RequestKind::CheckoutRefresh { comparison_id }) = self.request_kind {
|
|
if self.comparison_id != Some(comparison_id) {
|
|
return false;
|
|
}
|
|
self.request_kind = None;
|
|
let deferred = self.pending.take();
|
|
match result {
|
|
Ok(Outcome::CheckoutRefresh(None)) => {
|
|
if let Some(operation) = deferred {
|
|
self.queue(operation);
|
|
}
|
|
return false;
|
|
}
|
|
Ok(Outcome::CheckoutRefresh(Some(mut comparison))) => {
|
|
let pinned = self
|
|
.after
|
|
.as_ref()
|
|
.is_some_and(|after| after.target != RevisionTarget::Working);
|
|
let view = (self.top, self.left_before, self.left_after);
|
|
if !pinned {
|
|
self.reviewed_files.clear();
|
|
}
|
|
let selected = self
|
|
.comparison
|
|
.as_ref()
|
|
.and_then(|comparison| comparison.files.get(self.file))
|
|
.map(|file| &file.path);
|
|
self.file = selected
|
|
.and_then(|path| {
|
|
comparison.files.iter().position(|file| &file.path == path)
|
|
})
|
|
.unwrap_or(0);
|
|
if pinned {
|
|
self.restore_frozen_after();
|
|
if let Some(old) = self.comparison.take() {
|
|
// Checkout-only refreshes retain the frozen catalog order.
|
|
for (file, replacement) in
|
|
old.files.into_iter().zip(&mut comparison.files)
|
|
{
|
|
if file.loaded {
|
|
*replacement = file;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Some(files) = &mut self.files {
|
|
files.replace_paths(
|
|
comparison
|
|
.files
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, file)| (index, file.path.clone())),
|
|
);
|
|
}
|
|
self.comparison = Some(comparison);
|
|
self.comparison_id = Some(id);
|
|
self.snapshot_roots = [None, None];
|
|
self.frozen_after.clear();
|
|
self.live_files.clear();
|
|
self.live_buffer = None;
|
|
self.live_revision = None;
|
|
self.follow_caret = false;
|
|
self.prepare_selected_file();
|
|
if pinned {
|
|
self.top = view.0.min(self.display_rows.len().saturating_sub(1));
|
|
self.left_before = view.1.min(self.max_before);
|
|
self.left_after = view.2.min(self.max_after);
|
|
}
|
|
self.normalize_focus();
|
|
}
|
|
Err(error) => {
|
|
self.revoke_pinned_after();
|
|
if let Some(operation) = deferred {
|
|
self.queue(operation);
|
|
}
|
|
self.error = Some(error);
|
|
}
|
|
Ok(_) => {
|
|
self.revoke_pinned_after();
|
|
self.error = Some("Unexpected repository response".into());
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if matches!(result, Ok(Outcome::CheckoutRefresh(_))) {
|
|
return false;
|
|
}
|
|
if let Ok(Outcome::File {
|
|
comparison_id,
|
|
file_index,
|
|
..
|
|
}) = &result
|
|
&& (self.comparison_id != Some(*comparison_id)
|
|
|| self.request_kind
|
|
!= Some(RequestKind::File {
|
|
comparison_id: *comparison_id,
|
|
file_index: *file_index,
|
|
}))
|
|
{
|
|
return false;
|
|
}
|
|
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_fields = catalog
|
|
.entries
|
|
.iter()
|
|
.map(|entry| {
|
|
let id = match &entry.target {
|
|
RevisionTarget::Commit(id) => id.as_str(),
|
|
_ => "",
|
|
};
|
|
[
|
|
entry.label.to_lowercase(),
|
|
id.to_lowercase(),
|
|
entry.description.to_lowercase(),
|
|
]
|
|
})
|
|
.collect();
|
|
self.catalog = Some(catalog);
|
|
self.category = self.category.min(self.categories().len() - 1);
|
|
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.frozen_after.clear();
|
|
self.reviewed_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.snapshot_roots = [None, None];
|
|
self.phase = Phase::View;
|
|
self.file = 0;
|
|
self.focus = if self.files_visible {
|
|
Focus::Files
|
|
} else {
|
|
self.first_text_focus()
|
|
};
|
|
self.prepare_selected_file();
|
|
}
|
|
Ok(Outcome::File {
|
|
comparison_id,
|
|
file_index,
|
|
file,
|
|
}) if expected
|
|
== Some(RequestKind::File {
|
|
comparison_id,
|
|
file_index,
|
|
}) =>
|
|
{
|
|
if let Some(comparison) = &mut self.comparison
|
|
&& let Some(selected) = comparison.files.get_mut(file_index)
|
|
&& selected.path == file.path
|
|
&& file.loaded
|
|
{
|
|
if file
|
|
.notice
|
|
.as_deref()
|
|
.is_some_and(|notice| notice.contains("Binary or non-UTF-8"))
|
|
{
|
|
comparison.editable_paths.remove(&file.path);
|
|
}
|
|
*selected = file;
|
|
if self.file == file_index {
|
|
self.align_file();
|
|
self.normalize_focus();
|
|
}
|
|
self.error = None;
|
|
} else {
|
|
self.error = Some("Unexpected changed-file response".into());
|
|
}
|
|
}
|
|
Ok(_) => self.error = Some("Unexpected repository response".into()),
|
|
Err(error) => self.error = Some(error),
|
|
}
|
|
true
|
|
}
|
|
|
|
fn refilter(&mut self) {
|
|
self.visible.clear();
|
|
self.search_matches.clear();
|
|
if let Some(catalog) = &self.catalog {
|
|
let category = self.categories()[self.category];
|
|
let query = self.query.to_lowercase();
|
|
let tokens: Vec<_> = query.split_whitespace().collect();
|
|
// Repository revisions pin Working outside its category and query.
|
|
if self.pull_request_remote.is_none() {
|
|
self.visible.extend(
|
|
catalog
|
|
.entries
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, entry)| entry.target == RevisionTarget::Working)
|
|
.map(|(index, _)| index),
|
|
);
|
|
}
|
|
for (index, entry) in catalog.entries.iter().enumerate() {
|
|
if entry.target == RevisionTarget::Working
|
|
|| (entry.category != category
|
|
&& !(category == Category::All
|
|
&& matches!(entry.category, Category::Open | Category::Closed)))
|
|
{
|
|
continue;
|
|
}
|
|
if tokens.is_empty() {
|
|
self.visible.push(index);
|
|
continue;
|
|
}
|
|
let score = tokens.iter().try_fold([0usize; 7], |mut score, token| {
|
|
let best = self.search_fields[index]
|
|
.iter()
|
|
.enumerate()
|
|
.filter_map(|(field_index, field)| {
|
|
revision_match(field, token).map(
|
|
|[kind, boundaries, gaps, start, len]| {
|
|
[
|
|
kind,
|
|
usize::from(field_index == 2),
|
|
boundaries,
|
|
gaps,
|
|
start,
|
|
len,
|
|
]
|
|
},
|
|
)
|
|
})
|
|
.min()?;
|
|
// A scattered token never outranks an all-contiguous match.
|
|
score[0] = score[0].max(best[0]);
|
|
for (total, value) in score[1..].iter_mut().zip(best) {
|
|
*total += value;
|
|
}
|
|
Some(score)
|
|
});
|
|
if let Some(score) = score {
|
|
self.search_matches.push((score, index));
|
|
}
|
|
}
|
|
self.search_matches.sort_unstable();
|
|
self.visible
|
|
.extend(self.search_matches.iter().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 selector filtering and comparison navigation; the editor owns selector closing.
|
|
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
|
|
{
|
|
if files.searching && files.view == FileView::Tree {
|
|
files.finish_search();
|
|
} else {
|
|
files.focused = false;
|
|
self.files_visible = files.presentation == FilePresentation::Docked;
|
|
self.focus = self.first_text_focus();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if self.phase == Phase::View {
|
|
self.handle_view(input);
|
|
return;
|
|
}
|
|
match input {
|
|
Input::Char('\t') | Input::BackTab => {
|
|
let count = self.categories().len();
|
|
if count > 0 {
|
|
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 if BackspaceKind::from_input(&input).is_some() => {
|
|
if BackspaceKind::from_input(&input)
|
|
.expect("guard checked backspace input")
|
|
.truncate(&mut self.query)
|
|
{
|
|
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 {
|
|
// Listing can be retried without closing the modal.
|
|
if self.catalog.is_none() {
|
|
self.queue(match &self.pull_request_remote {
|
|
Some(remote) => Operation::PullRequests(remote.clone()),
|
|
None => Operation::Discover,
|
|
});
|
|
}
|
|
return;
|
|
};
|
|
if let RevisionTarget::UnavailablePullRequest(error) = &revision.target {
|
|
self.before = None;
|
|
self.after = None;
|
|
self.error = Some(error.clone());
|
|
return;
|
|
}
|
|
if revision.target == RevisionTarget::Working {
|
|
self.comparison_mode = ComparisonMode::Snapshots;
|
|
}
|
|
if let RevisionTarget::PullRequest {
|
|
remote,
|
|
base_ref,
|
|
base_sha,
|
|
head_ref,
|
|
head_sha,
|
|
} = &revision.target
|
|
{
|
|
self.before = Some(Revision {
|
|
target: RevisionTarget::RemoteCommit {
|
|
remote: remote.clone(),
|
|
reference: base_ref.clone(),
|
|
id: base_sha.clone(),
|
|
},
|
|
label: base_ref.clone(),
|
|
description: String::new(),
|
|
category: revision.category,
|
|
});
|
|
self.after = Some(Revision {
|
|
target: RevisionTarget::RemoteCommit {
|
|
remote: remote.clone(),
|
|
reference: head_ref.clone(),
|
|
id: head_sha.clone(),
|
|
},
|
|
..revision
|
|
});
|
|
self.comparison_mode = ComparisonMode::BranchChanges;
|
|
self.queue_comparison();
|
|
} else if let RevisionTarget::FetchRemote(remote) = &revision.target {
|
|
self.queue(Operation::FetchRemote(remote.clone()));
|
|
} else if let RevisionTarget::ListRemote(remote) = &revision.target {
|
|
self.queue(Operation::ListRemote(remote.clone()));
|
|
} else if self.phase == Phase::Before {
|
|
self.before = Some(revision);
|
|
self.after = None;
|
|
self.phase = Phase::After;
|
|
self.query.clear();
|
|
self.refilter();
|
|
self.error = None;
|
|
} else {
|
|
self.after = Some(revision);
|
|
self.queue_comparison();
|
|
}
|
|
}
|
|
|
|
pub(crate) fn working_selected(&self) -> bool {
|
|
[&self.before, &self.after]
|
|
.into_iter()
|
|
.flatten()
|
|
.any(|revision| revision.target == RevisionTarget::Working)
|
|
}
|
|
|
|
pub(crate) fn toggle_comparison_mode(&mut self) -> Result<(), &'static str> {
|
|
if self.pull_request_remote.is_some() && self.after.is_none() {
|
|
return Err("Select a pull request before changing comparison mode");
|
|
}
|
|
if self.working_selected() {
|
|
return Err("Working checkout requires Snapshots");
|
|
}
|
|
self.comparison_mode = match self.comparison_mode {
|
|
ComparisonMode::BranchChanges => ComparisonMode::Snapshots,
|
|
ComparisonMode::Snapshots => ComparisonMode::BranchChanges,
|
|
};
|
|
self.queue_comparison();
|
|
Ok(())
|
|
}
|
|
|
|
fn queue_comparison(&mut self) {
|
|
if let (Some(before), Some(after)) = (&self.before, &self.after) {
|
|
let operation = Operation::Compare {
|
|
before: before.clone(),
|
|
after: after.clone(),
|
|
mode: self.comparison_mode,
|
|
buffers: Box::new([]),
|
|
};
|
|
self.phase = Phase::After;
|
|
self.comparison = None;
|
|
self.comparison_id = None;
|
|
self.reviewed_files.clear();
|
|
self.queue(operation);
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
if !file.loaded {
|
|
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,
|
|
};
|
|
let columns = if self.pane_layout() == PaneLayout::Split {
|
|
self.max_before.max(self.max_after)
|
|
} else {
|
|
columns
|
|
};
|
|
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);
|
|
if self.pane_layout() == PaneLayout::Split {
|
|
self.left_before = left;
|
|
self.left_after = left;
|
|
} else {
|
|
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,
|
|
(Focus::Before | Focus::After, PaneLayout::Empty)
|
|
if self
|
|
.comparison
|
|
.as_ref()
|
|
.is_some_and(|comparison| !comparison.files.is_empty())
|
|
&& !self.selected_file_loaded() =>
|
|
{
|
|
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
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
fn revoke_pinned_after(&mut self) {
|
|
if self
|
|
.after
|
|
.as_ref()
|
|
.is_none_or(|after| after.target == RevisionTarget::Working)
|
|
{
|
|
return;
|
|
}
|
|
if let Some(comparison) = &mut self.comparison {
|
|
comparison.editable_paths.clear();
|
|
}
|
|
self.restore_frozen_after();
|
|
self.live_files.clear();
|
|
self.live_buffer = None;
|
|
self.live_revision = None;
|
|
self.follow_caret = false;
|
|
self.rebuild_file(false);
|
|
self.refresh_files();
|
|
}
|
|
|
|
fn restore_frozen_after(&mut self) {
|
|
if let Some(comparison) = &mut self.comparison {
|
|
for (index, (after, eof)) in std::mem::take(&mut self.frozen_after) {
|
|
if let Some(file) = comparison.files.get_mut(index) {
|
|
file.after = after;
|
|
file.after_eof = eof;
|
|
file.hunks =
|
|
line_hunks(&file.before, file.before_eof, &file.after, file.after_eof);
|
|
update_eof_notice(file);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn after_editable(&self) -> bool {
|
|
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
|
|
&& self.live_after_available()
|
|
}
|
|
|
|
pub(crate) fn live_after_available(&self) -> bool {
|
|
self.phase == Phase::View
|
|
&& self.selected_file_loaded()
|
|
&& self.comparison.as_ref().is_some_and(|comparison| {
|
|
comparison.files.get(self.file).is_some_and(|file| {
|
|
comparison.editable_paths.contains(&file.path)
|
|
&& file.status != FileStatus::Removed
|
|
&& file
|
|
.notice
|
|
.as_deref()
|
|
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
|
|
})
|
|
})
|
|
}
|
|
|
|
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
|
|
let comparison = self
|
|
.comparison
|
|
.as_ref()
|
|
.filter(|_| self.phase == Phase::View)
|
|
.ok_or("No comparison is open")?;
|
|
let index = if self.focus == Focus::Files {
|
|
let entry = self
|
|
.files
|
|
.as_ref()
|
|
.filter(|_| self.files_visible)
|
|
.and_then(|files| files.entries().nth(files.selected))
|
|
.ok_or("No changed file is selected")?;
|
|
if entry.is_dir {
|
|
return Err("Select a changed file, not a directory");
|
|
}
|
|
entry
|
|
.catalog_index
|
|
.filter(|&index| {
|
|
comparison
|
|
.files
|
|
.get(index)
|
|
.is_some_and(|file| file.path == entry.path)
|
|
})
|
|
.ok_or("No changed file is selected")?
|
|
} else {
|
|
self.file
|
|
};
|
|
if comparison.files.get(index).is_none() {
|
|
return Err("No changed file is selected");
|
|
}
|
|
if self.reviewed_files.remove(&index) {
|
|
Ok(false)
|
|
} else {
|
|
self.reviewed_files.insert(index);
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
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.select_file_index(index)
|
|
}
|
|
|
|
pub(crate) fn select_file_index(&mut self, index: usize) -> bool {
|
|
let Some(file) = self
|
|
.comparison
|
|
.as_ref()
|
|
.and_then(|comparison| comparison.files.get(index))
|
|
else {
|
|
return false;
|
|
};
|
|
if let Some(files) = &mut self.files {
|
|
files.reveal_path(&file.path);
|
|
}
|
|
self.file = index;
|
|
self.normalize_focus();
|
|
self.live_buffer = None;
|
|
self.live_revision = None;
|
|
self.follow_caret = false;
|
|
self.prepare_selected_file();
|
|
true
|
|
}
|
|
|
|
pub fn selected_file_loaded(&self) -> bool {
|
|
self.comparison
|
|
.as_ref()
|
|
.and_then(|comparison| comparison.files.get(self.file))
|
|
.is_some_and(|file| file.loaded)
|
|
}
|
|
|
|
/// Source rows without the editor's virtual terminal row, preserving real empty lines.
|
|
pub(crate) fn after_source_lines<'a>(&self, file: &'a ChangedFile) -> &'a [String] {
|
|
if self.live_files.contains(&self.file) {
|
|
live_content(&file.after, file.after_eof)
|
|
} else {
|
|
&file.after
|
|
}
|
|
}
|
|
|
|
fn prepare_selected_file(&mut self) {
|
|
if self.refresh_pending() {
|
|
self.align_file();
|
|
if !self.selected_file_loaded() {
|
|
self.load_file(self.file);
|
|
} else if matches!(self.pending, Some(Operation::LoadFile { .. })) {
|
|
self.pending = None;
|
|
self.loading = false;
|
|
}
|
|
return;
|
|
}
|
|
self.pending = None;
|
|
self.request_kind = None;
|
|
self.loading = false;
|
|
self.error = None;
|
|
if self.selected_file_loaded() {
|
|
self.align_file();
|
|
} else {
|
|
self.align_file();
|
|
if let Some(comparison_id) = self.comparison_id
|
|
&& self
|
|
.comparison
|
|
.as_ref()
|
|
.is_some_and(|comparison| !comparison.files.is_empty())
|
|
{
|
|
self.queue(Operation::LoadFile {
|
|
comparison_id,
|
|
file_index: self.file,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn load_file(&mut self, file_index: usize) {
|
|
if let Some(comparison_id) = self.comparison_id {
|
|
self.queue(Operation::LoadFile {
|
|
comparison_id,
|
|
file_index,
|
|
});
|
|
}
|
|
}
|
|
|
|
pub(crate) fn source_range_changed(
|
|
&self,
|
|
file_index: usize,
|
|
focus: Focus,
|
|
first: usize,
|
|
last: usize,
|
|
) -> bool {
|
|
if file_index == self.file {
|
|
return self.rows.iter().any(|aligned| {
|
|
aligned.kind != RowKind::Equal
|
|
&& match focus {
|
|
Focus::Before => aligned
|
|
.before
|
|
.is_some_and(|row| first <= row && row <= last),
|
|
Focus::After => {
|
|
aligned.after.is_some_and(|row| first <= row && row <= last)
|
|
}
|
|
Focus::Files => false,
|
|
}
|
|
});
|
|
}
|
|
self.comparison
|
|
.as_ref()
|
|
.and_then(|comparison| comparison.files.get(file_index))
|
|
.is_some_and(|file| {
|
|
file.hunks.iter().any(|hunk| {
|
|
let (start, len) = match focus {
|
|
Focus::Before => (hunk.before_start, hunk.before_len),
|
|
Focus::After => (hunk.after_start, hunk.after_len),
|
|
Focus::Files => return false,
|
|
};
|
|
len > 0 && start <= last && first < start.saturating_add(len)
|
|
})
|
|
})
|
|
}
|
|
|
|
pub(crate) fn reveal_source_row(&mut self, focus: Focus, row: usize) {
|
|
if self.source_display_row(focus, row).is_none()
|
|
&& let Some(index) = self.rows.iter().position(|aligned| match focus {
|
|
Focus::Before => aligned.before == Some(row),
|
|
Focus::After => aligned.after == Some(row),
|
|
Focus::Files => false,
|
|
})
|
|
{
|
|
self.excerpt_ranges.push((index, index + 1));
|
|
self.excerpt_ranges.sort_unstable();
|
|
self.rebuild_display_rows();
|
|
}
|
|
if let Some(display) = self.source_display_row(focus, row) {
|
|
self.top = display;
|
|
self.follow_caret = true;
|
|
}
|
|
}
|
|
|
|
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
|
|
(0..self.display_rows.len())
|
|
.position(|display| self.display_source_row(focus, display) == Some(source_row))
|
|
}
|
|
|
|
pub(crate) fn display_focus(&self, display: usize) -> Option<Focus> {
|
|
self.display_sides.get(display).copied().flatten()
|
|
}
|
|
|
|
pub(crate) fn display_source_row(&self, focus: Focus, display: usize) -> Option<usize> {
|
|
if self
|
|
.display_focus(display)
|
|
.is_some_and(|side| side != focus)
|
|
{
|
|
return None;
|
|
}
|
|
match *self.display_rows.get(display)? {
|
|
DisplayRow::Line(index) => self.source_row(focus, index),
|
|
DisplayRow::Fold { .. } => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn set_view_mode(&mut self, view_mode: ViewMode) {
|
|
if self.view_mode == view_mode {
|
|
return;
|
|
}
|
|
let anchor = self.display_rows.get(self.top).copied();
|
|
let side = self.display_focus(self.top).or_else(|| {
|
|
[self.focus, Focus::After, Focus::Before]
|
|
.into_iter()
|
|
.find(|&focus| self.display_source_row(focus, self.top).is_some())
|
|
});
|
|
self.view_mode = view_mode;
|
|
self.rebuild_display_rows();
|
|
self.restore_top(anchor, side);
|
|
}
|
|
|
|
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_at(&self, file_index: usize, focus: Focus) -> Vec<String> {
|
|
let file = &self.comparison.as_ref().expect("snapshot comparison").files[file_index];
|
|
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(&file_index),
|
|
),
|
|
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()
|
|
.enumerate()
|
|
.filter_map(|(display, row)| match *row {
|
|
DisplayRow::Line(index) => self
|
|
.display_source_row(to, display)
|
|
.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>, side: Option<Focus>) {
|
|
if let Some(anchor) = anchor {
|
|
let index = match anchor {
|
|
DisplayRow::Line(index) => index,
|
|
DisplayRow::Fold { start, .. } => start,
|
|
};
|
|
self.top = self
|
|
.display_rows
|
|
.iter()
|
|
.enumerate()
|
|
.position(|(display, row)| match *row {
|
|
DisplayRow::Line(row) => {
|
|
row == index
|
|
&& side.is_none_or(|side| {
|
|
self.display_focus(display)
|
|
.is_none_or(|focus| focus == side)
|
|
})
|
|
}
|
|
DisplayRow::Fold { start, end } => start <= index && index < end,
|
|
})
|
|
.unwrap_or(0);
|
|
}
|
|
}
|
|
|
|
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
|
|
if !self.live_after_available() {
|
|
return;
|
|
}
|
|
if self.live_revision == Some(revision) {
|
|
return;
|
|
}
|
|
let file = &self.comparison.as_ref().unwrap().files[self.file];
|
|
if self
|
|
.after
|
|
.as_ref()
|
|
.is_some_and(|after| after.target != RevisionTarget::Working)
|
|
{
|
|
self.frozen_after
|
|
.entry(self.file)
|
|
.or_insert_with(|| (file.after.clone(), file.after_eof));
|
|
}
|
|
let was_live = self.live_files.contains(&self.file);
|
|
let changed_lines = changed_line_range(&file.after, lines);
|
|
if self.reviewed_files.contains(&self.file) {
|
|
let unchanged = if was_live {
|
|
changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2
|
|
} else {
|
|
let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty);
|
|
file.after_eof == eof && file.after == live_content(lines, eof)
|
|
};
|
|
if !unchanged {
|
|
self.reviewed_files.remove(&self.file);
|
|
}
|
|
}
|
|
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
|
|
self.live_revision = Some(revision);
|
|
return;
|
|
}
|
|
if was_live && stable_after_edit(file, lines, changed_lines) {
|
|
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
|
replace_after_lines(
|
|
&mut file.after,
|
|
&mut self.after_widths,
|
|
&mut self.max_after,
|
|
lines,
|
|
changed_lines,
|
|
);
|
|
refresh_character_changes(&mut self.rows, file, changed_lines.0..changed_lines.2);
|
|
self.left_after = self.left_after.min(self.max_after);
|
|
self.live_revision = Some(revision);
|
|
return;
|
|
}
|
|
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_fold = self
|
|
.display_rows
|
|
.get(self.top)
|
|
.and_then(|display| match *display {
|
|
DisplayRow::Fold { start, .. } => Some(start),
|
|
DisplayRow::Line(_) => None,
|
|
});
|
|
let top_before = self
|
|
.display_source_row(Focus::Before, self.top)
|
|
.or_else(|| top_fold.and_then(|index| self.rows[index].before));
|
|
let top_after = self
|
|
.display_source_row(Focus::After, self.top)
|
|
.or_else(|| top_fold.and_then(|index| self.rows[index].after))
|
|
.map(map_after);
|
|
let was_changed = file_is_changed(file);
|
|
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
|
|
replace_after_lines(
|
|
&mut file.after,
|
|
&mut self.after_widths,
|
|
&mut self.max_after,
|
|
lines,
|
|
changed_lines,
|
|
);
|
|
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)), Some(Focus::Before));
|
|
}
|
|
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.display_sides.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;
|
|
self.after_widths.clear();
|
|
}
|
|
let Some(file) = self
|
|
.comparison
|
|
.as_ref()
|
|
.and_then(|comparison| comparison.files.get(self.file))
|
|
.filter(|file| file.loaded)
|
|
else {
|
|
self.after_widths.clear();
|
|
self.max_before = 0;
|
|
self.max_after = 0;
|
|
return;
|
|
};
|
|
if reset {
|
|
self.max_before = file
|
|
.before
|
|
.iter()
|
|
.map(|line| line_width(line))
|
|
.max()
|
|
.unwrap_or(0);
|
|
self.after_widths
|
|
.extend(file.after.iter().map(|line| line_width(line)));
|
|
self.max_after = self.after_widths.iter().copied().max().unwrap_or(0);
|
|
}
|
|
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,
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
refresh_character_changes(&mut self.rows, file, 0..file.after.len());
|
|
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();
|
|
self.display_sides.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,
|
|
});
|
|
if self.view_mode == ViewMode::Unified {
|
|
self.display_sides.push(None);
|
|
}
|
|
}
|
|
let mut index = start;
|
|
while index < next_end {
|
|
if self.view_mode == ViewMode::Split || self.rows[index].kind == RowKind::Equal {
|
|
self.display_rows.push(DisplayRow::Line(index));
|
|
if self.view_mode == ViewMode::Unified {
|
|
self.display_sides.push(None);
|
|
}
|
|
index += 1;
|
|
continue;
|
|
}
|
|
let run_start = index;
|
|
while index < next_end && self.rows[index].kind != RowKind::Equal {
|
|
index += 1;
|
|
}
|
|
// Unified hunks show all removals before all additions, without alignment holes.
|
|
for side in [Focus::Before, Focus::After] {
|
|
for aligned in run_start..index {
|
|
if self.source_row(side, aligned).is_some() {
|
|
self.display_rows.push(DisplayRow::Line(aligned));
|
|
self.display_sides.push(Some(side));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
end = next_end;
|
|
}
|
|
if end < self.rows.len() {
|
|
self.display_rows.push(DisplayRow::Fold {
|
|
start: end,
|
|
end: self.rows.len(),
|
|
});
|
|
if self.view_mode == ViewMode::Unified {
|
|
self.display_sides.push(None);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 top_side = self.display_focus(self.top);
|
|
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.restore_top(Some(DisplayRow::Line(anchor)), top_side);
|
|
}
|
|
}
|
|
|
|
// Match class, missed word/reference boundaries, gaps, start, field length.
|
|
fn revision_match(field: &str, token: &str) -> Option<[usize; 5]> {
|
|
if field == token {
|
|
return Some([0, 0, 0, 0, field.len()]);
|
|
}
|
|
if field.starts_with(token) {
|
|
return Some([1, 0, 0, 0, field.len()]);
|
|
}
|
|
let contiguous = field
|
|
.match_indices(token)
|
|
.map(|(start, _)| {
|
|
let boundary = field[..start]
|
|
.chars()
|
|
.next_back()
|
|
.is_none_or(|ch| !ch.is_alphanumeric());
|
|
[2, usize::from(!boundary), 0, start, field.len()]
|
|
})
|
|
.min();
|
|
if contiguous.is_some() {
|
|
return contiguous;
|
|
}
|
|
|
|
// ponytail: greedy subsequences can miss tighter later alignments; use dynamic
|
|
// programming if that ranking precision becomes necessary.
|
|
let mut wanted = token.chars();
|
|
let mut next = wanted.next()?;
|
|
let mut start = 0;
|
|
let mut matched = 0;
|
|
let mut boundaries = 0;
|
|
let mut previous_is_word = false;
|
|
for (position, ch) in field.chars().enumerate() {
|
|
if ch == next {
|
|
if matched == 0 {
|
|
start = position;
|
|
}
|
|
matched += 1;
|
|
boundaries += usize::from(previous_is_word);
|
|
if let Some(ch) = wanted.next() {
|
|
next = ch;
|
|
} else {
|
|
return Some([
|
|
3,
|
|
boundaries,
|
|
position - start + 1 - matched,
|
|
start,
|
|
field.len(),
|
|
]);
|
|
}
|
|
}
|
|
previous_is_word = ch.is_alphanumeric();
|
|
}
|
|
None
|
|
}
|
|
|
|
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,
|
|
changes: Vec::new(),
|
|
});
|
|
}
|
|
}
|
|
|
|
fn refresh_character_changes(
|
|
rows: &mut [AlignedRow],
|
|
file: &ChangedFile,
|
|
after_rows: std::ops::Range<usize>,
|
|
) {
|
|
for row in rows {
|
|
if row.kind == RowKind::Changed
|
|
&& let Some(after) = row.after.filter(|index| after_rows.contains(index))
|
|
{
|
|
row.changes = character_changes(
|
|
&file.before[row.before.expect("paired changed row")],
|
|
&file.after[after],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range<usize>; 2]> {
|
|
if before == after {
|
|
return Vec::new();
|
|
}
|
|
let before: Vec<_> = before.graphemes(true).collect();
|
|
let after: Vec<_> = after.graphemes(true).collect();
|
|
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([before, after])
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn line_width(line: &str) -> usize {
|
|
#[cfg(test)]
|
|
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
|
|
line.graphemes(true).fold(0, |column, grapheme| {
|
|
column
|
|
+ if grapheme == "\t" {
|
|
4 - column % 4
|
|
} else {
|
|
UnicodeWidthStr::width(grapheme)
|
|
}
|
|
})
|
|
}
|
|
|
|
fn changed_line_range(old: &[String], new: &[String]) -> (usize, usize, usize) {
|
|
let start = old
|
|
.iter()
|
|
.zip(new)
|
|
.take_while(|(old, new)| old == new)
|
|
.count();
|
|
let suffix = old[start..]
|
|
.iter()
|
|
.rev()
|
|
.zip(new[start..].iter().rev())
|
|
.take_while(|(old, new)| old == new)
|
|
.count();
|
|
(start, old.len() - suffix, new.len() - suffix)
|
|
}
|
|
|
|
fn stable_after_edit(
|
|
file: &ChangedFile,
|
|
lines: &[String],
|
|
(start, old_end, new_end): (usize, usize, usize),
|
|
) -> bool {
|
|
if old_end != start + 1
|
|
|| new_end != start + 1
|
|
|| file.after_eof != (lines.len() > 1 && lines.last().is_some_and(String::is_empty))
|
|
|| live_content(&file.after, file.after_eof).len()
|
|
!= live_content(lines, file.after_eof).len()
|
|
{
|
|
return false;
|
|
}
|
|
let old = &file.after[start];
|
|
let new = &lines[start];
|
|
// With neither token matching another line, the equality graph seen by Myers
|
|
// is unchanged. Hunks, alignment, excerpts and scroll anchors remain exact.
|
|
!file.before.iter().any(|line| line == old || line == new)
|
|
&& !file
|
|
.after
|
|
.iter()
|
|
.enumerate()
|
|
.any(|(index, line)| index != start && (line == old || line == new))
|
|
}
|
|
|
|
fn replace_after_lines(
|
|
after: &mut Vec<String>,
|
|
widths: &mut Vec<usize>,
|
|
max_width: &mut usize,
|
|
lines: &[String],
|
|
(start, old_end, new_end): (usize, usize, usize),
|
|
) {
|
|
let removed_max = widths[start..old_end].iter().copied().max().unwrap_or(0);
|
|
if old_end == new_end {
|
|
for index in start..new_end {
|
|
if after[index] != lines[index] {
|
|
after[index].clone_from(&lines[index]);
|
|
widths[index] = line_width(&lines[index]);
|
|
}
|
|
}
|
|
} else {
|
|
after.splice(start..old_end, lines[start..new_end].iter().cloned());
|
|
widths.splice(
|
|
start..old_end,
|
|
lines[start..new_end].iter().map(|line| line_width(line)),
|
|
);
|
|
}
|
|
let added_max = widths[start..new_end].iter().copied().max().unwrap_or(0);
|
|
*max_width = if removed_max == *max_width && added_max < removed_max {
|
|
widths.iter().copied().max().unwrap_or(0)
|
|
} else {
|
|
(*max_width).max(added_max)
|
|
};
|
|
}
|
|
|
|
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.loaded
|
|
|| 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};
|
|
std::thread_local! {
|
|
pub(super) static WIDTH_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
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
|
|
},
|
|
changes: Vec::new(),
|
|
})
|
|
.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 pull_request_catalog() -> Catalog {
|
|
Catalog {
|
|
kind: RepoKind::Git,
|
|
entries: [
|
|
("#1 Fix Ünicode", Category::Open),
|
|
("#2 Fix Ünicode", Category::Closed),
|
|
("#3 Other work", Category::Open),
|
|
]
|
|
.into_iter()
|
|
.map(|(label, category)| {
|
|
revision(
|
|
RevisionTarget::PullRequest {
|
|
remote: "origin".into(),
|
|
base_ref: "refs/heads/main".into(),
|
|
base_sha: "base-sha".into(),
|
|
head_ref: "refs/pull/1/head".into(),
|
|
head_sha: "head-sha".into(),
|
|
},
|
|
label,
|
|
"Alice · main ← topic",
|
|
category,
|
|
)
|
|
})
|
|
.collect(),
|
|
notice: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pull_request_categories_keep_query_and_select_both_pinned_branch_tips() {
|
|
let mut state = DiffState::pull_requests("origin".into());
|
|
assert_eq!(
|
|
state.categories(),
|
|
[Category::Open, Category::Closed, Category::All]
|
|
);
|
|
state.take_request();
|
|
state.handle(Input::Char('\t'));
|
|
for ch in "FIX üni Alice".chars() {
|
|
state.handle(Input::Char(ch));
|
|
}
|
|
state.apply_result(1, Ok(Outcome::Catalog(pull_request_catalog())));
|
|
assert_eq!(
|
|
state
|
|
.entries()
|
|
.map(|entry| entry.label.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["#2 Fix Ünicode"]
|
|
);
|
|
state.handle(Input::Char('\t'));
|
|
assert_eq!(state.entries().len(), 2);
|
|
state.handle(Input::Char('\t'));
|
|
assert_eq!(state.query, "FIX üni Alice");
|
|
assert_eq!(
|
|
state
|
|
.entries()
|
|
.map(|entry| entry.label.as_str())
|
|
.collect::<Vec<_>>(),
|
|
["#1 Fix Ünicode"]
|
|
);
|
|
state.handle(Input::Ctrl('u'));
|
|
assert_eq!(state.entries().len(), 2);
|
|
state.comparison_mode = ComparisonMode::Snapshots;
|
|
state.handle(Input::Enter);
|
|
assert_eq!(state.phase, Phase::After);
|
|
let Operation::Compare {
|
|
before,
|
|
after,
|
|
mode,
|
|
buffers,
|
|
} = state.take_request().unwrap()
|
|
else {
|
|
panic!("expected direct paired comparison");
|
|
};
|
|
assert_eq!(mode, ComparisonMode::BranchChanges);
|
|
assert!(buffers.is_empty());
|
|
assert_eq!(before.label, "refs/heads/main");
|
|
assert_eq!(after.label, "#1 Fix Ünicode");
|
|
assert_eq!(
|
|
before.target,
|
|
RevisionTarget::RemoteCommit {
|
|
remote: "origin".into(),
|
|
reference: "refs/heads/main".into(),
|
|
id: "base-sha".into(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
after.target,
|
|
RevisionTarget::RemoteCommit {
|
|
remote: "origin".into(),
|
|
reference: "refs/pull/1/head".into(),
|
|
id: "head-sha".into(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pull_request_loading_retry_and_escape_reject_stale_results() {
|
|
let mut editor = editor();
|
|
editor
|
|
.apply(vec![Effect::Replace("unsaved source".into())])
|
|
.unwrap();
|
|
editor.execute("pull_requests origin");
|
|
let (first, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
|
editor.handle(Input::Ctrl('b'));
|
|
editor.handle(Input::Enter);
|
|
assert!(editor.diff().unwrap().loading);
|
|
assert!(editor.take_diff_request().is_none());
|
|
text(&mut editor, "alice");
|
|
assert!(editor.apply_diff_result(first, Err("API unavailable".into())));
|
|
editor.handle(Input::Enter);
|
|
let (retry, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(operation, Operation::PullRequests(remote) if remote == "origin"));
|
|
assert_eq!(editor.diff().unwrap().query, "alice");
|
|
assert!(!editor.apply_diff_result(first, Ok(Outcome::Catalog(pull_request_catalog()))));
|
|
assert!(editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
|
editor.handle(Input::Enter);
|
|
let (compare, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(
|
|
operation,
|
|
Operation::Compare {
|
|
mode: ComparisonMode::BranchChanges,
|
|
..
|
|
}
|
|
));
|
|
assert!(editor.apply_diff_result(compare, Err("fetch unavailable".into())));
|
|
editor.handle(Input::Enter);
|
|
let (compare_retry, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(operation, Operation::Compare { .. }));
|
|
editor.handle(Input::Escape);
|
|
assert!(editor.diff().is_none());
|
|
assert!(!editor.apply_diff_result(compare_retry, Ok(Outcome::Comparison(comparison()))));
|
|
assert_eq!(editor.lines(), ["unsaved source"]);
|
|
assert!(editor.dirty());
|
|
editor.execute("pull_requests");
|
|
let (reopened, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(operation, Operation::PullRequests(remote) if remote.is_empty()));
|
|
assert!(!editor.apply_diff_result(retry, Ok(Outcome::Catalog(pull_request_catalog()))));
|
|
let mut empty = pull_request_catalog();
|
|
empty.entries.clear();
|
|
assert!(editor.apply_diff_result(reopened, Ok(Outcome::Catalog(empty))));
|
|
editor.handle(Input::Enter);
|
|
assert!(editor.take_diff_request().is_none());
|
|
editor.handle(Input::Escape);
|
|
assert!(editor.diff().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn unavailable_pull_request_does_not_block_other_catalog_entries() {
|
|
let mut state = DiffState::pull_requests("origin".into());
|
|
state.take_request();
|
|
let mut catalog = pull_request_catalog();
|
|
catalog.entries.insert(
|
|
0,
|
|
revision(
|
|
RevisionTarget::UnavailablePullRequest("Historical head no longer retained".into()),
|
|
"#194 Deleted head",
|
|
"unavailable",
|
|
Category::Open,
|
|
),
|
|
);
|
|
state.apply_result(1, Ok(Outcome::Catalog(catalog)));
|
|
state.handle(Input::Enter);
|
|
assert_eq!(
|
|
state.error.as_deref(),
|
|
Some("Historical head no longer retained")
|
|
);
|
|
assert!(state.take_request().is_none());
|
|
state.handle(Input::Down);
|
|
state.handle(Input::Enter);
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::Compare {
|
|
mode: ComparisonMode::BranchChanges,
|
|
..
|
|
})
|
|
));
|
|
state.apply_result(2, Err("fetch failed".into()));
|
|
state.handle(Input::Home);
|
|
state.handle(Input::Enter);
|
|
assert!(state.before.is_none() && state.after.is_none());
|
|
assert!(state.toggle_comparison_mode().is_err());
|
|
assert!(state.take_request().is_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,
|
|
loaded: true,
|
|
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,
|
|
loaded: true,
|
|
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,
|
|
loaded: true,
|
|
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 revision_matches_rank_later_contiguous_boundaries_and_tight_subsequences() {
|
|
assert_eq!(revision_match("r---f---s/topic/rfs", "rfs").unwrap()[0], 2);
|
|
assert!(revision_match("topic/rfs", "rfs") < revision_match("topicxrfs", "rfs"));
|
|
assert!(revision_match("r-f-s", "rfs") < revision_match("r---f---s", "rfs"));
|
|
assert!(revision_match("über-parser", "üpr").is_some());
|
|
assert!(revision_match("parser", "srp").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn jujutsu_fuzzy_selectors_rank_labels_above_descriptions_in_both_stages() {
|
|
for phase in [Phase::Before, Phase::After] {
|
|
for (category_index, &category) in RepoKind::Jujutsu.categories().iter().enumerate() {
|
|
let mut state = DiffState::new();
|
|
state.apply_result(
|
|
1,
|
|
Ok(Outcome::Catalog(Catalog {
|
|
kind: RepoKind::Jujutsu,
|
|
entries: vec![
|
|
revision(
|
|
RevisionTarget::Working,
|
|
"Working checkout",
|
|
"",
|
|
Category::Revisions,
|
|
),
|
|
revision(
|
|
RevisionTarget::Commit("description-target".into()),
|
|
"unrelated",
|
|
"sm/fin-1205-pilot-assignment-schema",
|
|
category,
|
|
),
|
|
revision(
|
|
RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
|
"sm/fin-1205-pilot-assignment-schema",
|
|
"",
|
|
category,
|
|
),
|
|
revision(
|
|
RevisionTarget::Commit("immutable-exact-target".into()),
|
|
"f1205pas",
|
|
"",
|
|
category,
|
|
),
|
|
],
|
|
notice: None,
|
|
})),
|
|
);
|
|
state.phase = phase;
|
|
state.category = category_index;
|
|
state.query = "F1205PAS".into();
|
|
state.refilter();
|
|
assert_eq!(
|
|
state
|
|
.entries()
|
|
.skip(1)
|
|
.map(|entry| &entry.target)
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
&RevisionTarget::Commit("immutable-exact-target".into()),
|
|
&RevisionTarget::Commit("immutable-fuzzy-target".into()),
|
|
&RevisionTarget::Commit("description-target".into()),
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 selector_word_and_line_deletion_preserve_source_in_both_stages() {
|
|
for after in [false, true] {
|
|
let mut editor = editor();
|
|
editor
|
|
.apply(vec![Effect::Replace("source text".into())])
|
|
.unwrap();
|
|
let source = editor.context().text;
|
|
let revision = editor.text_revision();
|
|
let carets = editor.carets();
|
|
open(&mut editor);
|
|
if after {
|
|
editor.handle(Input::Enter);
|
|
}
|
|
let phase = if after { Phase::After } else { Phase::Before };
|
|
text(&mut editor, "FIX missing");
|
|
assert_eq!(editor.diff().unwrap().entries().len(), 1);
|
|
editor.handle(Input::AltBackspace);
|
|
assert_eq!(editor.diff().unwrap().query, "FIX ");
|
|
assert_eq!(editor.diff().unwrap().entries().len(), 2);
|
|
editor.handle(Input::SuperBackspace);
|
|
assert!(editor.diff().unwrap().query.is_empty());
|
|
assert_eq!(
|
|
editor
|
|
.diff()
|
|
.unwrap()
|
|
.entries()
|
|
.map(|entry| entry.label.as_str())
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
"Working checkout",
|
|
"abcdef123",
|
|
"fedcba321",
|
|
"Load history from origin"
|
|
]
|
|
);
|
|
for input in [Input::Backspace, Input::AltBackspace, Input::SuperBackspace] {
|
|
editor.handle(input);
|
|
}
|
|
assert_eq!(editor.diff().unwrap().phase, phase);
|
|
assert_eq!(editor.context().text, source);
|
|
assert_eq!(editor.text_revision(), revision);
|
|
assert_eq!(editor.carets(), carets);
|
|
}
|
|
}
|
|
|
|
#[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.entries().nth(state.selected).unwrap().target,
|
|
RevisionTarget::FetchRemote("origin".into())
|
|
);
|
|
assert_eq!(state.entries().len(), 3);
|
|
let remote_position = state
|
|
.entries()
|
|
.position(|entry| matches!(&entry.target, RevisionTarget::Commit(id) if id == "remote-commit"))
|
|
.unwrap();
|
|
editor.handle(Input::Home);
|
|
for _ in 0..remote_position {
|
|
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 selector_escape_closes_each_stage_and_rejects_late_results() {
|
|
for after in [false, true] {
|
|
let mut editor = editor();
|
|
editor
|
|
.apply(vec![Effect::Replace("unsaved source".into())])
|
|
.unwrap();
|
|
let buffer = editor.buffer_id();
|
|
open(&mut editor);
|
|
let request = if after {
|
|
Some(choose_pair(&mut editor))
|
|
} else {
|
|
text(&mut editor, "query");
|
|
None
|
|
};
|
|
editor.handle(Input::Escape);
|
|
assert!(editor.diff().is_none());
|
|
assert_eq!(editor.buffer_id(), buffer);
|
|
assert_eq!(editor.lines(), ["unsaved source"]);
|
|
assert!(editor.dirty());
|
|
if let Some(id) = request {
|
|
assert!(!editor.apply_diff_result(id, Ok(Outcome::Comparison(comparison()))));
|
|
assert!(editor.diff().is_none());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn selector_printable_input_precedes_normal_bindings_but_not_selection_overrides() {
|
|
for after in [false, true] {
|
|
let mut editor = editor();
|
|
open(&mut editor);
|
|
if after {
|
|
editor.handle(Input::Enter);
|
|
}
|
|
editor
|
|
.bind("normal", "x", Some("core.command".into()))
|
|
.unwrap();
|
|
for ch in ":_| x".chars() {
|
|
editor.handle(Input::Char(ch));
|
|
assert_eq!(editor.mode(), Mode::Normal);
|
|
assert_eq!(editor.pane_layout().len(), 1);
|
|
}
|
|
assert_eq!(editor.diff().unwrap().query, ":_| x");
|
|
editor.handle(Input::Ctrl('u'));
|
|
editor
|
|
.bind("selection", ":", Some("core.down".into()))
|
|
.unwrap();
|
|
editor.handle(Input::Char(':'));
|
|
assert_eq!(editor.diff().unwrap().selected, 1);
|
|
assert!(editor.diff().unwrap().query.is_empty());
|
|
}
|
|
let mut editor = editor();
|
|
open(&mut editor);
|
|
editor.handle(Input::Ctrl('p'));
|
|
editor.handle(Input::Char(':'));
|
|
assert_eq!(editor.palette().unwrap().query, ":");
|
|
assert_eq!(editor.mode(), Mode::Normal);
|
|
editor.handle(Input::Escape);
|
|
assert!(editor.palette().is_none());
|
|
assert!(editor.diff().is_some());
|
|
}
|
|
|
|
#[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 closing_diff_rejects_pending_file_details_even_after_reopening() {
|
|
let mut editor = editor();
|
|
open(&mut editor);
|
|
let comparison_id = choose_pair(&mut editor);
|
|
let mut catalog = comparison();
|
|
let detail = catalog.files.remove(0);
|
|
catalog.files.clear();
|
|
catalog.files.push(ChangedFile {
|
|
path: detail.path.clone(),
|
|
old_path: detail.old_path.clone(),
|
|
status: detail.status,
|
|
loaded: false,
|
|
before: vec![],
|
|
after: vec![],
|
|
before_eof: false,
|
|
after_eof: false,
|
|
hunks: vec![],
|
|
notice: None,
|
|
});
|
|
editor.apply_diff_result(comparison_id, Ok(Outcome::Comparison(catalog)));
|
|
let (file_request, _) = editor.take_diff_request().unwrap();
|
|
editor.execute("diff.close");
|
|
assert!(!editor.apply_diff_result(
|
|
file_request,
|
|
Ok(Outcome::File {
|
|
comparison_id,
|
|
file_index: 0,
|
|
file: detail,
|
|
})
|
|
));
|
|
assert!(editor.diff().is_none());
|
|
open(&mut editor);
|
|
assert!(editor.diff().unwrap().comparison.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn explicit_remote_ref_loader_preserves_selector_position_without_choosing_revision() {
|
|
let mut state = DiffState::new();
|
|
let mut initial = catalog();
|
|
let loader = initial
|
|
.entries
|
|
.iter_mut()
|
|
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
|
|
.unwrap();
|
|
loader.target = RevisionTarget::ListRemote("origin".into());
|
|
loader.label = "Load refs from origin".into();
|
|
state.apply_result(1, Ok(Outcome::Catalog(initial)));
|
|
state.phase = Phase::After;
|
|
state.query = "origin".into();
|
|
state.refilter();
|
|
state.selected = 1;
|
|
state.choose();
|
|
assert!(
|
|
matches!(state.take_request(), Some(Operation::ListRemote(remote)) if remote == "origin")
|
|
);
|
|
let mut loaded = catalog();
|
|
let loader = loaded
|
|
.entries
|
|
.iter_mut()
|
|
.find(|entry| matches!(entry.target, RevisionTarget::FetchRemote(_)))
|
|
.unwrap();
|
|
loader.target = RevisionTarget::ListRemote("origin".into());
|
|
loader.label = "Load refs from origin".into();
|
|
loaded.entries.push(revision(
|
|
RevisionTarget::Remote {
|
|
remote: "origin".into(),
|
|
reference: "refs/heads/new".into(),
|
|
},
|
|
"new@origin",
|
|
"",
|
|
Category::Branches,
|
|
));
|
|
state.apply_result(2, Ok(Outcome::Catalog(loaded)));
|
|
assert_eq!(state.phase, Phase::After);
|
|
assert_eq!(state.query, "origin");
|
|
assert_eq!(state.category, 0);
|
|
assert_eq!(state.selected, 1);
|
|
assert!(state.after.is_none());
|
|
assert!(!state.loading);
|
|
state.category = 1;
|
|
state.refilter();
|
|
assert!(
|
|
state
|
|
.entries()
|
|
.any(|entry| matches!(entry.target, RevisionTarget::Remote { .. }))
|
|
);
|
|
}
|
|
|
|
#[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_none());
|
|
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,
|
|
loaded: true,
|
|
}],
|
|
});
|
|
state.files = Some(FilePicker::from_paths(
|
|
PathBuf::new(),
|
|
[(0, PathBuf::from("file"))],
|
|
));
|
|
state.align_file();
|
|
state
|
|
}
|
|
|
|
#[test]
|
|
fn working_refresh_preserves_noops_errors_and_view_state_but_invalidates_old_identity() {
|
|
let mut state = live_state(vec!["old".into()], vec!["new".into()], false);
|
|
state.loading = false;
|
|
state.pending = None;
|
|
state.request_kind = None;
|
|
state.comparison_id = Some(7);
|
|
state.word_wrap = true;
|
|
state.view_mode = ViewMode::Unified;
|
|
state.comparison_mode = ComparisonMode::Snapshots;
|
|
state.split_axis = crate::panes::SplitAxis::Horizontal;
|
|
state.files_visible = false;
|
|
state.reviewed_files.insert(0);
|
|
state.error = Some("previous notice".into());
|
|
let files = state.files.as_mut().unwrap();
|
|
files.set_view(FileView::Tree);
|
|
files.start_search();
|
|
files.query = "file".into();
|
|
files.refilter();
|
|
state.queue_checkout_refresh(true);
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::RefreshCheckout {
|
|
comparison_id: 7,
|
|
..
|
|
})
|
|
));
|
|
assert!(!state.apply_result(8, Ok(Outcome::CheckoutRefresh(None))));
|
|
assert_eq!(state.comparison_id, Some(7));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
|
|
state.queue_checkout_refresh(true);
|
|
state.take_request();
|
|
assert!(state.apply_result(9, Err("refresh failed".into())));
|
|
assert_eq!(state.comparison_id, Some(7));
|
|
assert_eq!(state.comparison.as_ref().unwrap().files[0].after, ["new"]);
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
assert_eq!(state.error.as_deref(), Some("refresh failed"));
|
|
|
|
state.queue_checkout_refresh(true);
|
|
state.take_request();
|
|
let mut replacement = live_state(vec!["old".into()], vec!["ahead".into()], false)
|
|
.comparison
|
|
.take()
|
|
.unwrap();
|
|
replacement.files.insert(0, comparison().files.remove(0));
|
|
assert!(state.apply_result(10, Ok(Outcome::CheckoutRefresh(Some(replacement)))));
|
|
assert_eq!(state.comparison_id, Some(10));
|
|
assert_eq!(state.file, 1);
|
|
assert_eq!(state.focus, Focus::After);
|
|
assert!(state.word_wrap && !state.files_visible);
|
|
assert_eq!(state.split_axis, crate::panes::SplitAxis::Horizontal);
|
|
assert_eq!(state.view_mode, ViewMode::Unified);
|
|
assert_eq!(state.comparison_mode, ComparisonMode::Snapshots);
|
|
assert!(state.reviewed_files.is_empty());
|
|
let files = state.files.as_ref().unwrap();
|
|
assert_eq!(files.query, "file");
|
|
assert_eq!(files.view, FileView::Tree);
|
|
assert!(files.searching);
|
|
assert_eq!(
|
|
files.entries().nth(files.selected).unwrap().path,
|
|
Path::new("file")
|
|
);
|
|
assert!(!state.apply_result(7, Ok(Outcome::CheckoutRefresh(None))));
|
|
assert_eq!(state.comparison_id, Some(10));
|
|
assert_eq!(state.file, 1);
|
|
state.after.as_mut().unwrap().target = RevisionTarget::Commit("historical".into());
|
|
state.queue_checkout_refresh(false);
|
|
assert!(state.request_queued());
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::RefreshCheckout {
|
|
follow_working: false,
|
|
..
|
|
})
|
|
));
|
|
assert!(!state.apply_result(11, Ok(Outcome::CheckoutRefresh(None))));
|
|
state.reviewed_files.insert(1);
|
|
state.queue_checkout_refresh(false);
|
|
state.take_request();
|
|
let mut demoted = live_state(vec!["old".into()], vec!["ahead".into()], false)
|
|
.comparison
|
|
.take()
|
|
.unwrap();
|
|
demoted.files.insert(0, comparison().files.remove(0));
|
|
demoted.editable_paths.clear();
|
|
assert!(state.apply_result(12, Ok(Outcome::CheckoutRefresh(Some(demoted)))));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
|
assert!(!state.live_after_available());
|
|
state
|
|
.comparison
|
|
.as_mut()
|
|
.unwrap()
|
|
.editable_paths
|
|
.insert("file".into());
|
|
state.refresh_after(&["dirty".into()], 1);
|
|
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["dirty"]);
|
|
state.queue_checkout_refresh(false);
|
|
state.take_request();
|
|
assert!(state.apply_result(13, Err("checkout unavailable".into())));
|
|
assert!(!state.live_after_available());
|
|
assert_eq!(state.comparison_id, Some(12));
|
|
assert_eq!(state.comparison.as_ref().unwrap().files[1].after, ["ahead"]);
|
|
state.after.as_mut().unwrap().target = RevisionTarget::Working;
|
|
state.queue_checkout_refresh(false);
|
|
assert!(!state.request_queued());
|
|
state.load_file(0);
|
|
state.queue_checkout_refresh(true);
|
|
assert!(matches!(
|
|
state.pending,
|
|
Some(Operation::LoadFile { file_index: 0, .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn reviewed_files_use_highlighted_catalog_indices_not_sorted_or_filtered_rows() {
|
|
let mut state = DiffState::new();
|
|
let mut changes = comparison();
|
|
changes.files[0].path = "nested/z.txt".into();
|
|
changes.files[1].path = "nested/a.txt".into();
|
|
state.request_kind = Some(RequestKind::Comparison);
|
|
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
|
for view in [FileView::List, FileView::Tree] {
|
|
let files = state.files.as_mut().unwrap();
|
|
files.set_view(view);
|
|
files.clear_filter();
|
|
files.start_search();
|
|
for ch in "nested/a".chars() {
|
|
files.handle_filter_input(&Input::Char(ch));
|
|
}
|
|
files.reveal_path(Path::new("nested/a.txt"));
|
|
assert_eq!(state.file, 0);
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([1]));
|
|
assert_eq!(state.toggle_reviewed(), Ok(false));
|
|
assert!(state.reviewed_files.is_empty());
|
|
}
|
|
let files = state.files.as_mut().unwrap();
|
|
files.clear_filter();
|
|
files.finish_search();
|
|
let selected = files.entries().position(|entry| entry.is_dir).unwrap();
|
|
files.selected = selected;
|
|
assert_eq!(
|
|
state.toggle_reviewed(),
|
|
Err("Select a changed file, not a directory")
|
|
);
|
|
assert!(state.reviewed_files.is_empty());
|
|
let files = state.files.as_mut().unwrap();
|
|
files.start_search();
|
|
for ch in "missing".chars() {
|
|
files.handle_filter_input(&Input::Char(ch));
|
|
}
|
|
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
|
state.files = Some(FilePicker::from_paths(
|
|
PathBuf::new(),
|
|
[(0, PathBuf::from("ordinary.txt"))],
|
|
));
|
|
assert_eq!(state.toggle_reviewed(), Err("No changed file is selected"));
|
|
state.focus = Focus::Before;
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
assert!(state.select_file_index(2));
|
|
state.focus = Focus::Before;
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0, 2]));
|
|
}
|
|
|
|
#[test]
|
|
fn reviewed_files_survive_lazy_details_and_views_but_reset_on_comparison() {
|
|
let mut state = DiffState::new();
|
|
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
|
let mut changes = comparison();
|
|
changes.files[0].loaded = false;
|
|
changes.files[0].before.clear();
|
|
changes.files[0].after.clear();
|
|
state.request_kind = Some(RequestKind::Comparison);
|
|
state.apply_result(1, Ok(Outcome::Comparison(changes)));
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
let file = comparison().files.remove(0);
|
|
state.apply_result(
|
|
2,
|
|
Ok(Outcome::File {
|
|
comparison_id: 1,
|
|
file_index: 0,
|
|
file,
|
|
}),
|
|
);
|
|
assert!(state.selected_file_loaded());
|
|
state.set_view_mode(ViewMode::Unified);
|
|
state.set_view_mode(ViewMode::Split);
|
|
state.split_axis = crate::panes::SplitAxis::Horizontal;
|
|
assert!(state.select_file_index(1));
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
state.before = Some(revision(
|
|
RevisionTarget::Commit("base".into()),
|
|
"base",
|
|
"",
|
|
Category::Commits,
|
|
));
|
|
state.after = Some(revision(
|
|
RevisionTarget::Commit("head".into()),
|
|
"head",
|
|
"",
|
|
Category::Commits,
|
|
));
|
|
state.toggle_comparison_mode().unwrap();
|
|
assert!(state.reviewed_files.is_empty());
|
|
assert_eq!(state.toggle_reviewed(), Err("No comparison is open"));
|
|
state.apply_result(3, Ok(Outcome::Comparison(comparison())));
|
|
assert!(state.reviewed_files.is_empty());
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
state.request_kind = Some(RequestKind::Comparison);
|
|
state.apply_result(4, Ok(Outcome::Comparison(comparison())));
|
|
assert!(state.reviewed_files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn working_review_marks_clear_only_for_changed_bytes_or_eof() {
|
|
for (after, eof, lines) in [
|
|
(vec!["changed".into()], false, vec!["changed".into()]),
|
|
(
|
|
vec!["changed".into()],
|
|
true,
|
|
vec!["changed".into(), String::new()],
|
|
),
|
|
(Vec::new(), false, vec![String::new()]),
|
|
] {
|
|
let mut state = live_state(vec!["base".into()], after, eof);
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
state.refresh_after(&lines, 1);
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
state.refresh_after(&lines, 2);
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
state.refresh_after(&["edited".into()], 3);
|
|
assert!(state.reviewed_files.is_empty());
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
state.refresh_after(&["edited".into(), String::new()], 4);
|
|
assert!(state.reviewed_files.is_empty());
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
state.refresh_after(&["edited".into()], 5);
|
|
assert!(state.reviewed_files.is_empty());
|
|
}
|
|
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
|
state.comparison.as_mut().unwrap().files[0].loaded = false;
|
|
assert_eq!(state.toggle_reviewed(), Ok(true));
|
|
state.refresh_after(&["edited".into()], 1);
|
|
assert_eq!(state.reviewed_files, BTreeSet::from([0]));
|
|
}
|
|
|
|
#[test]
|
|
fn unified_groups_changed_runs_and_roundtrips_side_specific_source_rows() {
|
|
let mut state = live_state(
|
|
["head", "old one", "old two", "tail"]
|
|
.map(str::to_owned)
|
|
.to_vec(),
|
|
["head", "new one", "new two", "extra", "tail"]
|
|
.map(str::to_owned)
|
|
.to_vec(),
|
|
false,
|
|
);
|
|
assert_eq!(state.view_mode, ViewMode::Split);
|
|
assert_eq!(state.split_axis, crate::panes::SplitAxis::Vertical);
|
|
state.top = state.after_display_row(2).unwrap();
|
|
let excerpts = state.excerpt_ranges.clone();
|
|
state.set_view_mode(ViewMode::Unified);
|
|
let rows: Vec<_> = (0..state.display_rows.len())
|
|
.map(|display| {
|
|
(
|
|
state.display_focus(display),
|
|
state.display_source_row(Focus::Before, display),
|
|
state.display_source_row(Focus::After, display),
|
|
)
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
rows,
|
|
[
|
|
(None, Some(0), Some(0)),
|
|
(Some(Focus::Before), Some(1), None),
|
|
(Some(Focus::Before), Some(2), None),
|
|
(Some(Focus::After), None, Some(1)),
|
|
(Some(Focus::After), None, Some(2)),
|
|
(Some(Focus::After), None, Some(3)),
|
|
(None, Some(3), Some(4)),
|
|
]
|
|
);
|
|
assert_eq!(state.top, state.after_display_row(2).unwrap());
|
|
assert_eq!(
|
|
state.corresponding_row(Focus::Before, Focus::After, 2),
|
|
Some(2)
|
|
);
|
|
assert!(state.after_editable());
|
|
for focus in [Focus::Before, Focus::After] {
|
|
for source in state.source_rows(focus) {
|
|
let display = state.source_display_row(focus, source).unwrap();
|
|
assert_eq!(state.display_source_row(focus, display), Some(source));
|
|
}
|
|
}
|
|
state.set_view_mode(ViewMode::Split);
|
|
assert_eq!(state.excerpt_ranges, excerpts);
|
|
assert_eq!(state.focus, Focus::After);
|
|
assert_eq!(state.top, state.after_display_row(2).unwrap());
|
|
assert_eq!(state.display_rows.len(), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn unified_live_reflow_preserves_removed_top_and_expanded_source_excerpts() {
|
|
let base: Vec<_> = (0..40).map(|row| format!("line {row}")).collect();
|
|
let mut lines = base.clone();
|
|
lines[20] = "new line".into();
|
|
let mut state = live_state(base, lines.clone(), false);
|
|
state.set_context_options(1, 3);
|
|
state.refresh_after(&lines, 1);
|
|
state.set_view_mode(ViewMode::Unified);
|
|
state.top = state.source_display_row(Focus::Before, 20).unwrap();
|
|
state.expand_source_excerpt(Focus::After, false, 20);
|
|
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
|
|
assert!(state.source_display_row(Focus::After, 16).is_some());
|
|
lines.insert(0, "inserted".into());
|
|
state.refresh_after(&lines, 2);
|
|
assert_eq!(state.display_focus(state.top), Some(Focus::Before));
|
|
assert_eq!(state.display_source_row(Focus::Before, state.top), Some(20));
|
|
assert!(state.source_display_row(Focus::After, 17).is_some());
|
|
state.reveal_source_row(Focus::After, 35);
|
|
let visible: Vec<_> = state.source_rows(Focus::After).collect();
|
|
state.set_view_mode(ViewMode::Split);
|
|
state.set_view_mode(ViewMode::Unified);
|
|
assert_eq!(state.source_rows(Focus::After).collect::<Vec<_>>(), visible);
|
|
assert_eq!(state.display_source_row(Focus::After, state.top), Some(35));
|
|
}
|
|
|
|
#[test]
|
|
fn character_ranges_follow_insertions_deletions_unicode_and_live_edits() {
|
|
for (before, after, expected) in [
|
|
("ab", "a界b", vec![[1..1, 1..2]]),
|
|
("a界b", "ab", vec![[1..2, 1..1]]),
|
|
("xe\u{301}👩🚀z", "xe\u{300}👩🔬z", vec![[1..3, 1..3]]),
|
|
] {
|
|
let state = live_state(vec![before.into()], vec![after.into()], false);
|
|
assert_eq!(state.rows[0].changes, expected);
|
|
}
|
|
let mut state = live_state(vec!["abc".into()], vec!["aXbc".into()], false);
|
|
state.refresh_after(&["aXYbc".into()], 1);
|
|
assert_eq!(state.rows[0].changes, vec![[1..1, 1..3]]);
|
|
state.refresh_after(&["abYZc".into()], 2);
|
|
assert_eq!(state.rows[0].changes, vec![[2..2, 2..4]]);
|
|
state.refresh_after(&["abc".into()], 3);
|
|
assert_eq!(state.rows[0].kind, RowKind::Equal);
|
|
assert!(state.rows[0].changes.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn reverting_loaded_file_keeps_unloaded_changes_selectable() {
|
|
let mut state = live_state(vec!["base".into()], vec!["changed".into()], false);
|
|
state.comparison_id = Some(17);
|
|
state.pending = None;
|
|
state.comparison.as_mut().unwrap().files.push(ChangedFile {
|
|
path: "unloaded.txt".into(),
|
|
old_path: None,
|
|
status: FileStatus::Modified,
|
|
loaded: false,
|
|
before: vec![],
|
|
after: vec![],
|
|
before_eof: false,
|
|
after_eof: false,
|
|
hunks: vec![],
|
|
notice: None,
|
|
});
|
|
state.refresh_after(&["base".into()], 1);
|
|
assert_eq!(
|
|
state
|
|
.files
|
|
.as_ref()
|
|
.unwrap()
|
|
.entries()
|
|
.map(|entry| entry.path)
|
|
.collect::<Vec<_>>(),
|
|
[Path::new("unloaded.txt")],
|
|
);
|
|
assert!(state.select_file(Path::new("unloaded.txt")));
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::LoadFile {
|
|
comparison_id: 17,
|
|
file_index: 1
|
|
})
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn live_width_cache_scans_only_changed_lines_and_shrinks_after_deletion() {
|
|
let base = vec!["original".into(), "medium".into(), "short".into()];
|
|
let mut lines = vec!["界\tz".into(), "medium".into(), "short".into()];
|
|
let mut state = live_state(base, lines.clone(), false);
|
|
state.refresh_after(&lines, 1);
|
|
assert_eq!(state.after_widths, [5, 6, 5]);
|
|
lines[0] = "界界\t🙂".into();
|
|
WIDTH_SCANS.set(0);
|
|
state.refresh_after(&lines, 2);
|
|
assert_eq!(WIDTH_SCANS.get(), 1);
|
|
assert_eq!(state.max_after, 10);
|
|
state.left_after = 9;
|
|
lines[0] = "界".into();
|
|
WIDTH_SCANS.set(0);
|
|
state.refresh_after(&lines, 3);
|
|
assert_eq!(WIDTH_SCANS.get(), 1);
|
|
assert_eq!(state.max_after, 6);
|
|
assert_eq!(state.left_after, 6);
|
|
lines.remove(1);
|
|
WIDTH_SCANS.set(0);
|
|
state.refresh_after(&lines, 4);
|
|
assert_eq!(WIDTH_SCANS.get(), 0);
|
|
assert_eq!(state.after_widths, [2, 5]);
|
|
assert_eq!(state.max_after, 5);
|
|
lines.insert(1, "巨大\t🙂".into());
|
|
WIDTH_SCANS.set(0);
|
|
state.refresh_after(&lines, 5);
|
|
assert_eq!(WIDTH_SCANS.get(), 1);
|
|
assert_eq!(state.after_widths, [2, 10, 5]);
|
|
lines = vec!["".into()];
|
|
state.refresh_after(&lines, 6);
|
|
assert_eq!(state.after_widths, [0]);
|
|
assert_eq!(state.max_after, 0);
|
|
assert_eq!(state.left_after, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn stable_line_edits_keep_fresh_hunks_expansion_and_scroll_anchor_until_revert() {
|
|
let base = (0..100)
|
|
.map(|row| format!("line {row}"))
|
|
.collect::<Vec<_>>();
|
|
let mut lines = base.clone();
|
|
lines[30] = "first change".into();
|
|
let mut state = live_state(base.clone(), lines.clone(), true);
|
|
state.set_context_options(2, 5);
|
|
lines.push(String::new());
|
|
state.refresh_after(&lines, 1);
|
|
state.top = state.after_display_row(30).unwrap();
|
|
state.handle(Input::Char('['));
|
|
let display = state.display_rows.clone();
|
|
let excerpts = state.excerpt_ranges.clone();
|
|
let top = state.top;
|
|
lines[30].push('!');
|
|
let file = &state.comparison.as_ref().unwrap().files[0];
|
|
assert!(stable_after_edit(
|
|
file,
|
|
&lines,
|
|
changed_line_range(&file.after, &lines)
|
|
));
|
|
WIDTH_SCANS.set(0);
|
|
state.refresh_after(&lines, 2);
|
|
assert_eq!(WIDTH_SCANS.get(), 1);
|
|
assert_eq!(state.display_rows, display);
|
|
assert_eq!(state.excerpt_ranges, excerpts);
|
|
assert_eq!(state.top, top);
|
|
let file = &state.comparison.as_ref().unwrap().files[0];
|
|
let hunk_ranges = |hunks: &[Hunk]| {
|
|
hunks
|
|
.iter()
|
|
.map(|hunk| {
|
|
(
|
|
hunk.before_start,
|
|
hunk.before_len,
|
|
hunk.after_start,
|
|
hunk.after_len,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
assert_eq!(
|
|
hunk_ranges(&file.hunks),
|
|
hunk_ranges(&line_hunks(&base, true, live_content(&lines, true), true))
|
|
);
|
|
lines[30].clone_from(&base[30]);
|
|
assert!(!stable_after_edit(
|
|
file,
|
|
&lines,
|
|
changed_line_range(&file.after, &lines)
|
|
));
|
|
state.refresh_after(&lines, 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_eq!(state.top, state.after_display_row(30).unwrap());
|
|
assert_eq!(state.files.as_ref().unwrap().entries().len(), 0);
|
|
lines[50] = "changed again".into();
|
|
state.refresh_after(&lines, 4);
|
|
assert_eq!(state.files.as_ref().unwrap().entries().len(), 1);
|
|
assert!(state.after_display_row(23).is_some());
|
|
assert!(state.after_display_row(50).is_some());
|
|
}
|
|
|
|
#[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.comparison.as_mut().unwrap().editable_paths.clear();
|
|
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);
|
|
}
|
|
|
|
#[test]
|
|
fn lazy_selection_waits_for_details_rejects_stale_files_and_reuses_loaded_cache() {
|
|
let mut state = DiffState::new();
|
|
state.pending = None;
|
|
state.request_kind = Some(RequestKind::Comparison);
|
|
let mut catalog = comparison();
|
|
catalog.files.truncate(2);
|
|
let details: Vec<_> = catalog.files.drain(..).collect();
|
|
for detail in &details {
|
|
catalog.files.push(ChangedFile {
|
|
path: detail.path.clone(),
|
|
old_path: detail.old_path.clone(),
|
|
status: detail.status,
|
|
loaded: false,
|
|
before: vec![],
|
|
after: vec![],
|
|
before_eof: false,
|
|
after_eof: false,
|
|
hunks: vec![],
|
|
notice: None,
|
|
});
|
|
}
|
|
state.apply_result(41, Ok(Outcome::Comparison(catalog)));
|
|
assert!(!state.selected_file_loaded());
|
|
assert!(state.rows.is_empty() && state.display_rows.is_empty());
|
|
assert_eq!(state.pane_layout(), PaneLayout::Empty);
|
|
assert!(!state.after_editable());
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::LoadFile {
|
|
comparison_id: 41,
|
|
file_index: 0,
|
|
})
|
|
));
|
|
assert!(state.select_file(Path::new("added.txt")));
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::LoadFile {
|
|
comparison_id: 41,
|
|
file_index: 1,
|
|
})
|
|
));
|
|
let mut details = details.into_iter();
|
|
let first = details.next().unwrap();
|
|
let second = details.next().unwrap();
|
|
state.apply_result(
|
|
42,
|
|
Ok(Outcome::File {
|
|
comparison_id: 40,
|
|
file_index: 1,
|
|
file: first,
|
|
}),
|
|
);
|
|
assert!(state.loading && !state.selected_file_loaded());
|
|
state.apply_result(
|
|
43,
|
|
Ok(Outcome::File {
|
|
comparison_id: 41,
|
|
file_index: 1,
|
|
file: second,
|
|
}),
|
|
);
|
|
assert!(state.selected_file_loaded());
|
|
assert_eq!(state.pane_layout(), PaneLayout::After);
|
|
assert!(!state.rows.is_empty());
|
|
assert!(state.select_file(Path::new("renamed.txt")));
|
|
assert!(state.loading);
|
|
assert!(state.select_file(Path::new("added.txt")));
|
|
assert!(!state.loading && state.selected_file_loaded());
|
|
assert!(state.take_request().is_none());
|
|
state.apply_result(44, Err("cancelled old load".into()));
|
|
assert!(state.error.is_none() && state.selected_file_loaded());
|
|
}
|
|
|
|
#[test]
|
|
fn failed_lazy_file_load_can_be_retried_without_reopening_comparison() {
|
|
let mut state = DiffState::new();
|
|
state.pending = None;
|
|
state.request_kind = Some(RequestKind::Comparison);
|
|
let mut catalog = comparison();
|
|
catalog.files.truncate(1);
|
|
catalog.files[0].loaded = false;
|
|
state.apply_result(7, Ok(Outcome::Comparison(catalog)));
|
|
state.take_request();
|
|
state.apply_result(8, Err("cannot read blob".into()));
|
|
assert_eq!(state.error.as_deref(), Some("cannot read blob"));
|
|
assert!(!state.selected_file_loaded());
|
|
assert!(state.select_file(Path::new("renamed.txt")));
|
|
assert!(state.loading && state.error.is_none());
|
|
assert!(matches!(
|
|
state.take_request(),
|
|
Some(Operation::LoadFile {
|
|
comparison_id: 7,
|
|
file_index: 0,
|
|
})
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn mode_action_keeps_filter_input_and_rejects_superseded_comparisons() {
|
|
let mut editor = editor();
|
|
open(&mut editor);
|
|
editor
|
|
.bind("normal", "m", Some("core.diff.mode.toggle".into()))
|
|
.unwrap();
|
|
text(&mut editor, "m");
|
|
assert_eq!(editor.diff().unwrap().query, "m");
|
|
assert_eq!(
|
|
editor.diff().unwrap().comparison_mode,
|
|
ComparisonMode::BranchChanges
|
|
);
|
|
editor.handle(Input::Ctrl('u'));
|
|
editor.handle(Input::Down);
|
|
editor.handle(Input::Enter);
|
|
editor.handle(Input::Down);
|
|
editor.handle(Input::Enter);
|
|
let (old, _) = editor.take_diff_request().unwrap();
|
|
editor.execute("diff.mode.toggle");
|
|
assert!(!editor.apply_diff_result(old, Err("superseded".into())));
|
|
let (current, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(
|
|
operation,
|
|
Operation::Compare {
|
|
mode: ComparisonMode::Snapshots,
|
|
..
|
|
}
|
|
));
|
|
assert!(editor.apply_diff_result(current, Err("retryable".into())));
|
|
assert_eq!(editor.diff().unwrap().phase, Phase::After);
|
|
assert_eq!(editor.diff().unwrap().error.as_deref(), Some("retryable"));
|
|
editor.execute("diff.mode.toggle");
|
|
let (_, operation) = editor.take_diff_request().unwrap();
|
|
assert!(matches!(
|
|
operation,
|
|
Operation::Compare {
|
|
mode: ComparisonMode::BranchChanges,
|
|
..
|
|
}
|
|
));
|
|
assert!(editor.diff().unwrap().error.is_none());
|
|
}
|
|
}
|