use crate::change::{Change, ChangeSpan};
use crate::diff::{DiffEngine, DiffResult};
use crate::git::{ChangedFile, FileStatus};
use crate::step::{DiffNavigator, StepDirection};
use ignore::overrides::OverrideBuilder;
use ignore::WalkBuilder;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MultiDiffError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Git error: {0}")]
Git(#[from] crate::git::GitError),
}
#[derive(Debug, Clone)]
pub struct FileEntry {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub old_source_path: Option<PathBuf>,
pub new_source_path: Option<PathBuf>,
pub display_name: String,
pub status: FileStatus,
pub insertions: usize,
pub deletions: usize,
pub binary: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileSide {
Old,
New,
}
#[derive(Debug, Clone)]
struct SourceRoots {
old: PathBuf,
new: PathBuf,
}
pub struct MultiFileDiff {
pub files: Vec<FileEntry>,
pub selected_index: usize,
navigators: Vec<Option<DiffNavigator>>,
navigator_is_placeholder: Vec<bool>,
#[allow(dead_code)]
repo_root: Option<PathBuf>,
git_mode: Option<GitDiffMode>,
source_roots: Option<SourceRoots>,
old_contents: Vec<Arc<str>>,
new_contents: Vec<Arc<str>>,
precomputed_diffs: Vec<Option<PrecomputedDiff>>,
diff_statuses: Vec<DiffStatus>,
}
#[derive(Debug, Clone)]
enum GitDiffMode {
Uncommitted,
Staged,
IndexRange { from: String, to_index: bool },
Range { from: String, to: String },
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BlameSource {
Worktree,
Index,
Commit(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffStatus {
Ready,
Deferred,
Computing,
Failed,
Disabled,
}
#[derive(Debug, Clone)]
enum PrecomputedDiff {
Placeholder(DiffResult),
Ready(DiffResult),
}
const DEFAULT_DIFF_MAX_BYTES: u64 = 16 * 1024 * 1024;
const DEFAULT_FULL_CONTEXT_MAX_BYTES: u64 = 2 * 1024 * 1024;
static DIFF_MAX_BYTES: AtomicU64 = AtomicU64::new(DEFAULT_DIFF_MAX_BYTES);
static FULL_CONTEXT_MAX_BYTES: AtomicU64 = AtomicU64::new(DEFAULT_FULL_CONTEXT_MAX_BYTES);
static DIFF_DEFER: AtomicBool = AtomicBool::new(true);
pub const DEFAULT_SCAN_IGNORE_GLOBS: &[&str] = &[".git/**", ".jj/**", ".hg/**", ".svn/**"];
#[derive(Debug, Clone)]
pub struct DirectoryScanOptions {
pub git_ignore: bool,
pub ignore_globs: Vec<String>,
}
impl Default for DirectoryScanOptions {
fn default() -> Self {
Self {
git_ignore: true,
ignore_globs: DEFAULT_SCAN_IGNORE_GLOBS
.iter()
.map(|pattern| (*pattern).to_string())
.collect(),
}
}
}
impl MultiFileDiff {
const MAX_TEXT_BYTES: u64 = 32 * 1024 * 1024;
const MAX_WORD_LEVEL_BYTES: u64 = 2 * 1024 * 1024;
const MAX_LINE_CHARS: usize = 16_384;
pub fn set_diff_max_bytes(max_bytes: u64) {
let limit = max_bytes.max(1);
DIFF_MAX_BYTES.store(limit, Ordering::Relaxed);
}
pub fn set_full_context_max_bytes(max_bytes: u64) {
let limit = max_bytes.max(1);
FULL_CONTEXT_MAX_BYTES.store(limit, Ordering::Relaxed);
}
pub fn set_diff_defer(enabled: bool) {
DIFF_DEFER.store(enabled, Ordering::Relaxed);
}
fn diff_max_bytes() -> u64 {
DIFF_MAX_BYTES.load(Ordering::Relaxed)
}
fn full_context_max_bytes() -> u64 {
FULL_CONTEXT_MAX_BYTES.load(Ordering::Relaxed)
}
fn diff_defer_enabled() -> bool {
DIFF_DEFER.load(Ordering::Relaxed)
}
fn decode_bytes(bytes: Vec<u8>) -> (String, bool) {
if bytes.is_empty() {
return (String::new(), false);
}
if bytes.contains(&0) || std::str::from_utf8(&bytes).is_err() {
return (String::new(), true);
}
let text = String::from_utf8_lossy(&bytes).to_string();
(Self::normalize_text(text), false)
}
fn text_too_large(size: u64) -> bool {
size > Self::MAX_TEXT_BYTES
}
fn read_text_or_binary(path: &Path) -> (String, bool) {
if let Ok(metadata) = path.metadata() {
if Self::text_too_large(metadata.len()) {
return (String::new(), true);
}
}
let bytes = std::fs::read(path).unwrap_or_default();
Self::decode_bytes(bytes)
}
fn read_git_commit_or_binary(repo_root: &Path, commit: &str, path: &Path) -> (String, bool) {
if let Some(size) = crate::git::get_file_at_commit_size(repo_root, commit, path) {
if Self::text_too_large(size) {
return (String::new(), true);
}
}
let bytes =
crate::git::get_file_at_commit_bytes(repo_root, commit, path).unwrap_or_default();
Self::decode_bytes(bytes)
}
fn read_git_index_or_binary(repo_root: &Path, path: &Path) -> (String, bool) {
if let Some(size) = crate::git::get_staged_content_size(repo_root, path) {
if Self::text_too_large(size) {
return (String::new(), true);
}
}
let bytes = crate::git::get_staged_content_bytes(repo_root, path).unwrap_or_default();
Self::decode_bytes(bytes)
}
fn diff_strings(old: &str, new: &str) -> crate::diff::DiffResult {
let max_len = old.len().max(new.len()) as u64;
let word_level = max_len <= Self::MAX_WORD_LEVEL_BYTES;
let context_limit = Self::full_context_max_bytes().min(Self::diff_max_bytes());
let context_lines = if max_len > context_limit {
3
} else {
usize::MAX
};
DiffEngine::new()
.with_word_level(word_level)
.with_context(context_lines)
.diff_strings(old, new)
}
pub fn compute_diff(old: &str, new: &str) -> crate::diff::DiffResult {
Self::diff_strings(old, new)
}
fn should_defer_diff(old: &str, new: &str) -> bool {
let max_len = old.len().max(new.len()) as u64;
max_len > Self::diff_max_bytes()
}
fn context_only_diff(text: &str) -> DiffResult {
let mut changes = Vec::new();
for (change_id, line) in text.split('\n').enumerate() {
let line_num = change_id + 1;
let span = ChangeSpan::equal(line).with_lines(Some(line_num), Some(line_num));
changes.push(Change::single(change_id, span));
}
DiffResult {
changes,
significant_changes: Vec::new(),
hunks: Vec::new(),
insertions: 0,
deletions: 0,
}
}
fn diff_stats(old: &str, new: &str, binary: bool) -> (usize, usize) {
if binary {
return (0, 0);
}
let max_len = old.len().max(new.len()) as u64;
if max_len > Self::MAX_WORD_LEVEL_BYTES {
let old_lines = old.lines().count();
let new_lines = new.lines().count();
if old_lines == 0 {
return (new_lines, 0);
}
if new_lines == 0 {
return (0, old_lines);
}
return (0, 0);
}
let diff = Self::diff_strings(old, new);
(diff.insertions, diff.deletions)
}
fn normalize_text(text: String) -> String {
if !text.lines().any(|line| line.len() > Self::MAX_LINE_CHARS) {
return text;
}
let mut out = String::new();
for chunk in text.split_inclusive('\n') {
let (line, has_newline) = if let Some(line) = chunk.strip_suffix('\n') {
(line, true)
} else {
(chunk, false)
};
if line.len() > Self::MAX_LINE_CHARS {
let cutoff = line
.char_indices()
.nth(Self::MAX_LINE_CHARS)
.map(|(idx, _)| idx)
.unwrap_or_else(|| line.len());
out.push_str(&line[..cutoff]);
out.push('…');
} else {
out.push_str(line);
}
if has_newline {
out.push('\n');
}
}
out
}
fn maybe_defer_diff(
old_content: String,
new_content: String,
binary: bool,
) -> (String, String, Option<PrecomputedDiff>, DiffStatus) {
if binary {
return (String::new(), String::new(), None, DiffStatus::Disabled);
}
if Self::should_defer_diff(&old_content, &new_content) {
let display = if new_content.is_empty() {
old_content.clone()
} else {
new_content.clone()
};
let diff = Self::context_only_diff(&display);
let status = if Self::diff_defer_enabled() {
DiffStatus::Deferred
} else {
DiffStatus::Disabled
};
return (
old_content,
new_content,
Some(PrecomputedDiff::Placeholder(diff)),
status,
);
}
(old_content, new_content, None, DiffStatus::Ready)
}
pub fn from_git_changes(
repo_root: PathBuf,
changes: Vec<ChangedFile>,
) -> Result<Self, MultiDiffError> {
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
for change in changes {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &change.path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => {
let full_path = repo_root.join(&change.path);
Self::read_text_or_binary(&full_path)
}
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: change.path.display().to_string(),
path: change.path,
old_path: change.old_path,
old_source_path: None,
new_source_path: None,
status: change.status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
Ok(Self {
files,
selected_index: 0,
navigators,
navigator_is_placeholder,
repo_root: Some(repo_root),
git_mode: Some(GitDiffMode::Uncommitted),
source_roots: None,
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
})
}
pub fn from_git_staged(
repo_root: PathBuf,
changes: Vec<ChangedFile>,
) -> Result<Self, MultiDiffError> {
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
for change in changes {
let old_path = change
.old_path
.clone()
.unwrap_or_else(|| change.path.clone());
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &change.path),
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: change.path.display().to_string(),
path: change.path,
old_path: change.old_path,
old_source_path: None,
new_source_path: None,
status: change.status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
Ok(Self {
files,
selected_index: 0,
navigators,
navigator_is_placeholder,
repo_root: Some(repo_root),
git_mode: Some(GitDiffMode::Staged),
source_roots: None,
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
})
}
pub fn from_git_index_range(
repo_root: PathBuf,
changes: Vec<ChangedFile>,
from: String,
to_index: bool,
) -> Result<Self, MultiDiffError> {
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
for change in changes {
let old_path = change
.old_path
.clone()
.unwrap_or_else(|| change.path.clone());
let (old_content, old_binary, new_content, new_binary) = if to_index {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, &from, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &change.path),
};
(old_content, old_binary, new_content, new_binary)
} else {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, &from, &change.path),
};
(old_content, old_binary, new_content, new_binary)
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: change.path.display().to_string(),
path: change.path,
old_path: change.old_path,
old_source_path: None,
new_source_path: None,
status: change.status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
Ok(Self {
files,
selected_index: 0,
navigators,
navigator_is_placeholder,
repo_root: Some(repo_root),
git_mode: Some(GitDiffMode::IndexRange { from, to_index }),
source_roots: None,
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
})
}
pub fn from_git_range(
repo_root: PathBuf,
changes: Vec<ChangedFile>,
from: String,
to: String,
) -> Result<Self, MultiDiffError> {
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
for change in changes {
let old_path = change
.old_path
.clone()
.unwrap_or_else(|| change.path.clone());
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, &from, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, &to, &change.path),
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: change.path.display().to_string(),
path: change.path,
old_path: change.old_path,
old_source_path: None,
new_source_path: None,
status: change.status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
Ok(Self {
files,
selected_index: 0,
navigators,
navigator_is_placeholder,
repo_root: Some(repo_root),
git_mode: Some(GitDiffMode::Range { from, to }),
source_roots: None,
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
})
}
pub fn from_directories(old_dir: &Path, new_dir: &Path) -> Result<Self, MultiDiffError> {
Self::from_directories_with_options(old_dir, new_dir, &DirectoryScanOptions::default())
}
pub fn from_directories_with_options(
old_dir: &Path,
new_dir: &Path,
scan_options: &DirectoryScanOptions,
) -> Result<Self, MultiDiffError> {
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
let mut all_files = std::collections::HashSet::new();
if old_dir.is_dir() {
collect_files(old_dir, old_dir, &mut all_files, scan_options)?;
}
if new_dir.is_dir() {
collect_files(new_dir, new_dir, &mut all_files, scan_options)?;
}
let mut all_files: Vec<_> = all_files.into_iter().collect();
all_files.sort();
for rel_path in all_files {
let old_path = old_dir.join(&rel_path);
let new_path = new_dir.join(&rel_path);
let old_exists = old_path.exists();
let new_exists = new_path.exists();
let status = if !old_exists {
FileStatus::Added
} else if !new_exists {
FileStatus::Deleted
} else {
FileStatus::Modified
};
let (old_content, old_binary, old_bytes) = if old_exists {
if let Ok(metadata) = old_path.metadata() {
if Self::text_too_large(metadata.len()) {
(String::new(), true, Vec::new())
} else {
let bytes = std::fs::read(&old_path).unwrap_or_default();
let (content, binary) = Self::decode_bytes(bytes.clone());
(content, binary, bytes)
}
} else {
(String::new(), false, Vec::new())
}
} else {
(String::new(), false, Vec::new())
};
let (new_content, new_binary, new_bytes) = if new_exists {
if let Ok(metadata) = new_path.metadata() {
if Self::text_too_large(metadata.len()) {
(String::new(), true, Vec::new())
} else {
let bytes = std::fs::read(&new_path).unwrap_or_default();
let (content, binary) = Self::decode_bytes(bytes.clone());
(content, binary, bytes)
}
} else {
(String::new(), false, Vec::new())
}
} else {
(String::new(), false, Vec::new())
};
let binary = old_binary || new_binary;
if !binary && old_bytes == new_bytes {
continue;
}
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: rel_path.display().to_string(),
path: rel_path,
old_path: None,
old_source_path: None,
new_source_path: None,
status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
Ok(Self {
files,
selected_index: 0,
navigators,
navigator_is_placeholder,
repo_root: None,
git_mode: None,
source_roots: Some(SourceRoots {
old: old_dir.to_path_buf(),
new: new_dir.to_path_buf(),
}),
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
})
}
pub fn from_file_pair(
_old_path: PathBuf,
new_path: PathBuf,
old_content: String,
new_content: String,
) -> Self {
Self::from_file_pair_with_sources(
new_path.clone(),
old_content.into_bytes(),
new_content.into_bytes(),
None,
Some(new_path),
)
}
pub fn from_file_pair_bytes(new_path: PathBuf, old_bytes: Vec<u8>, new_bytes: Vec<u8>) -> Self {
Self::from_file_pair_with_sources(new_path, old_bytes, new_bytes, None, None)
}
pub fn from_file_pair_with_sources(
new_path: PathBuf,
old_bytes: Vec<u8>,
new_bytes: Vec<u8>,
old_source: Option<PathBuf>,
new_source: Option<PathBuf>,
) -> Self {
let (old_content, old_binary) = Self::decode_bytes(old_bytes);
let (new_content, new_binary) = Self::decode_bytes(new_bytes);
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
let files = vec![FileEntry {
display_name: new_path.display().to_string(),
path: new_path,
old_path: None,
old_source_path: old_source,
new_source_path: new_source,
status: FileStatus::Modified,
insertions,
deletions,
binary,
}];
Self {
files,
selected_index: 0,
navigators: vec![None],
navigator_is_placeholder: vec![false],
repo_root: None,
git_mode: None,
source_roots: None,
old_contents: vec![Arc::from(old_content)],
new_contents: vec![Arc::from(new_content)],
precomputed_diffs: vec![precomputed],
diff_statuses: vec![diff_status],
}
}
pub fn from_file_pairs(pairs: Vec<(PathBuf, String, String)>) -> Self {
let mut files = Vec::with_capacity(pairs.len());
let mut old_contents = Vec::with_capacity(pairs.len());
let mut new_contents = Vec::with_capacity(pairs.len());
let mut precomputed_diffs = Vec::with_capacity(pairs.len());
let mut diff_statuses = Vec::with_capacity(pairs.len());
for (path, old_content, new_content) in pairs {
let (old_content, old_binary) = Self::decode_bytes(old_content.into_bytes());
let (new_content, new_binary) = Self::decode_bytes(new_content.into_bytes());
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: path.display().to_string(),
path,
old_path: None,
old_source_path: None,
new_source_path: None,
status: FileStatus::Modified,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
Self {
files,
selected_index: 0,
navigators: (0..old_contents.len()).map(|_| None).collect(),
navigator_is_placeholder: vec![false; old_contents.len()],
repo_root: None,
git_mode: None,
source_roots: None,
old_contents,
new_contents,
precomputed_diffs,
diff_statuses,
}
}
pub fn current_navigator(&mut self) -> &mut DiffNavigator {
if self.navigators[self.selected_index].is_none() {
let mut placeholder = false;
let lazy_maps = self.file_is_large(self.selected_index);
let diff = if let Some(slot) = self.precomputed_diffs.get_mut(self.selected_index) {
match slot.take() {
Some(PrecomputedDiff::Placeholder(diff)) => {
placeholder = true;
diff
}
Some(PrecomputedDiff::Ready(diff)) => diff,
None => Self::diff_strings(
self.old_contents[self.selected_index].as_ref(),
self.new_contents[self.selected_index].as_ref(),
),
}
} else {
Self::diff_strings(
self.old_contents[self.selected_index].as_ref(),
self.new_contents[self.selected_index].as_ref(),
)
};
let navigator = DiffNavigator::new(
diff,
self.old_contents[self.selected_index].clone(),
self.new_contents[self.selected_index].clone(),
lazy_maps,
);
self.navigators[self.selected_index] = Some(navigator);
if let Some(flag) = self.navigator_is_placeholder.get_mut(self.selected_index) {
*flag = placeholder;
}
}
self.navigators[self.selected_index].as_mut().unwrap()
}
pub fn current_file(&self) -> Option<&FileEntry> {
self.files.get(self.selected_index)
}
pub fn file_contents(&self, idx: usize) -> Option<(&str, &str)> {
let old = self.old_contents.get(idx)?;
let new = self.new_contents.get(idx)?;
Some((old.as_ref(), new.as_ref()))
}
pub fn file_contents_arc(&self, idx: usize) -> Option<(Arc<str>, Arc<str>)> {
let old = self.old_contents.get(idx)?;
let new = self.new_contents.get(idx)?;
Some((old.clone(), new.clone()))
}
pub fn set_source_roots(&mut self, old: PathBuf, new: PathBuf) {
self.source_roots = Some(SourceRoots { old, new });
}
pub fn clear_source_roots(&mut self) {
self.source_roots = None;
}
pub fn source_path(&self, idx: usize, side: FileSide) -> Option<PathBuf> {
let file = self.files.get(idx)?;
if let Some(path) = match side {
FileSide::Old => file.old_source_path.as_ref(),
FileSide::New => file.new_source_path.as_ref(),
} {
return Some(path.clone());
}
let rel_path = match side {
FileSide::Old => file.old_path.as_ref().or_else(|| {
if self.source_roots.is_some() || self.repo_root.is_some() {
Some(&file.path)
} else {
None
}
})?,
FileSide::New => &file.path,
};
if rel_path.is_absolute() {
return Some(rel_path.clone());
}
if let Some(roots) = &self.source_roots {
let root = match side {
FileSide::Old => &roots.old,
FileSide::New => &roots.new,
};
return Some(root.join(rel_path));
}
self.repo_root.as_ref().map(|root| root.join(rel_path))
}
pub fn existing_source_path(&self, idx: usize, side: FileSide) -> Option<PathBuf> {
let path = self.source_path(idx, side)?;
path.is_file().then_some(path)
}
pub fn current_file_is_binary(&self) -> bool {
self.files
.get(self.selected_index)
.map(|f| f.binary)
.unwrap_or(false)
}
pub fn current_file_diff_disabled(&self) -> bool {
matches!(
self.diff_statuses.get(self.selected_index),
Some(
DiffStatus::Deferred
| DiffStatus::Computing
| DiffStatus::Failed
| DiffStatus::Disabled
)
)
}
pub fn diff_status(&self, idx: usize) -> DiffStatus {
self.diff_statuses
.get(idx)
.copied()
.unwrap_or(DiffStatus::Ready)
}
pub fn file_is_large(&self, idx: usize) -> bool {
let old_len = self.old_contents.get(idx).map(|s| s.len()).unwrap_or(0);
let new_len = self.new_contents.get(idx).map(|s| s.len()).unwrap_or(0);
(old_len.max(new_len) as u64) > Self::diff_max_bytes()
}
pub fn current_file_is_large(&self) -> bool {
self.file_is_large(self.selected_index)
}
pub fn current_navigator_is_placeholder(&self) -> bool {
self.navigator_is_placeholder
.get(self.selected_index)
.copied()
.unwrap_or(false)
}
pub fn current_file_diff_status(&self) -> DiffStatus {
self.diff_status(self.selected_index)
}
pub fn mark_diff_computing(&mut self, idx: usize) {
if let Some(status) = self.diff_statuses.get_mut(idx) {
*status = DiffStatus::Computing;
}
}
pub fn mark_diff_failed(&mut self, idx: usize) {
if let Some(status) = self.diff_statuses.get_mut(idx) {
*status = DiffStatus::Failed;
}
}
pub fn apply_diff_result(&mut self, idx: usize, diff: DiffResult) {
if let Some(status) = self.diff_statuses.get_mut(idx) {
*status = DiffStatus::Ready;
}
let insertions = diff.insertions;
let deletions = diff.deletions;
if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
*slot = Some(PrecomputedDiff::Ready(diff));
}
if let Some(file) = self.files.get_mut(idx) {
file.insertions = insertions;
file.deletions = deletions;
}
}
pub fn ensure_full_navigator(&mut self, idx: usize) {
if !matches!(self.diff_status(idx), DiffStatus::Ready) {
return;
}
let needs_refresh = self
.navigator_is_placeholder
.get(idx)
.copied()
.unwrap_or(false);
if self.navigators.get(idx).and_then(|n| n.as_ref()).is_some() && !needs_refresh {
return;
}
let diff = if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
match slot.take() {
Some(PrecomputedDiff::Ready(diff)) => diff,
Some(PrecomputedDiff::Placeholder(diff)) => diff,
None => Self::diff_strings(
self.old_contents[idx].as_ref(),
self.new_contents[idx].as_ref(),
),
}
} else {
Self::diff_strings(
self.old_contents[idx].as_ref(),
self.new_contents[idx].as_ref(),
)
};
let lazy_maps = self.file_is_large(idx);
let navigator = DiffNavigator::new(
diff,
self.old_contents[idx].clone(),
self.new_contents[idx].clone(),
lazy_maps,
);
if let Some(slot) = self.navigators.get_mut(idx) {
*slot = Some(navigator);
}
if let Some(flag) = self.navigator_is_placeholder.get_mut(idx) {
*flag = false;
}
}
pub fn next_file(&mut self) -> bool {
if self.selected_index < self.files.len().saturating_sub(1) {
self.selected_index += 1;
true
} else {
false
}
}
pub fn prev_file(&mut self) -> bool {
if self.selected_index > 0 {
self.selected_index -= 1;
true
} else {
false
}
}
pub fn select_file(&mut self, index: usize) {
if index < self.files.len() {
self.selected_index = index;
}
}
pub fn file_count(&self) -> usize {
self.files.len()
}
pub fn repo_root(&self) -> Option<&Path> {
self.repo_root.as_deref()
}
pub fn is_git_mode(&self) -> bool {
self.repo_root.is_some()
}
pub fn git_range_display(&self) -> Option<(String, String)> {
let mode = self.git_mode.as_ref()?;
match mode {
GitDiffMode::Range { from, to } => Some((format_ref(from), format_ref(to))),
GitDiffMode::IndexRange { from, to_index } => {
let staged = "STAGED".to_string();
if *to_index {
Some((format_ref(from), staged))
} else {
Some((staged, format_ref(from)))
}
}
_ => None,
}
}
pub fn blame_sources(&self) -> Option<(BlameSource, BlameSource)> {
let mode = self.git_mode.as_ref()?;
let sources = match mode {
GitDiffMode::Uncommitted => (
BlameSource::Commit("HEAD".to_string()),
BlameSource::Worktree,
),
GitDiffMode::Staged => (BlameSource::Commit("HEAD".to_string()), BlameSource::Index),
GitDiffMode::Range { from, to } => (
BlameSource::Commit(from.clone()),
BlameSource::Commit(to.clone()),
),
GitDiffMode::IndexRange { from, to_index } => {
if *to_index {
(BlameSource::Commit(from.clone()), BlameSource::Index)
} else {
(BlameSource::Index, BlameSource::Commit(from.clone()))
}
}
};
Some(sources)
}
pub fn current_step_direction(&self) -> StepDirection {
if let Some(Some(nav)) = self.navigators.get(self.selected_index) {
nav.state().step_direction
} else {
StepDirection::None
}
}
pub fn is_multi_file(&self) -> bool {
self.files.len() > 1
}
pub fn total_stats(&self) -> (usize, usize) {
self.files.iter().fold((0, 0), |(ins, del), f| {
(ins + f.insertions, del + f.deletions)
})
}
pub fn current_old_is_empty(&self) -> bool {
self.old_contents
.get(self.selected_index)
.map(|s| s.is_empty())
.unwrap_or(true)
}
pub fn current_new_is_empty(&self) -> bool {
self.new_contents
.get(self.selected_index)
.map(|s| s.is_empty())
.unwrap_or(true)
}
pub fn refresh_all_from_git(&mut self) -> bool {
let repo_root = match &self.repo_root {
Some(root) => root.clone(),
None => return false,
};
let mode = match &self.git_mode {
Some(mode) => mode.clone(),
None => return false,
};
let changes = match mode {
GitDiffMode::Uncommitted => crate::git::get_uncommitted_changes(&repo_root),
GitDiffMode::Staged => crate::git::get_staged_changes(&repo_root),
GitDiffMode::Range { ref from, ref to } => {
crate::git::get_changes_between(&repo_root, from, to)
}
GitDiffMode::IndexRange { ref from, to_index } => {
crate::git::get_changes_between_index(&repo_root, from, !to_index)
}
};
let changes = match changes {
Ok(c) => c,
Err(_) => return false,
};
let mut files = Vec::new();
let mut old_contents = Vec::new();
let mut new_contents = Vec::new();
let mut precomputed_diffs = Vec::new();
let mut diff_statuses = Vec::new();
for change in changes {
let old_path = change
.old_path
.clone()
.unwrap_or_else(|| change.path.clone());
let (old_content, old_binary, new_content, new_binary) = match mode {
GitDiffMode::Uncommitted => {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => {
let full_path = repo_root.join(&change.path);
Self::read_text_or_binary(&full_path)
}
};
(old_content, old_binary, new_content, new_binary)
}
GitDiffMode::Staged => {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &change.path),
};
(old_content, old_binary, new_content, new_binary)
}
GitDiffMode::Range { ref from, ref to } => {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, from, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, to, &change.path),
};
(old_content, old_binary, new_content, new_binary)
}
GitDiffMode::IndexRange { ref from, to_index } => {
if to_index {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, from, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &change.path),
};
(old_content, old_binary, new_content, new_binary)
} else {
let (old_content, old_binary) = match change.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_index_or_binary(&repo_root, &old_path),
};
let (new_content, new_binary) = match change.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(&repo_root, from, &change.path),
};
(old_content, old_binary, new_content, new_binary)
}
}
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
files.push(FileEntry {
display_name: change.path.display().to_string(),
path: change.path,
old_path: change.old_path,
old_source_path: None,
new_source_path: None,
status: change.status,
insertions,
deletions,
binary,
});
old_contents.push(Arc::from(old_content));
new_contents.push(Arc::from(new_content));
precomputed_diffs.push(precomputed);
diff_statuses.push(diff_status);
}
let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
let navigator_is_placeholder = vec![false; files.len()];
self.files = files;
self.old_contents = old_contents;
self.new_contents = new_contents;
self.precomputed_diffs = precomputed_diffs;
self.diff_statuses = diff_statuses;
self.navigators = navigators;
self.navigator_is_placeholder = navigator_is_placeholder;
if self.selected_index >= self.files.len() {
self.selected_index = self.files.len().saturating_sub(1);
}
true
}
pub fn refresh_current_file(&mut self) {
let idx = self.selected_index;
let file = &self.files[idx];
let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone());
let (old_content, old_binary, new_content, new_binary) =
match (&self.repo_root, &self.git_mode) {
(Some(repo_root), Some(GitDiffMode::Uncommitted)) => {
let (old_content, old_binary) = match file.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, "HEAD", &old_path),
};
let (new_content, new_binary) = match file.status {
FileStatus::Deleted => (String::new(), false),
_ => {
let full_path = repo_root.join(&file.path);
Self::read_text_or_binary(&full_path)
}
};
(old_content, old_binary, new_content, new_binary)
}
(Some(repo_root), Some(GitDiffMode::Staged)) => {
let (old_content, old_binary) = match file.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, "HEAD", &old_path),
};
let (new_content, new_binary) = match file.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(repo_root, &file.path),
};
(old_content, old_binary, new_content, new_binary)
}
(Some(repo_root), Some(GitDiffMode::Range { from, to })) => {
let (old_content, old_binary) = match file.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, from, &old_path),
};
let (new_content, new_binary) = match file.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, to, &file.path),
};
(old_content, old_binary, new_content, new_binary)
}
(Some(repo_root), Some(GitDiffMode::IndexRange { from, to_index })) => {
if *to_index {
let (old_content, old_binary) = match file.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, from, &old_path),
};
let (new_content, new_binary) = match file.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_index_or_binary(repo_root, &file.path),
};
(old_content, old_binary, new_content, new_binary)
} else {
let (old_content, old_binary) = match file.status {
FileStatus::Added | FileStatus::Untracked => (String::new(), false),
_ => Self::read_git_index_or_binary(repo_root, &old_path),
};
let (new_content, new_binary) = match file.status {
FileStatus::Deleted => (String::new(), false),
_ => Self::read_git_commit_or_binary(repo_root, from, &file.path),
};
(old_content, old_binary, new_content, new_binary)
}
}
_ => {
let old_content = self.old_contents[idx].as_ref().to_string();
let (old_content, old_binary) = self
.source_path(idx, FileSide::Old)
.filter(|path| path.is_file())
.map(|path| Self::read_text_or_binary(&path))
.unwrap_or((old_content, false));
let new_path = self
.source_path(idx, FileSide::New)
.unwrap_or_else(|| file.path.clone());
let (new_content, new_binary) = Self::read_text_or_binary(&new_path);
(old_content, old_binary, new_content, new_binary)
}
};
let binary = old_binary || new_binary;
let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
let (old_content, new_content, precomputed, diff_status) =
Self::maybe_defer_diff(old_content, new_content, binary);
self.old_contents[idx] = Arc::from(old_content);
self.new_contents[idx] = Arc::from(new_content);
self.files[idx].binary = binary;
self.files[idx].insertions = insertions;
self.files[idx].deletions = deletions;
if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
*slot = precomputed;
}
if let Some(status) = self.diff_statuses.get_mut(idx) {
*status = diff_status;
}
self.navigators[idx] = None;
if let Some(flag) = self.navigator_is_placeholder.get_mut(idx) {
*flag = false;
}
}
}
fn collect_files(
dir: &Path,
base: &Path,
files: &mut std::collections::HashSet<PathBuf>,
scan_options: &DirectoryScanOptions,
) -> Result<(), std::io::Error> {
let mut builder = WalkBuilder::new(dir);
builder
.standard_filters(false)
.hidden(false)
.parents(scan_options.git_ignore)
.ignore(false)
.git_ignore(scan_options.git_ignore)
.git_global(scan_options.git_ignore)
.git_exclude(scan_options.git_ignore)
.require_git(false);
if !scan_options.ignore_globs.is_empty() {
let mut overrides = OverrideBuilder::new(base);
for pattern in &scan_options.ignore_globs {
if let Some(dir_pattern) = pattern.strip_suffix("/**") {
if !dir_pattern.is_empty() {
overrides
.add(&format!("!{dir_pattern}"))
.map_err(ignore_error_to_io)?;
}
}
overrides
.add(&format!("!{pattern}"))
.map_err(ignore_error_to_io)?;
}
builder.overrides(overrides.build().map_err(ignore_error_to_io)?);
}
for entry in builder.build() {
let entry = entry.map_err(ignore_error_to_io)?;
let path = entry.path();
if path == dir {
continue;
}
if path.is_file() {
if let Ok(rel) = path.strip_prefix(base) {
files.insert(rel.to_path_buf());
}
}
}
Ok(())
}
fn ignore_error_to_io(error: ignore::Error) -> std::io::Error {
std::io::Error::other(error)
}
fn format_ref(reference: &str) -> String {
match reference {
"HEAD" => "HEAD".to_string(),
"INDEX" => "STAGED".to_string(),
_ => shorten_hash(reference),
}
}
fn shorten_hash(hash: &str) -> String {
hash.chars().take(7).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
static DIFF_SETTINGS_LOCK: Mutex<()> = Mutex::new(());
fn temp_dir(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("oyo-core-{name}-{}-{nanos}", std::process::id()))
}
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn display_names(diff: &MultiFileDiff) -> Vec<String> {
diff.files
.iter()
.map(|file| file.display_name.clone())
.collect()
}
#[test]
fn directory_scan_includes_dotfiles() {
let root = temp_dir("dotfiles");
let old_dir = root.join("old");
let new_dir = root.join("new");
std::fs::create_dir_all(&old_dir).unwrap();
write_file(
&new_dir.join(".github/actions/foo/action.yml"),
"name: test\n",
);
write_file(&new_dir.join(".env.example"), "KEY=value\n");
let diff = MultiFileDiff::from_directories(&old_dir, &new_dir).unwrap();
let names = display_names(&diff);
assert!(names.contains(&".github/actions/foo/action.yml".to_string()));
assert!(names.contains(&".env.example".to_string()));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn directory_scan_respects_gitignore_when_enabled() {
let root = temp_dir("gitignore");
let old_dir = root.join("old");
let new_dir = root.join("new");
write_file(&old_dir.join(".gitignore"), "ignored.txt\n");
write_file(&new_dir.join(".gitignore"), "ignored.txt\n");
write_file(&old_dir.join("ignored.txt"), "old\n");
write_file(&new_dir.join("ignored.txt"), "new\n");
let ignored = MultiFileDiff::from_directories_with_options(
&old_dir,
&new_dir,
&DirectoryScanOptions {
git_ignore: true,
ignore_globs: Vec::new(),
},
)
.unwrap();
assert!(!display_names(&ignored).contains(&"ignored.txt".to_string()));
let included = MultiFileDiff::from_directories_with_options(
&old_dir,
&new_dir,
&DirectoryScanOptions {
git_ignore: false,
ignore_globs: Vec::new(),
},
)
.unwrap();
assert!(display_names(&included).contains(&"ignored.txt".to_string()));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn directory_scan_skips_vcs_metadata_by_default() {
let root = temp_dir("vcs-metadata");
let old_dir = root.join("old");
let new_dir = root.join("new");
write_file(&old_dir.join(".git/config"), "old\n");
write_file(&new_dir.join(".git/config"), "new\n");
let diff = MultiFileDiff::from_directories(&old_dir, &new_dir).unwrap();
assert!(!display_names(&diff).contains(&".git/config".to_string()));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn directory_diff_exposes_source_paths() {
let root = temp_dir("source-paths");
let old_dir = root.join("old");
let new_dir = root.join("new");
write_file(&old_dir.join("file.txt"), "old\n");
write_file(&new_dir.join("file.txt"), "new\n");
let diff = MultiFileDiff::from_directories(&old_dir, &new_dir).unwrap();
assert_eq!(
diff.existing_source_path(0, FileSide::Old),
Some(old_dir.join("file.txt"))
);
assert_eq!(
diff.existing_source_path(0, FileSide::New),
Some(new_dir.join("file.txt"))
);
write_file(&old_dir.join("file.txt"), "older\n");
let mut diff = diff;
diff.refresh_current_file();
assert_eq!(diff.file_contents(0).map(|(old, _)| old), Some("older\n"));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn file_pair_exposes_explicit_source_path() {
let root = temp_dir("file-pair-source");
let old_path = root.join("old.txt");
let new_path = root.join("new.txt");
write_file(&old_path, "old\n");
write_file(&new_path, "new\n");
let diff = MultiFileDiff::from_file_pair_with_sources(
PathBuf::from("display.txt"),
b"old\n".to_vec(),
b"new\n".to_vec(),
Some(old_path.clone()),
Some(new_path.clone()),
);
assert_eq!(diff.existing_source_path(0, FileSide::Old), Some(old_path));
assert_eq!(diff.existing_source_path(0, FileSide::New), Some(new_path));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn deferred_diff_upgrades_to_ready() {
let _guard = DIFF_SETTINGS_LOCK.lock().unwrap();
MultiFileDiff::set_diff_max_bytes(32);
MultiFileDiff::set_diff_defer(true);
let content = "a".repeat(128);
let mut diff = MultiFileDiff::from_file_pair_bytes(
PathBuf::from("file.txt"),
content.clone().into_bytes(),
content.into_bytes(),
);
assert_eq!(diff.diff_status(0), DiffStatus::Deferred);
let computed = MultiFileDiff::compute_diff(
diff.old_contents[0].as_ref(),
diff.new_contents[0].as_ref(),
);
diff.apply_diff_result(0, computed);
assert_eq!(diff.diff_status(0), DiffStatus::Ready);
MultiFileDiff::set_diff_max_bytes(DEFAULT_DIFF_MAX_BYTES);
MultiFileDiff::set_diff_defer(true);
}
}