Files
vamp/src/diff.rs
T
Valdemar Lorenzen 4fb1be7972 fix: show revision-aware blame in diff buffers
Diff and snapshot sources were excluded before scheduling, and the separate diff renderer never painted attribution. Capture per-side Git provenance, preserve private object stores for historical tabs, and reuse the current-line worker and cache.
2026-10-20 16:28:53 +02:00

3127 lines
109 KiB
Rust

use crate::editor::{BackspaceKind, Input};
use crate::files::{FilePicker, FilePresentation, FileView, fuzzy_match};
use std::{
borrow::Cow,
collections::{BTreeMap, BTreeSet},
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
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),
Bookmark {
name: String,
remote: Option<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<Revision>,
pub notice: Option<String>,
}
#[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<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FileStatus {
Added,
Removed,
Modified,
Renamed,
}
#[derive(Debug)]
pub struct Hunk {
pub before_start: usize,
pub before_len: usize,
pub after_start: usize,
pub after_len: usize,
}
#[derive(Debug)]
pub struct ChangedFile {
/// Immutable file-side identities, independent of unrelated revision changes.
pub review_revisions: Option<[String; 2]>,
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub status: FileStatus,
/// False means text, EOF flags, hunks and notices have not been prepared.
pub loaded: bool,
pub before: Vec<String>,
pub after: Vec<String>,
pub before_eof: bool,
pub after_eof: bool,
pub hunks: Vec<Hunk>,
pub notice: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlameRevision {
pub store: PathBuf,
pub commit: String,
pub(crate) keepalive: Option<Arc<crate::repository::Temporary>>,
}
#[derive(Debug)]
pub struct Comparison {
/// Immutable comparison-context IDs; captured review text also freezes live edits.
pub revisions: [String; 2],
/// Git object stores and immutable attribution baselines for Before/After.
pub blame: [Option<BlameRevision>; 2],
pub before: String,
pub after: String,
pub files: Vec<ChangedFile>,
pub root: PathBuf,
/// Eligible regular-file After paths; loaded binary details are excluded.
pub editable_paths: BTreeSet<PathBuf>,
}
#[derive(Debug)]
pub enum Operation {
Discover,
PullRequests(String),
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<PullRequestPage, String>,
},
ResolvedPullRequest {
before: Revision,
after: Revision,
},
Comparison(Comparison),
CheckoutRefresh(Option<Comparison>),
BookmarkRefresh(Comparison),
File {
comparison_id: u64,
file_index: usize,
file: ChangedFile,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Phase {
Before,
After,
View,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Focus {
Files,
Before,
After,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewMode {
Split,
Unified,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum PaneLayout {
Split,
Before,
After,
Empty,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RowKind {
Equal,
Added,
Removed,
Changed,
}
#[derive(Debug)]
pub struct AlignedRow {
pub before: Option<usize>,
pub after: Option<usize>,
pub kind: RowKind,
/// Changed grapheme ranges, paired as [before, after]; empty on unpaired rows.
pub changes: Vec<[std::ops::Range<usize>; 2]>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DisplayRow {
Line(usize),
Fold { start: usize, end: usize },
}
/// Borrowed painting and native-coordinate projection of a loaded comparison.
#[derive(Clone, Copy, Debug)]
pub(crate) struct ColumnView<'a> {
diff: &'a DiffState,
file: &'a ChangedFile,
pub info: ColumnInfo<'a>,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ColumnInfo<'a> {
pub focus: Focus,
pub unified: bool,
pub display_len: usize,
pub source_extents: [usize; 2],
pub stored_lengths: [usize; 2],
pub empty: ColumnEmpty<'a>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ColumnEmpty<'a> {
EmptyFile,
NoChanges,
Notice(&'a str),
}
#[derive(Debug)]
pub(crate) enum ColumnRow<'a> {
Fold { hidden: usize },
Gap,
Text(ColumnLine<'a>),
}
#[derive(Debug)]
pub(crate) struct ColumnLine<'a> {
pub text: Option<&'a str>,
pub side: Focus,
pub source_row: usize,
pub numbers: [Option<usize>; 2],
pub changed: bool,
pub emphasize: bool,
pub newline: bool,
changes: Cow<'a, [[std::ops::Range<usize>; 2]]>,
}
impl<'a> ColumnLine<'a> {
pub fn changes(&self) -> impl Iterator<Item = &std::ops::Range<usize>> + '_ {
let side = usize::from(self.side == Focus::After);
let emphasize = self.emphasize;
self.changes
.iter()
.filter(move |_| emphasize)
.map(move |change| &change[side])
}
}
impl<'a> ColumnView<'a> {
fn new(diff: &'a DiffState, file: &'a ChangedFile, focus: Focus) -> Self {
let stored_lengths = [file.before.len(), file.after.len()];
let last = diff.rows.last();
let source_extents = [
stored_lengths[0].max(last.and_then(|row| row.before).map_or(0, |row| row + 1)),
stored_lengths[1].max(last.and_then(|row| row.after).map_or(0, |row| row + 1)),
];
Self {
diff,
file,
info: ColumnInfo {
focus,
unified: diff.view_mode == ViewMode::Unified,
display_len: diff.display_rows.len(),
source_extents,
stored_lengths,
empty: match file.notice.as_deref() {
Some(notice) => ColumnEmpty::Notice(notice),
None if diff.rows.is_empty() => ColumnEmpty::EmptyFile,
None => ColumnEmpty::NoChanges,
},
},
}
}
pub fn row(&self, display: usize) -> Option<ColumnRow<'a>> {
let index = match *self.diff.display_rows.get(display)? {
DisplayRow::Fold { start, end } => {
return Some(ColumnRow::Fold {
hidden: end - start,
});
}
DisplayRow::Line(index) => index,
};
let row = self.diff.rows.get(index)?;
let side = if self.info.unified {
self.diff.display_focus(display).unwrap_or(self.info.focus)
} else {
self.info.focus
};
let Some(source_row) = self.diff.display_source_row(side, display) else {
return Some(ColumnRow::Gap);
};
let before = side == Focus::Before;
let side_index = usize::from(!before);
let (source, eof) = if before {
(&self.file.before, self.file.before_eof)
} else {
(&self.file.after, self.file.after_eof)
};
let text = source
.get(source_row)
.map(String::as_str)
.or_else(|| (self.diff.terminal_rows[side_index] == Some(source_row)).then_some(""));
Some(ColumnRow::Text(ColumnLine {
text,
side,
source_row,
numbers: [
self.diff.display_source_row(Focus::Before, display),
self.diff.display_source_row(Focus::After, display),
],
changed: row.kind == RowKind::Changed
|| (before && row.kind == RowKind::Removed)
|| (!before && row.kind == RowKind::Added),
emphasize: !matches!(self.file.status, FileStatus::Added | FileStatus::Removed),
newline: source_row + 1 < source.len() || eof,
changes: if self.diff.lazy_changes && row.kind == RowKind::Changed {
match (row.before, row.after) {
(Some(_), Some(_)) if self.file.hunks.is_empty() => {
// A stitched History base is changed by another column.
Cow::Owned(vec![[0..usize::MAX, 0..usize::MAX]])
}
(Some(before), Some(after)) => Cow::Owned(character_changes(
&self.file.before[before],
&self.file.after[after],
)),
_ => Cow::Borrowed(&row.changes),
}
} else {
Cow::Borrowed(&row.changes)
},
}))
}
/// Caret mapping stays on this view's native side, even in unified display.
pub fn native_row(&self, display: usize) -> Option<usize> {
self.diff.display_source_row(self.info.focus, display)
}
/// Finds the first visible native match; never reveals folded source rows.
pub fn display_row(&self, native: usize) -> Option<usize> {
self.diff.source_display_row(self.info.focus, native)
}
/// Review anchors may address either coordinate of unified equal context,
/// independently of the focused native caret mapping.
pub fn review_row(&self, side: Focus, display: usize) -> Option<usize> {
self.diff.display_source_row(side, display).or_else(|| {
let DisplayRow::Line(index) = *self.diff.display_rows.get(display)? else {
return None;
};
let row = self.diff.rows.get(index)?;
if !self.info.unified || row.kind != RowKind::Equal {
return None;
}
match side {
Focus::Before => row.before,
Focus::After => row.after,
Focus::Files => None,
}
})
}
}
/// Immutable left/base/right conflict History, aligned at shared base rows.
pub(crate) struct HistoryComparison {
columns: [DiffState; 3],
}
/// A read-only projection for the existing diff painter.
pub(crate) struct HistoryColumn<'a> {
pub diff: &'a DiffState,
pub file: &'a ChangedFile,
pub focus: Focus,
pub lines: &'a [String],
}
impl<'a> HistoryColumn<'a> {
pub fn view(&self) -> ColumnView<'a> {
ColumnView::new(self.diff, self.file, self.focus)
}
pub fn source_row(&self, display: usize) -> Option<usize> {
self.diff.display_source_row(self.focus, display)
}
}
impl HistoryComparison {
pub fn new(sources: [&str; 3]) -> Self {
let [left, base, right] =
sources.map(|text| text.split('\n').map(str::to_owned).collect::<Vec<_>>());
let mut columns: [DiffState; 3] = std::array::from_fn(|_| DiffState::new());
columns[0].set_resolver_text(&base, &left);
columns[1].set_resolver_text(&base, &base);
columns[2].set_resolver_text(&base, &right);
let mut left = std::mem::take(&mut columns[0].rows).into_iter().peekable();
let mut right = std::mem::take(&mut columns[2].rows).into_iter().peekable();
columns[1].rows.clear();
// Stitch the existing pairwise alignments at their shared base rows.
while left.peek().is_some() || right.peek().is_some() {
let insertion = left.peek().is_some_and(|row| row.before.is_none())
|| right.peek().is_some_and(|row| row.before.is_none());
let take_left = left
.peek()
.is_some_and(|row| !insertion || row.before.is_none());
let take_right = right
.peek()
.is_some_and(|row| !insertion || row.before.is_none());
let empty = || AlignedRow {
before: None,
after: None,
kind: RowKind::Equal,
changes: Vec::new(),
};
let left_row = if take_left {
left.next().unwrap()
} else {
empty()
};
let right_row = if take_right {
right.next().unwrap()
} else {
empty()
};
let base = left_row.before.or(right_row.before);
let changed = left_row.kind != RowKind::Equal || right_row.kind != RowKind::Equal;
let changes = left_row
.changes
.iter()
.chain(&right_row.changes)
.map(|change| [change[0].clone(), change[0].clone()])
.collect();
columns[1].rows.push(AlignedRow {
before: base,
after: base,
kind: if changed {
RowKind::Changed
} else {
RowKind::Equal
},
changes,
});
columns[0].rows.push(left_row);
columns[2].rows.push(right_row);
}
for column in &mut columns {
column.display_rows = (0..column.rows.len()).map(DisplayRow::Line).collect();
column.rebuild_source_ranges();
}
Self { columns }
}
pub fn row_count(&self) -> usize {
self.columns[0].rows.len()
}
pub fn column(&self, index: usize) -> HistoryColumn<'_> {
let diff = &self.columns[index];
let file = &diff.comparison.as_ref().unwrap().files[0];
let focus = if index == 1 {
Focus::Before
} else {
Focus::After
};
let lines = if focus == Focus::Before {
&file.before
} else {
&file.after
};
HistoryColumn {
diff,
file,
focus,
lines,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RequestKind {
Catalog,
Comparison,
CheckoutRefresh {
comparison_id: u64,
},
File {
comparison_id: u64,
file_index: usize,
},
}
#[derive(Debug)]
pub struct DiffState {
pub catalog: Option<Catalog>,
pub pull_request_remote: Option<String>,
pull_request_categories: [PullRequestCategory; 2],
pull_request_order: BTreeMap<u64, (String, usize)>,
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 writable_side: Option<Focus>,
pub side_labels: [&'static str; 2],
pub before: Option<Revision>,
pub after: Option<Revision>,
pub category: usize,
pub query: String,
pub selected: usize,
pub loading: bool,
pub error: Option<String>,
pub comparison: Option<Comparison>,
pub local_review: crate::review::ReviewState,
pub comparison_id: Option<u64>,
pub reviewed_files: BTreeSet<usize>,
pub(crate) snapshot_roots: [Option<PathBuf>; 2],
pub files: Option<FilePicker>,
pub files_visible: bool,
pub file: usize,
external_file_selection: bool,
pub(crate) requested_file: Option<usize>,
pub focus: Focus,
pub top: usize,
pub left_before: usize,
pub left_after: usize,
pub rows: Vec<AlignedRow>,
pub display_rows: Vec<DisplayRow>,
display_sides: Vec<Option<Focus>>,
source_ranges: [Vec<(std::ops::Range<usize>, usize)>; 2],
lazy_changes: bool,
/// Native empty terminal rows explicitly supplied by resolver text.
terminal_rows: [Option<usize>; 2],
excerpt_ranges: Vec<(usize, usize)>,
pub live_buffer: Option<usize>,
pub live_revision: Option<u64>,
pub follow_caret: bool,
live_files: BTreeSet<usize>,
/// Files edited since their frozen comparison was loaded; undo does not restore review marks.
modified_after: BTreeSet<usize>,
frozen_after: BTreeMap<usize, (Vec<String>, bool)>,
context_lines: usize,
expand_lines: usize,
pending: Option<Operation>,
request_kind: Option<RequestKind>,
visible: Vec<usize>,
search_fields: Vec<[String; 3]>,
search_matches: Vec<([usize; 7], usize)>,
max_before: usize,
max_after: usize,
after_widths: Vec<usize>,
pub(crate) refresh_view: Option<(usize, usize, usize)>,
}
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,
writable_side: Some(Focus::After),
side_labels: ["Before", "After"],
before: None,
after: None,
category: 0,
query: String::new(),
selected: 0,
loading: true,
error: None,
comparison: None,
local_review: Default::default(),
comparison_id: None,
reviewed_files: BTreeSet::new(),
files: None,
snapshot_roots: [None, None],
files_visible: true,
file: 0,
external_file_selection: false,
requested_file: None,
focus: Focus::Files,
top: 0,
left_before: 0,
left_after: 0,
rows: Vec::new(),
display_rows: Vec::new(),
display_sides: Vec::new(),
source_ranges: Default::default(),
lazy_changes: false,
terminal_rows: [None; 2],
excerpt_ranges: Vec::new(),
live_buffer: None,
live_revision: None,
follow_caret: false,
live_files: BTreeSet::new(),
modified_after: 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(),
refresh_view: None,
}
}
pub(crate) fn set_resolver_paths(&mut self, root: PathBuf, paths: Vec<PathBuf>, file: usize) {
let comparison = self.comparison.get_or_insert_with(|| Comparison {
revisions: Default::default(),
blame: Default::default(),
before: "Resolution".into(),
after: "Other version".into(),
files: Vec::new(),
root: root.clone(),
editable_paths: BTreeSet::new(),
});
comparison.root = root.clone();
comparison.files.resize_with(paths.len(), || ChangedFile {
review_revisions: None,
path: PathBuf::new(),
old_path: None,
status: FileStatus::Modified,
loaded: true,
before: Vec::new(),
after: Vec::new(),
before_eof: false,
after_eof: false,
hunks: Vec::new(),
notice: None,
});
for (entry, path) in comparison.files.iter_mut().zip(&paths) {
entry.path.clone_from(path);
}
self.file = file;
self.external_file_selection = true;
self.requested_file = None;
if self.files.is_none() {
self.files = Some(FilePicker::from_paths(root, paths.into_iter().enumerate()));
} else if let Some(files) = &mut self.files {
files.replace_paths(paths.into_iter().enumerate());
}
if let Some(files) = &mut self.files {
files.reveal_path(&comparison.files[file].path);
}
}
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);
let before_terminal = (draft.len() > before.len()).then_some(before.len());
let after_terminal = (alternative.len() > after.len()).then_some(after.len());
if self.phase == Phase::View
&& self.terminal_rows == [before_terminal, after_terminal]
&& self.comparison.as_ref().is_some_and(|comparison| {
let file = &comparison.files[self.file];
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);
if self.comparison.is_none() {
self.set_resolver_paths(PathBuf::new(), vec![PathBuf::from("resolution.txt")], 0);
}
self.phase = Phase::View;
self.loading = false;
self.pending = None;
self.request_kind = None;
self.side_labels = ["Resolution", "Other version"];
self.comparison_id = Some(self.comparison_id.unwrap_or(0).wrapping_add(1));
self.live_files.clear();
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
file.before = before.to_vec();
file.after = after.to_vec();
file.before_eof = before_eof;
file.after_eof = after_eof;
file.hunks = line_hunks(before, before_eof, after, after_eof);
self.align_file();
self.terminal_rows = [before_terminal, after_terminal];
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[self.file]
.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<Item = &Revision> {
self.visible
.iter()
.map(|&index| &self.catalog.as_ref().expect("visible catalog").entries[index])
}
pub(crate) fn take_request(&mut self) -> Option<Operation> {
if !self.request_ready() {
return None;
}
self.pending.take()
}
pub(crate) fn request_queued(&self) -> bool {
self.pending.is_some()
}
pub(crate) fn request_ready(&self) -> bool {
self.pending.as_ref().is_some_and(|operation| {
!self.refresh_pending() || matches!(operation, Operation::RefreshCheckout { .. })
})
}
pub(crate) fn refresh_pending(&self) -> bool {
matches!(self.request_kind, Some(RequestKind::CheckoutRefresh { .. }))
}
pub(crate) fn queue_checkout_refresh(&mut self, follow_working: bool) {
if self.phase != Phase::View
|| self.loading
|| self.request_kind.is_some()
|| self.pending.is_some()
|| self.comparison.is_none()
|| self.after.as_ref().is_none_or(|after| {
after.target == RevisionTarget::Working
&& !follow_working
&& !self
.before
.iter()
.chain(self.after.iter())
.any(|revision| matches!(revision.target, RevisionTarget::Bookmark { .. }))
})
{
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<Outcome, String>) -> 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();
let bookmark_refresh = matches!(&result, Ok(Outcome::BookmarkRefresh(_)));
match result {
Ok(Outcome::CheckoutRefresh(None)) => {
let recovered = self.error.take().is_some();
if let Some(operation) = deferred {
self.queue(operation);
}
return recovered;
}
Ok(Outcome::CheckoutRefresh(Some(mut comparison)))
| Ok(Outcome::BookmarkRefresh(mut comparison)) => {
let pinned = !bookmark_refresh
&& 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.modified_after.clear();
self.live_buffer = None;
self.live_revision = None;
self.refresh_view = None;
self.follow_caret = false;
self.prepare_selected_file();
if pinned || bookmark_refresh {
if self.selected_file_loaded() {
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);
} else {
self.refresh_view = Some(view);
}
}
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(_) | Outcome::BookmarkRefresh(_))
) {
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.modified_after.clear();
self.frozen_after.clear();
self.refresh_view = None;
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();
if let Some(view) = self.refresh_view.take() {
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();
}
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<String> {
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)| {
fuzzy_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
.writable_side
.unwrap_or_else(|| self.first_text_focus());
self.normalize_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 side_label(&self, focus: Focus) -> &'static str {
self.side_labels[usize::from(focus == Focus::After)]
}
pub fn side_editable(&self, focus: Focus) -> bool {
self.writable_side == Some(focus)
&& match focus {
Focus::Before => self.selected_file_loaded(),
Focus::After => self.after_editable(),
Focus::Files => false,
}
}
pub fn after_editable(&self) -> bool {
matches!(self.pane_layout(), PaneLayout::Split | PaneLayout::After)
&& self.live_after_available()
}
pub(crate) fn live_after_available(&self) -> bool {
self.phase == Phase::View
&& self.selected_file_loaded()
&& self.comparison.as_ref().is_some_and(|comparison| {
comparison.files.get(self.file).is_some_and(|file| {
comparison.editable_paths.contains(&file.path)
&& file.status != FileStatus::Removed
&& file
.notice
.as_deref()
.is_none_or(|notice| !notice.contains("Binary or non-UTF-8"))
})
})
}
pub(crate) fn toggle_reviewed(&mut self) -> Result<bool, &'static str> {
let comparison = self
.comparison
.as_ref()
.filter(|_| self.phase == Phase::View)
.ok_or("No comparison is open")?;
let index = if self.focus == Focus::Files {
let entry = self
.files
.as_ref()
.filter(|_| self.files_visible)
.and_then(|files| files.entries().nth(files.selected))
.ok_or("No changed file is selected")?;
if entry.is_dir {
return Err("Select a changed file, not a directory");
}
entry
.catalog_index
.filter(|&index| {
comparison
.files
.get(index)
.is_some_and(|file| file.path == entry.path)
})
.ok_or("No changed file is selected")?
} else {
self.file
};
let file = comparison
.files
.get(index)
.ok_or("No changed file is selected")?;
let reviewed = !self.reviewed_files.remove(&index);
if reviewed {
self.reviewed_files.insert(index);
}
// Resolver comparisons have no immutable revision identity or local review.
if comparison
.revisions
.iter()
.all(|revision| !revision.is_empty())
{
if self.local_review.document.root.as_os_str().is_empty() {
self.local_review.document = crate::review::Review::new(comparison.root.clone());
}
let records = &mut self.local_review.document.reviewed_files;
records.retain(|record| {
record.revisions
!= *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
|| record.path != file.path
});
if reviewed {
records.push(crate::review::ReviewedFile {
revisions: file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
.clone(),
path: file.path.clone(),
old_path: file.old_path.clone(),
after: self
.live_files
.contains(&index)
.then(|| file.after.join("\n")),
});
}
self.local_review.dirty = true;
}
Ok(reviewed)
}
pub(crate) fn restore_reviewed_files(&mut self) {
self.reviewed_files.clear();
let Some(comparison) = self.comparison.as_ref() else {
return;
};
if self.local_review.document.root != comparison.root {
return;
}
let records: BTreeMap<_, _> = self
.local_review
.document
.reviewed_files
.iter()
.map(|record| ((&record.revisions, &record.path), record))
.collect();
for (index, file) in comparison.files.iter().enumerate() {
let Some(record) = records
.get(&(
file.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions),
&file.path,
))
.filter(|record| record.old_path == file.old_path)
else {
continue;
};
let matches = match &record.after {
None => !self.modified_after.contains(&index),
Some(text) => {
file.loaded
&& (text
.split('\n')
.eq(file.after.iter().map(String::as_str).chain(
((!self.live_files.contains(&index) && file.after_eof)
|| file.after.is_empty())
.then_some(""),
)))
}
};
if matches {
self.reviewed_files.insert(index);
}
}
}
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);
}
if self.external_file_selection {
self.requested_file = Some(index);
return true;
}
self.file = index;
self.refresh_view = None;
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)
}
pub(crate) fn column_view(&self, focus: Focus) -> Option<ColumnView<'_>> {
if focus == Focus::Files {
return None;
}
let file = self
.comparison
.as_ref()?
.files
.get(self.file)
.filter(|file| file.loaded)?;
Some(ColumnView::new(self, file, focus))
}
/// Source rows without the editor's virtual terminal row, preserving real empty lines.
pub(crate) fn after_source_lines<'a>(&self, file: &'a ChangedFile) -> &'a [String] {
if self.live_files.contains(&self.file) {
live_content(&file.after, file.after_eof)
} else {
&file.after
}
}
fn prepare_selected_file(&mut self) {
if self.refresh_pending() {
self.align_file();
if !self.selected_file_loaded() {
self.load_file(self.file);
} else if matches!(self.pending, Some(Operation::LoadFile { .. })) {
self.pending = None;
self.loading = false;
}
return;
}
self.pending = None;
self.request_kind = None;
self.loading = false;
self.error = None;
if self.selected_file_loaded() {
self.align_file();
} else {
self.align_file();
if let Some(comparison_id) = self.comparison_id
&& self
.comparison
.as_ref()
.is_some_and(|comparison| !comparison.files.is_empty())
{
self.queue(Operation::LoadFile {
comparison_id,
file_index: self.file,
});
}
}
}
pub(crate) fn load_file(&mut self, file_index: usize) {
if let Some(comparison_id) = self.comparison_id {
self.queue(Operation::LoadFile {
comparison_id,
file_index,
});
}
}
pub(crate) fn source_range_changed(
&self,
file_index: usize,
focus: Focus,
first: usize,
last: usize,
) -> bool {
if file_index == self.file {
return self.rows.iter().any(|aligned| {
aligned.kind != RowKind::Equal
&& match focus {
Focus::Before => aligned
.before
.is_some_and(|row| first <= row && row <= last),
Focus::After => {
aligned.after.is_some_and(|row| first <= row && row <= last)
}
Focus::Files => false,
}
});
}
self.comparison
.as_ref()
.and_then(|comparison| comparison.files.get(file_index))
.is_some_and(|file| {
file.hunks.iter().any(|hunk| {
let (start, len) = match focus {
Focus::Before => (hunk.before_start, hunk.before_len),
Focus::After => (hunk.after_start, hunk.after_len),
Focus::Files => return false,
};
len > 0 && start <= last && first < start.saturating_add(len)
})
})
}
pub(crate) fn reveal_source_row(&mut self, focus: Focus, row: usize) {
if self.source_display_row(focus, row).is_none()
&& let Some(index) = self.rows.iter().position(|aligned| match focus {
Focus::Before => aligned.before == Some(row),
Focus::After => aligned.after == Some(row),
Focus::Files => false,
})
{
self.excerpt_ranges.push((index, index + 1));
self.excerpt_ranges.sort_unstable();
self.rebuild_display_rows();
}
if let Some(display) = self.source_display_row(focus, row) {
self.top = display;
self.follow_caret = true;
}
}
pub(crate) fn source_display_row(&self, focus: Focus, source_row: usize) -> Option<usize> {
let ranges = self.source_ranges.get(usize::from(focus == Focus::After))?;
if focus == Focus::Files {
return None;
}
let index = ranges.partition_point(|(range, _)| range.end <= source_row);
let (range, display) = ranges.get(index)?;
range
.contains(&source_row)
.then(|| display + source_row - range.start)
}
pub(crate) fn display_focus(&self, display: usize) -> Option<Focus> {
self.display_sides.get(display).copied().flatten()
}
pub(crate) fn display_source_row(&self, focus: Focus, display: usize) -> Option<usize> {
if self
.display_focus(display)
.is_some_and(|side| side != focus)
{
return None;
}
match *self.display_rows.get(display)? {
DisplayRow::Line(index) => self.source_row(focus, index),
DisplayRow::Fold { .. } => None,
}
}
pub(crate) fn set_view_mode(&mut self, view_mode: ViewMode) {
if self.view_mode == view_mode {
return;
}
let anchor = self.display_rows.get(self.top).copied();
let side = self.display_focus(self.top).or_else(|| {
[self.focus, Focus::After, Focus::Before]
.into_iter()
.find(|&focus| self.display_source_row(focus, self.top).is_some())
});
self.view_mode = view_mode;
self.rebuild_display_rows();
self.restore_top(anchor, side);
}
pub fn after_display_row(&self, source_row: usize) -> Option<usize> {
self.source_display_row(Focus::After, source_row)
}
fn source_row(&self, focus: Focus, index: usize) -> Option<usize> {
match focus {
Focus::Before => self.rows[index].before,
Focus::After => self.rows[index].after,
Focus::Files => None,
}
}
pub(crate) fn source_rows_in(
&self,
focus: Focus,
start: usize,
end: usize,
) -> impl DoubleEndedIterator<Item = usize> + '_ {
let ranges = &self.source_ranges[usize::from(focus == Focus::After)];
let first = ranges.partition_point(|(range, _)| range.end <= start);
let last = ranges
.partition_point(|(range, _)| range.start < end)
.max(first);
ranges[first..last].iter().flat_map(move |(range, _)| {
let start = range.start.max(start);
start..if focus == Focus::Files {
start
} else {
range.end.min(end)
}
})
}
pub(crate) fn snapshot_lines_at(&self, file_index: usize, focus: Focus) -> Vec<String> {
let file = &self.comparison.as_ref().expect("snapshot comparison").files[file_index];
let (source, eof, live) = match focus {
Focus::Before => (&file.before, file.before_eof, false),
Focus::After => (
&file.after,
file.after_eof,
self.live_files.contains(&file_index),
),
Focus::Files => unreachable!("snapshot belongs to a text pane"),
};
let mut lines = source.clone();
if !live && (eof || lines.is_empty()) {
lines.push(String::new());
}
lines
}
pub fn review_anchor_current(&self, anchor: &crate::review::Anchor) -> bool {
use crate::review::Side;
let Some(comparison) = &self.comparison else {
return false;
};
if self.local_review.document.root != comparison.root {
return false;
}
comparison.files.iter().enumerate().any(|(index, file)| {
if !file.loaded || file.notice.is_some() {
return false;
}
let (path, lines, eof) = match anchor.side {
Side::Before => (
file.old_path.as_deref().unwrap_or(&file.path),
&file.before,
file.before_eof,
),
Side::After => (
file.path.as_path(),
&file.after,
file.after_eof && !self.live_files.contains(&index),
),
};
anchor.matches_lines(
&comparison.revisions[anchor.side.index()],
path,
anchor.side,
lines,
eof,
)
})
}
pub(crate) fn corresponding_row(&self, from: Focus, to: Focus, row: usize) -> Option<usize> {
let aligned = self
.source_display_row(from, row)
.and_then(|display| match self.display_rows[display] {
DisplayRow::Line(index) => Some(index),
DisplayRow::Fold { .. } => None,
})
.or_else(|| {
self.rows.iter().position(|aligned| match from {
Focus::Before => aligned.before == Some(row),
Focus::After => aligned.after == Some(row),
Focus::Files => false,
})
})?;
if to == Focus::Files {
return None;
}
let mut nearest = None;
for (range, display) in &self.source_ranges[usize::from(to == Focus::After)] {
let visible = &self.display_rows[*display..*display + range.len()];
let offset = visible
.partition_point(|row| matches!(row, DisplayRow::Line(index) if *index < aligned));
for offset in [offset.saturating_sub(1), offset.min(visible.len() - 1)] {
let DisplayRow::Line(index) = visible[offset] else {
unreachable!()
};
let candidate = (index.abs_diff(aligned), range.start + offset);
if nearest.is_none_or(|previous| candidate < previous) {
nearest = Some(candidate);
}
}
}
nearest.map(|(_, row)| row)
}
pub(crate) fn clamp_source_row(&self, focus: Focus, row: usize) -> Option<usize> {
let ranges = &self.source_ranges[usize::from(focus == Focus::After)];
if focus == Focus::Files {
return None;
}
let index = ranges.partition_point(|(range, _)| range.end <= row);
let next = ranges.get(index).map(|(range, _)| row.max(range.start));
let previous = index.checked_sub(1).map(|index| ranges[index].0.end - 1);
match (previous, next) {
(Some(previous), Some(next)) if row - previous < next - row => Some(previous),
(_, Some(next)) => Some(next),
(previous, None) => previous,
}
}
fn restore_top(&mut self, anchor: Option<DisplayRow>, side: Option<Focus>) {
if let Some(anchor) = anchor {
let index = match anchor {
DisplayRow::Line(index) => index,
DisplayRow::Fold { start, .. } => start,
};
self.top = self
.display_rows
.iter()
.enumerate()
.position(|(display, row)| match *row {
DisplayRow::Line(row) => {
row == index
&& side.is_none_or(|side| {
self.display_focus(display)
.is_none_or(|focus| focus == side)
})
}
DisplayRow::Fold { start, end } => start <= index && index < end,
})
.unwrap_or(0);
}
}
pub fn refresh_after(&mut self, lines: &[String], revision: u64) {
if !self.live_after_available() {
return;
}
if self.live_revision == Some(revision) {
return;
}
let file = &self.comparison.as_ref().unwrap().files[self.file];
if self
.after
.as_ref()
.is_some_and(|after| after.target != RevisionTarget::Working)
{
self.frozen_after
.entry(self.file)
.or_insert_with(|| (file.after.clone(), file.after_eof));
}
let was_live = self.live_files.contains(&self.file);
let changed_lines = changed_line_range(&file.after, lines);
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.modified_after.insert(self.file);
}
let comparison = self.comparison.as_ref().unwrap();
let record = (self.local_review.document.root == comparison.root)
.then(|| {
self.local_review
.document
.reviewed_files
.iter()
.position(|record| {
record.revisions
== *file
.review_revisions
.as_ref()
.unwrap_or(&comparison.revisions)
&& record.path == file.path
&& record.old_path == file.old_path
})
})
.flatten();
let matches = record
.and_then(|position| {
self.local_review.document.reviewed_files[position]
.after
.as_ref()
})
.map(|text| text.split('\n').eq(lines.iter().map(String::as_str)));
if matches == Some(true) {
self.reviewed_files.insert(self.file);
}
if self.reviewed_files.contains(&self.file) && !matches.unwrap_or(unchanged) {
self.reviewed_files.remove(&self.file);
if let Some(position) = record {
self.local_review.document.reviewed_files.remove(position);
self.local_review.dirty = true;
}
}
if was_live && changed_lines.0 == changed_lines.1 && changed_lines.1 == changed_lines.2 {
self.live_revision = Some(revision);
return;
}
if was_live && stable_after_edit(file, lines, changed_lines) {
let file = &mut self.comparison.as_mut().unwrap().files[self.file];
replace_after_lines(
&mut file.after,
&mut self.after_widths,
&mut self.max_after,
lines,
changed_lines,
);
refresh_character_changes(&mut self.rows, file, changed_lines.0..changed_lines.2);
self.left_after = self.left_after.min(self.max_after);
self.live_revision = Some(revision);
return;
}
let mut old = file.after.iter().map(String::as_str).collect::<Vec<_>>();
if !was_live && (file.after_eof || old.is_empty()) {
old.push("");
}
let new = lines.iter().map(String::as_str).collect::<Vec<_>>();
let ops = similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&old,
&new,
Some(Instant::now() + Duration::from_millis(100)),
);
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.terminal_rows = [None; 2];
self.display_rows.clear();
self.display_sides.clear();
self.source_ranges.iter_mut().for_each(Vec::clear);
self.excerpt_ranges.clear();
if reset {
self.top = 0;
self.left_before = 0;
self.left_after = 0;
self.lazy_changes = false;
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;
};
self.lazy_changes = file.before.len().saturating_add(file.after.len()) > 20_000;
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();
self.source_ranges.iter_mut().for_each(Vec::clear);
if self.excerpt_ranges.is_empty() {
return;
}
let visible = self
.excerpt_ranges
.iter()
.map(|&(start, end)| end - start)
.sum::<usize>();
let capacity = visible
.saturating_add(self.excerpt_ranges.len())
.saturating_add(1);
self.display_rows.reserve(capacity);
if self.view_mode == ViewMode::Unified {
self.display_sides.reserve(capacity);
}
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);
}
}
self.rebuild_source_ranges();
}
fn rebuild_source_ranges(&mut self) {
self.source_ranges.iter_mut().for_each(Vec::clear);
for display in 0..self.display_rows.len() {
for (side, focus) in [Focus::Before, Focus::After].into_iter().enumerate() {
let Some(source) = self.display_source_row(focus, display) else {
continue;
};
let ranges = &mut self.source_ranges[side];
if let Some((range, first)) = ranges.last_mut()
&& range.end == source
&& *first + range.len() == display
{
range.end += 1;
} else {
ranges.push((source..source + 1, display));
}
}
}
}
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);
}
}
fn append_rows(
rows: &mut Vec<AlignedRow>,
before: usize,
before_end: usize,
after: usize,
after_end: usize,
changed: bool,
) {
for offset in 0..(before_end - before).max(after_end - after) {
let before = (offset < before_end - before).then_some(before + offset);
let after = (offset < after_end - after).then_some(after + offset);
let kind = match (before, after) {
(Some(_), Some(_)) if changed => RowKind::Changed,
(Some(_), Some(_)) => RowKind::Equal,
(Some(_), None) => RowKind::Removed,
(None, Some(_)) => RowKind::Added,
(None, None) => unreachable!("alignment has at least one line"),
};
rows.push(AlignedRow {
before,
after,
kind,
changes: Vec::new(),
});
}
}
fn refresh_character_changes(
rows: &mut [AlignedRow],
file: &ChangedFile,
after_rows: std::ops::Range<usize>,
) {
let deadline = Instant::now() + Duration::from_millis(20);
for row in rows {
if row.kind == RowKind::Changed
&& let Some(after) = row.after.filter(|index| after_rows.contains(index))
{
if file.before.len().saturating_add(file.after.len()) <= 20_000 {
row.changes = character_changes_deadline(
&file.before[row.before.expect("paired changed row")],
&file.after[after],
deadline,
);
} else {
row.changes.clear();
}
}
}
}
fn character_changes(before: &str, after: &str) -> Vec<[std::ops::Range<usize>; 2]> {
character_changes_deadline(before, after, Instant::now() + Duration::from_millis(2))
}
fn character_changes_deadline(
before: &str,
after: &str,
deadline: Instant,
) -> Vec<[std::ops::Range<usize>; 2]> {
if before == after {
return Vec::new();
}
// Large/minified lines keep their complete text and line-level change shading,
// without allocating a grapheme table proportional to their size.
if before.len().saturating_add(after.len()) > 64 * 1024 || Instant::now() >= deadline {
return vec![[0..usize::MAX, 0..usize::MAX]];
}
let before: Vec<_> = before.graphemes(true).collect();
let after: Vec<_> = after.graphemes(true).collect();
let mut changes: Vec<[std::ops::Range<usize>; 2]> = Vec::new();
let (mut before_offset, mut after_offset) = (0, 0);
for op in similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&before,
&after,
if before.len().saturating_add(after.len()) <= 2048 {
None
} else {
Some(deadline.min(Instant::now() + Duration::from_millis(2)))
},
) {
let (tag, before, after) = op.as_tag_tuple();
// similar's optimized empty-range anchors can be stale; consume lengths in order.
let before = before_offset..before_offset + before.len();
let after = after_offset..after_offset + after.len();
(before_offset, after_offset) = (before.end, after.end);
if tag == similar::DiffTag::Equal {
continue;
}
if let Some(previous) = changes.last_mut()
&& (before.start - previous[0].end <= 2 || after.start - previous[1].end <= 2)
{
previous[0].end = before.end;
previous[1].end = after.end;
} else {
changes.push([before, after]);
}
}
// A merge can enable an earlier gap; VS Code bounds this pass to 11 sweeps.
for _ in 0..11 {
let mut kept = 1;
for index in 1..changes.len() {
if short_match_between_large_changes(&before, &changes[kept - 1], &changes[index]) {
changes[kept - 1][0].end = changes[index][0].end;
changes[kept - 1][1].end = changes[index][1].end;
} else {
if kept != index {
changes[kept] = changes[index].clone();
}
kept += 1;
}
}
if kept >= changes.len() {
break;
}
changes.truncate(kept);
}
changes
}
fn short_match_between_large_changes(
before: &[&str],
previous: &[std::ops::Range<usize>; 2],
next: &[std::ops::Range<usize>; 2],
) -> bool {
let gap = &before[previous[0].end..next[0].start];
if gap.len() > 500 {
return false;
}
let start = gap
.iter()
.position(|grapheme| !grapheme.trim().is_empty())
.unwrap_or(gap.len());
let trimmed_len = gap[start..]
.iter()
.rposition(|grapheme| !grapheme.trim().is_empty())
.map_or(0, |end| end + 1);
if trimmed_len > 20 {
return false;
}
// VS Code's size heuristic, specialized to single lines and normalized by 130^2.25:
// https://github.com/microsoft/vscode/blob/main/src/vs/editor/common/diff/defaultLinesDiffComputer/heuristicSequenceOptimizations.ts
let score = |change: &[std::ops::Range<usize>; 2]| {
let weight = change
.iter()
.map(|range| {
let length = range.len().min(130) as f64 / 130.0;
length * length.sqrt()
})
.sum::<f64>();
weight * weight.sqrt()
};
score(previous) + score(next) > 1.3
}
fn line_width(line: &str) -> usize {
#[cfg(test)]
tests::WIDTH_SCANS.with(|scans| scans.set(scans.get() + 1));
if line.is_ascii() {
if !line.bytes().any(|byte| byte < 32 || byte == 127) {
return line.len();
}
return line.bytes().fold(0, |column, byte| {
column
+ match byte {
b'\t' => 4 - column % 4,
0..=31 | 127 => 0,
_ => 1,
}
});
}
line.graphemes(true).fold(0, |column, grapheme| {
column
+ if grapheme == "\t" {
4 - column % 4
} else {
UnicodeWidthStr::width(grapheme)
}
})
}
fn changed_line_range(old: &[String], new: &[String]) -> (usize, usize, usize) {
let start = old
.iter()
.zip(new)
.take_while(|(old, new)| old == new)
.count();
let suffix = old[start..]
.iter()
.rev()
.zip(new[start..].iter().rev())
.take_while(|(old, new)| old == new)
.count();
(start, old.len() - suffix, new.len() - suffix)
}
fn stable_after_edit(
file: &ChangedFile,
lines: &[String],
(start, old_end, new_end): (usize, usize, usize),
) -> bool {
if old_end != start + 1
|| new_end != start + 1
|| file.after_eof != (lines.len() > 1 && lines.last().is_some_and(String::is_empty))
|| live_content(&file.after, file.after_eof).len()
!= live_content(lines, file.after_eof).len()
{
return false;
}
let old = &file.after[start];
let new = &lines[start];
// With neither token matching another line, the equality graph seen by Myers
// is unchanged. Hunks, alignment, excerpts and scroll anchors remain exact.
!file.before.iter().any(|line| line == old || line == new)
&& !file
.after
.iter()
.enumerate()
.any(|(index, line)| index != start && (line == old || line == new))
}
fn replace_after_lines(
after: &mut Vec<String>,
widths: &mut Vec<usize>,
max_width: &mut usize,
lines: &[String],
(start, old_end, new_end): (usize, usize, usize),
) {
let removed_max = widths[start..old_end].iter().copied().max().unwrap_or(0);
if old_end == new_end {
for index in start..new_end {
if after[index] != lines[index] {
after[index].clone_from(&lines[index]);
widths[index] = line_width(&lines[index]);
}
}
} else {
after.splice(start..old_end, lines[start..new_end].iter().cloned());
widths.splice(
start..old_end,
lines[start..new_end].iter().map(|line| line_width(line)),
);
}
let added_max = widths[start..new_end].iter().copied().max().unwrap_or(0);
*max_width = if removed_max == *max_width && added_max < removed_max {
widths.iter().copied().max().unwrap_or(0)
} else {
(*max_width).max(added_max)
};
}
fn live_content(lines: &[String], eof: bool) -> &[String] {
if eof || (lines.len() == 1 && lines[0].is_empty()) {
&lines[..lines.len().saturating_sub(1)]
} else {
lines
}
}
pub(crate) fn line_hunks(
before: &[String],
before_eof: bool,
after: &[String],
after_eof: bool,
) -> Vec<Hunk> {
// Empty sides need neither a token table nor a Myers frontier.
if before.is_empty() || after.is_empty() {
return if before.is_empty() && after.is_empty() {
Vec::new()
} else {
vec![Hunk {
before_start: 0,
before_len: before.len(),
after_start: 0,
after_len: after.len(),
}]
};
}
let same = |old: usize, new: usize| {
before[old] == after[new]
&& (old + 1 < before.len() || before_eof) == (new + 1 < after.len() || after_eof)
};
let large = before.len().saturating_add(after.len()) > 20_000;
// Keep ordinary Myers/Compact anchoring unchanged; large comparisons avoid
// tokenizing their already-equal prefix and suffix.
let (start, suffix) = if large {
let start = (0..before.len().min(after.len()))
.take_while(|&index| same(index, index))
.count();
let suffix = (0..(before.len() - start).min(after.len() - start))
.take_while(|&offset| same(before.len() - 1 - offset, after.len() - 1 - offset))
.count();
(start, suffix)
} else {
(0, 0)
};
let before_end = before.len() - suffix;
let after_end = after.len() - suffix;
if start == before_end || start == after_end {
return if start == before_end && start == after_end {
Vec::new()
} else {
vec![Hunk {
before_start: start,
before_len: before_end - start,
after_start: start,
after_len: after_end - start,
}]
};
}
fn tokens(lines: &[String], eof: bool, range: std::ops::Range<usize>) -> Vec<(&str, bool)> {
range
.map(|index| (lines[index].as_str(), index + 1 < lines.len() || eof))
.collect()
}
let old = tokens(before, before_eof, start..before_end);
let new = tokens(after, after_eof, start..after_end);
// ponytail: after 100ms Myers may conservatively replace unresolved lines;
// every source line remains present, never mislabeled as equal.
let mut old_offset = start;
let mut new_offset = start;
similar::capture_diff_slices_deadline(
similar::Algorithm::Myers,
&old,
&new,
Some(Instant::now() + Duration::from_millis(100)),
)
.into_iter()
.filter_map(|op| {
let (tag, old, new) = op.as_tag_tuple();
let hunk = Hunk {
before_start: if large { old_offset } else { old.start },
before_len: old.len(),
after_start: if large { new_offset } else { new.start },
after_len: new.len(),
};
old_offset += old.len();
new_offset += new.len();
(tag != similar::DiffTag::Equal).then_some(hunk)
})
.collect()
}
fn file_is_changed(file: &ChangedFile) -> bool {
!file.loaded
|| file.status != FileStatus::Modified
|| !file.hunks.is_empty()
|| file.notice.as_ref().is_some_and(|notice| {
notice
.split("; ")
.any(|part| !part.starts_with("EOF newline:"))
})
}
fn update_eof_notice(file: &mut ChangedFile) {
let mut notices = file
.notice
.as_deref()
.unwrap_or("")
.split("; ")
.filter(|part| !part.is_empty() && !part.starts_with("EOF newline:"))
.map(str::to_owned)
.collect::<Vec<_>>();
let before_empty = file.before.is_empty();
let after_empty = live_content(&file.after, file.after_eof).is_empty();
if (!before_empty && !file.before_eof) || (!after_empty && !file.after_eof) {
let label = |absent, empty, eof| {
if absent {
"absent"
} else if empty {
"empty file"
} else if eof {
"present"
} else {
"missing"
}
};
notices.push(format!(
"EOF newline: Before {}, After {}",
label(
file.status == FileStatus::Added,
before_empty,
file.before_eof
),
label(false, after_empty, file.after_eof)
));
}
file.notice = (!notices.is_empty()).then(|| notices.join("; "));
}
#[cfg(test)]
#[path = "../tests/unit/diff.rs"]
mod tests;