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, number: u64, }, 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, pub notice: Option, } #[derive(Debug)] pub struct PullRequestPage { pub kind: RepoKind, pub remote: String, pub entries: Vec<(Revision, String)>, pub has_more: bool, } #[derive(Debug, Default)] struct PullRequestCategory { complete: bool, error: Option, } #[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, pub status: FileStatus, /// False means text, EOF flags, hunks and notices have not been prepared. pub loaded: bool, pub before: Vec, pub after: Vec, pub before_eof: bool, pub after_eof: bool, pub hunks: Vec, pub notice: Option, } #[derive(Debug)] pub struct Comparison { pub before: String, pub after: String, pub files: Vec, pub root: PathBuf, /// Eligible regular-file After paths; loaded binary details are excluded. pub editable_paths: BTreeSet, } #[derive(Debug)] pub enum Operation { Discover, PullRequests(String), ResolvePullRequest(Revision), 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), PullRequestPage { closed: bool, result: Result, }, ResolvedPullRequest { before: Revision, after: Revision, }, Comparison(Comparison), CheckoutRefresh(Option), 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, pub after: Option, pub kind: RowKind, /// Changed grapheme ranges, paired as [before, after]; empty on unpaired rows. pub changes: Vec<[std::ops::Range; 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, pub pull_request_remote: Option, pull_request_categories: [PullRequestCategory; 2], pull_request_order: BTreeMap, pub(crate) pull_request_retries: [u64; 2], pub phase: Phase, pub word_wrap: bool, pub view_mode: ViewMode, pub comparison_mode: ComparisonMode, pub split_axis: crate::panes::SplitAxis, pub before: Option, pub after: Option, pub category: usize, pub query: String, pub selected: usize, pub loading: bool, pub error: Option, pub comparison: Option, pub comparison_id: Option, pub reviewed_files: BTreeSet, pub(crate) snapshot_roots: [Option; 2], pub files: Option, pub files_visible: bool, pub file: usize, pub focus: Focus, pub top: usize, pub left_before: usize, pub left_after: usize, pub rows: Vec, pub display_rows: Vec, display_sides: Vec>, excerpt_ranges: Vec<(usize, usize)>, pub live_buffer: Option, pub live_revision: Option, pub follow_caret: bool, live_files: BTreeSet, frozen_after: BTreeMap, bool)>, context_lines: usize, expand_lines: usize, pending: Option, request_kind: Option, visible: Vec, search_fields: Vec<[String; 3]>, search_matches: Vec<([usize; 7], usize)>, max_before: usize, max_after: usize, after_widths: Vec, } impl DiffState { pub(crate) fn new() -> Self { Self { catalog: None, pull_request_remote: None, pull_request_categories: Default::default(), pull_request_order: BTreeMap::new(), pull_request_retries: [0; 2], 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 set_resolver_text(&mut self, draft: &[String], alternative: &[String]) { fn content(lines: &[String]) -> (&[String], bool) { let eof = lines.len() > 1 && lines.last().is_some_and(String::is_empty); let content = if eof { &lines[..lines.len() - 1] } else { lines }; ( if !eof && content.len() == 1 && content[0].is_empty() { &[] } else { content }, eof, ) } let (before, before_eof) = content(draft); let (after, after_eof) = content(alternative); if self.comparison.as_ref().is_some_and(|comparison| { let file = &comparison.files[0]; file.before == before && file.after == after && file.before_eof == before_eof && file.after_eof == after_eof }) { return; } let viewport = (self.top, self.left_before, self.left_after); self.phase = Phase::View; self.loading = false; self.pending = None; self.request_kind = None; self.files_visible = false; self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1)); self.file = 0; self.live_files.clear(); self.comparison = Some(Comparison { before: "Draft".into(), after: "Alternative".into(), root: PathBuf::new(), editable_paths: BTreeSet::new(), files: vec![ChangedFile { path: PathBuf::new(), old_path: None, status: FileStatus::Modified, loaded: true, before: before.to_vec(), after: after.to_vec(), before_eof, after_eof, hunks: line_hunks(before, before_eof, after, after_eof), notice: None, }], }); self.focus = if self.focus == Focus::After { Focus::After } else { Focus::Before }; self.align_file(); let before_terminal = (draft.len() > before.len()).then_some(before.len()); let after_terminal = (alternative.len() > after.len()).then_some(after.len()); if before_terminal.is_some() || after_terminal.is_some() { self.rows.push(AlignedRow { before: before_terminal, after: after_terminal, kind: RowKind::Equal, changes: Vec::new(), }); self.reset_excerpts(); } if self.comparison.as_ref().unwrap().files[0].hunks.is_empty() { self.excerpt_ranges = vec![(0, self.rows.len())]; self.rebuild_display_rows(); } self.top = viewport.0.min(self.display_rows.len().saturating_sub(1)); self.left_before = viewport.1.min(self.max_before); self.left_after = viewport.2.min(self.max_after); } 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 { self.visible .iter() .map(|&index| &self.catalog.as_ref().expect("visible catalog").entries[index]) } pub(crate) fn take_request(&mut self) -> Option { 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 { .. } | Operation::ResolvePullRequest(_) => 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) -> bool { if let Ok(Outcome::PullRequestPage { closed, result }) = result { if self.pull_request_remote.is_none() || self.request_kind != Some(RequestKind::Catalog) || self.pending.is_some() { return false; } self.error = None; let status = &mut self.pull_request_categories[usize::from(closed)]; match result { Ok(page) => { status.complete = !page.has_more; status.error = None; let selected = self .entries() .nth(self.selected) .map(|entry| entry.target.clone()); let catalog = self.catalog.get_or_insert_with(|| Catalog { kind: page.kind, entries: Vec::new(), notice: Some(format!("Pull requests from {}", page.remote)), }); for (revision, created_at) in page.entries { let RevisionTarget::PullRequest { number, .. } = &revision.target else { continue; }; if let Some((date, index)) = self.pull_request_order.get_mut(number) { *date = created_at; catalog.entries[*index] = revision; } else { self.pull_request_order .insert(*number, (created_at, catalog.entries.len())); catalog.entries.push(revision); } } let number = |revision: &Revision| match revision.target { RevisionTarget::PullRequest { number, .. } => number, _ => unreachable!("pull request catalog"), }; catalog.entries.sort_unstable_by(|a, b| { self.pull_request_order[&number(b)] .0 .cmp(&self.pull_request_order[&number(a)].0) .then_with(|| number(b).cmp(&number(a))) }); for (index, revision) in catalog.entries.iter().enumerate() { self.pull_request_order .get_mut(&number(revision)) .expect("known pull request") .1 = index; } self.prepare_search(); self.refilter(); if let Some(target) = selected { self.select_target(&target); } } Err(error) => status.error = Some(error), } self.loading = self .pull_request_categories .iter() .any(|status| !status.complete && status.error.is_none()); return true; } 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::ResolvedPullRequest { before, after }) if expected == Some(RequestKind::Comparison) => { self.before = Some(before); self.after = Some(after); self.queue_comparison(); } Ok(Outcome::Catalog(catalog)) if expected == Some(RequestKind::Catalog) => { let selected = self .entries() .nth(self.selected) .map(|entry| entry.target.clone()); self.catalog = Some(catalog); self.prepare_search(); 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 prepare_search(&mut self) { self.search_fields = self .catalog .as_ref() .into_iter() .flat_map(|catalog| &catalog.entries) .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(); } pub(crate) fn pull_request_partial(&self) -> bool { self.pull_request_remote.is_some() && self .pull_request_categories .iter() .enumerate() .any(|(index, status)| { (self.category == 2 || self.category == index) && !status.complete }) } pub(crate) fn pull_request_notice(&self) -> Option { self.pull_request_remote.as_ref()?; let statuses: Vec<_> = self .pull_request_categories .iter() .enumerate() .filter(|(index, _)| self.category == 2 || self.category == *index) .map(|(index, status)| { let category = [Category::Open, Category::Closed][index]; let count = self.catalog.as_ref().map_or(0, |catalog| { catalog .entries .iter() .filter(|entry| entry.category == category) .count() }); let state = status.error.as_deref().unwrap_or(if status.complete { "complete" } else { "loading…" }); format!("{}: {count} loaded · {state}", category.label()) }) .collect(); Some(format!( "{}{}", statuses.join(" · "), if self.pull_request_partial() { " · partial search" } else { "" } )) } fn retry_pull_requests(&mut self) { for (index, status) in self.pull_request_categories.iter_mut().enumerate() { if (self.category == 2 || self.category == index) && status.error.take().is_some() { self.pull_request_retries[index] += 1; self.loading = 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.pull_request_remote.is_some() && self.request_kind == Some(RequestKind::Catalog)) => { self.choose() } Input::Ctrl('r') if self.pull_request_remote.is_some() => self.retry_pull_requests(), 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 { self.retry_pull_requests(); // Listing can be retried without closing the modal. if self.catalog.is_none() && (self.pull_request_remote.is_none() || self.error.is_some()) { self.queue(match &self.pull_request_remote { Some(remote) => Operation::PullRequests(remote.clone()), None => Operation::Discover, }); } return; }; if matches!(revision.target, RevisionTarget::PullRequest { .. }) { self.before = None; self.after = None; self.comparison_mode = ComparisonMode::BranchChanges; self.phase = Phase::After; self.queue(Operation::ResolvePullRequest(revision)); return; } if revision.target == RevisionTarget::Working { self.comparison_mode = ComparisonMode::Snapshots; } 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 { 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 { (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 { self.display_sides.get(display).copied().flatten() } pub(crate) fn display_source_row(&self, focus: Focus, display: usize) -> Option { 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 { self.source_display_row(Focus::After, source_row) } fn source_row(&self, focus: Focus, index: usize) -> Option { 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 + '_ { 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 { 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 { 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 { 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, side: Option) { 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::>(); if !was_live && (file.after_eof || old.is_empty()) { old.push(""); } let new = lines.iter().map(String::as_str).collect::>(); 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, 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, ) { 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; 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, widths: &mut Vec, 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 { 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::>(); 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 = const { std::cell::Cell::new(0) }; } #[test] fn resolver_equal_text_retains_native_rows_and_cached_viewport() { for text in ["", "\n", "same\n", "same"] { let lines: Vec<_> = text.split('\n').map(str::to_owned).collect(); let mut state = DiffState::new(); state.set_resolver_text(&lines, &lines); assert_eq!(state.pane_layout(), PaneLayout::Split); assert!(state.comparison.as_ref().unwrap().files[0].hunks.is_empty()); for row in 0..lines.len() { assert!(state.source_display_row(Focus::Before, row).is_some()); assert!(state.source_display_row(Focus::After, row).is_some()); } let id = state.comparison_id; state.top = 1; state.left_before = 3; state.left_after = 4; state.set_resolver_text(&lines, &lines); assert_eq!(state.comparison_id, id); assert_eq!((state.top, state.left_before, state.left_after), (1, 3, 4)); } } 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::>()); 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_page(closed: bool, rows: &[(u64, &str, &str)], has_more: bool) -> Outcome { Outcome::PullRequestPage { closed, result: Ok(PullRequestPage { kind: RepoKind::Git, remote: "origin".into(), has_more, entries: rows .iter() .map(|&(number, title, date)| { ( revision( RevisionTarget::PullRequest { remote: "origin".into(), number, }, &format!("#{number} {title}"), "Alice · main ← topic", if closed { Category::Closed } else { Category::Open }, ), date.into(), ) }) .collect(), }), } } #[test] fn pull_request_categories_keep_query_and_select_loaded_rows_before_completion() { 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(pull_page( true, &[(2, "Fix Ünicode", "2026-01-02T00:00:00Z")], true, )), ); assert!(state.loading); assert!(state.pull_request_partial()); assert_eq!(state.entries().next().unwrap().label, "#2 Fix Ünicode"); state.handle(Input::Char('\t')); state.apply_result( 1, Ok(pull_page( false, &[ (1, "Fix Ünicode", "2026-01-03T00:00:00Z"), (3, "Other work", "2026-01-01T00:00:00Z"), ], true, )), ); assert_eq!( state .entries() .map(|entry| entry.label.as_str()) .collect::>(), ["#1 Fix Ünicode", "#2 Fix Ünicode"] ); state.handle(Input::Char('\t')); assert_eq!(state.query, "FIX üni Alice"); state.comparison_mode = ComparisonMode::Snapshots; state.handle(Input::Enter); assert_eq!(state.phase, Phase::After); assert!(state.before.is_none() && state.after.is_none()); assert_eq!(state.comparison_mode, ComparisonMode::BranchChanges); assert!(matches!( state.take_request(), Some(Operation::ResolvePullRequest(Revision { target: RevisionTarget::PullRequest { number: 1, .. }, .. })) )); } #[test] fn pull_request_all_streams_sort_deduplicate_and_preserve_selection_identity() { let mut state = DiffState::pull_requests("origin".into()); state.take_request(); state.handle(Input::BackTab); state.apply_result( 1, Ok(pull_page( false, &[ (1, "First", "2026-01-01T00:00:00Z"), (2, "Second", "2026-01-02T00:00:00Z"), ], true, )), ); state.handle(Input::End); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); state.apply_result(1, Ok(pull_page(true, &[], true))); assert!(state.pull_request_partial()); state.apply_result( 1, Ok(pull_page( true, &[(3, "Newest", "2026-01-03T00:00:00Z")], true, )), ); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); state.apply_result( 1, Ok(pull_page( false, &[(2, "Second updated", "2026-01-02T00:00:00Z")], false, )), ); assert_eq!(state.entries().len(), 3); assert_eq!( state .entries() .map(|entry| entry.label.as_str()) .collect::>(), ["#3 Newest", "#2 Second updated", "#1 First"] ); assert!(state.pull_request_partial()); state.apply_result(1, Ok(pull_page(true, &[], false))); assert!(!state.pull_request_partial()); assert!(!state.loading); assert_eq!( state.entries().nth(state.selected).unwrap().label, "#1 First" ); } #[test] fn pull_request_late_category_errors_retry_without_losing_rows_or_other_results() { let mut editor = editor(); editor.execute("pull_requests origin"); let (id, _) = editor.take_diff_request().unwrap(); editor.apply_diff_result( id, Ok(pull_page( false, &[(1, "Open", "2026-01-01T00:00:00Z")], true, )), ); editor.apply_diff_result( id, Ok(pull_page( true, &[(2, "Closed", "2026-01-02T00:00:00Z")], true, )), ); editor.handle(Input::Char('\t')); editor.apply_diff_result( id, Ok(Outcome::PullRequestPage { closed: true, result: Err("offline".into()), }), ); let diff = editor.diff().unwrap(); assert!(diff.pull_request_notice().unwrap().contains("offline")); assert_eq!(diff.entries().next().unwrap().label, "#2 Closed"); assert!(diff.pull_request_partial()); editor.handle(Input::Ctrl('r')); assert_eq!(editor.diff().unwrap().pull_request_retries, [0, 1]); assert!(editor.take_diff_request().is_none()); editor.apply_diff_result( id, Ok(pull_page( false, &[(3, "More open", "2026-01-03T00:00:00Z")], false, )), ); assert_eq!( editor.diff().unwrap().entries().next().unwrap().label, "#2 Closed" ); editor.apply_diff_result(id, Ok(pull_page(true, &[], false))); assert!(!editor.diff().unwrap().pull_request_partial()); editor.handle(Input::Enter); let (_, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::ResolvePullRequest(_))); assert!(!editor.apply_diff_result(id, Ok(pull_page(false, &[], false)))); } #[test] fn pull_request_loading_retry_escape_and_reopen_reject_stale_results() { let mut editor = editor(); editor .apply(vec![Effect::Replace("unsaved source".into())]) .unwrap(); editor.execute("pull_requests origin"); let (first, _) = editor.take_diff_request().unwrap(); editor.handle(Input::Enter); assert!(editor.take_diff_request().is_none()); text(&mut editor, "alice"); editor.apply_diff_result( first, Ok(Outcome::PullRequestPage { closed: false, result: Err("API unavailable".into()), }), ); editor.handle(Input::Enter); assert_eq!(editor.diff().unwrap().pull_request_retries, [1, 0]); assert!(editor.take_diff_request().is_none()); assert!(editor.apply_diff_result( first, Ok(pull_page( false, &[ (1, "Open", "2026-01-02T00:00:00Z"), (2, "Other open", "2026-01-01T00:00:00Z"), ], true )) )); editor.handle(Input::Enter); let (resolve, operation) = editor.take_diff_request().unwrap(); assert!(matches!(operation, Operation::ResolvePullRequest(_))); assert!(!editor.apply_diff_result(first, Ok(pull_page(true, &[], false)))); editor.apply_diff_result(resolve, Err("Historical head no longer retained".into())); assert!(editor.diff().unwrap().before.is_none()); assert!(editor.diff().unwrap().after.is_none()); editor.handle(Input::Down); editor.handle(Input::Enter); let (retry, operation) = editor.take_diff_request().unwrap(); assert!(matches!( operation, Operation::ResolvePullRequest(Revision { target: RevisionTarget::PullRequest { number: 2, .. }, .. }) )); editor.handle(Input::Escape); assert!(editor.diff().is_none()); assert!(!editor.apply_diff_result(retry, Ok(Outcome::Comparison(comparison())))); assert_eq!(editor.lines(), ["unsaved source"]); assert!(editor.dirty()); editor.execute("pull_requests"); let (reopened, _) = editor.take_diff_request().unwrap(); assert!(!editor.apply_diff_result(first, Ok(pull_page(false, &[], false)))); assert!(editor.apply_diff_result(reopened, Ok(pull_page(false, &[], false)))); assert!(editor.apply_diff_result(reopened, Ok(pull_page(true, &[], false)))); editor.handle(Input::Enter); assert!(editor.take_diff_request().is_none()); } #[test] fn resolved_pull_request_modes_reuse_pinned_endpoints_without_resolving_again() { let mut state = DiffState::pull_requests("origin".into()); state.take_request(); state.apply_result( 1, Ok(pull_page( false, &[(1, "Open", "2026-01-01T00:00:00Z")], true, )), ); state.handle(Input::Enter); state.take_request(); let before = revision( RevisionTarget::RemoteCommit { remote: "origin".into(), reference: "refs/heads/main".into(), id: "base-sha".into(), }, "main", "", Category::Open, ); let after = revision( RevisionTarget::RemoteCommit { remote: "origin".into(), reference: "refs/pull/1/head".into(), id: "head-sha".into(), }, "#1 Open", "", Category::Open, ); state.apply_result( 2, Ok(Outcome::ResolvedPullRequest { before: before.clone(), after: after.clone(), }), ); assert!(matches!( state.take_request(), Some(Operation::Compare { mode: ComparisonMode::BranchChanges, .. }) )); state.apply_result(3, Err("No common ancestor".into())); assert_eq!(state.before.as_ref(), Some(&before)); assert_eq!(state.after.as_ref(), Some(&after)); state.toggle_comparison_mode().unwrap(); let Some(Operation::Compare { before: pinned_before, after: pinned_after, mode, .. }) = state.take_request() else { panic!("expected ordinary pinned comparison"); }; assert_eq!( (pinned_before, pinned_after, mode), (before, after, ComparisonMode::Snapshots) ); } 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::>(), [ &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::>(), ["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::>(), [ "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::>(), [ (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::>(); 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::>(), 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, after: Vec, 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::>(), 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::>(), [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::>(); 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::>() }; 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::>(); 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()); } }