1use crate::change::{Change, ChangeSpan};
4use crate::diff::{DiffEngine, DiffResult};
5use crate::git::{ChangedFile, FileStatus};
6use crate::step::{DiffNavigator, StepDirection};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
9use std::sync::Arc;
10use thiserror::Error;
11
12#[derive(Error, Debug)]
13pub enum MultiDiffError {
14 #[error("IO error: {0}")]
15 Io(#[from] std::io::Error),
16 #[error("Git error: {0}")]
17 Git(#[from] crate::git::GitError),
18}
19
20#[derive(Debug, Clone)]
22pub struct FileEntry {
23 pub path: PathBuf,
24 pub old_path: Option<PathBuf>,
25 pub display_name: String,
26 pub status: FileStatus,
27 pub insertions: usize,
28 pub deletions: usize,
29 pub binary: bool,
30}
31
32pub struct MultiFileDiff {
34 pub files: Vec<FileEntry>,
36 pub selected_index: usize,
38 navigators: Vec<Option<DiffNavigator>>,
40 navigator_is_placeholder: Vec<bool>,
42 #[allow(dead_code)]
44 repo_root: Option<PathBuf>,
45 git_mode: Option<GitDiffMode>,
47 old_contents: Vec<Arc<str>>,
49 new_contents: Vec<Arc<str>>,
51 precomputed_diffs: Vec<Option<PrecomputedDiff>>,
53 diff_statuses: Vec<DiffStatus>,
55}
56
57#[derive(Debug, Clone)]
58enum GitDiffMode {
59 Uncommitted,
60 Staged,
61 IndexRange { from: String, to_index: bool },
62 Range { from: String, to: String },
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
67pub enum BlameSource {
68 Worktree,
69 Index,
70 Commit(String),
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum DiffStatus {
75 Ready,
76 Deferred,
77 Computing,
78 Failed,
79 Disabled,
80}
81
82#[derive(Debug, Clone)]
83enum PrecomputedDiff {
84 Placeholder(DiffResult),
85 Ready(DiffResult),
86}
87
88const DEFAULT_DIFF_MAX_BYTES: u64 = 16 * 1024 * 1024;
89const DEFAULT_FULL_CONTEXT_MAX_BYTES: u64 = 2 * 1024 * 1024;
90static DIFF_MAX_BYTES: AtomicU64 = AtomicU64::new(DEFAULT_DIFF_MAX_BYTES);
91static FULL_CONTEXT_MAX_BYTES: AtomicU64 = AtomicU64::new(DEFAULT_FULL_CONTEXT_MAX_BYTES);
92static DIFF_DEFER: AtomicBool = AtomicBool::new(true);
93
94impl MultiFileDiff {
95 const MAX_TEXT_BYTES: u64 = 32 * 1024 * 1024;
96 const MAX_WORD_LEVEL_BYTES: u64 = 2 * 1024 * 1024;
97 const MAX_LINE_CHARS: usize = 16_384;
98
99 pub fn set_diff_max_bytes(max_bytes: u64) {
100 let limit = max_bytes.max(1);
101 DIFF_MAX_BYTES.store(limit, Ordering::Relaxed);
102 }
103
104 pub fn set_full_context_max_bytes(max_bytes: u64) {
105 let limit = max_bytes.max(1);
106 FULL_CONTEXT_MAX_BYTES.store(limit, Ordering::Relaxed);
107 }
108
109 pub fn set_diff_defer(enabled: bool) {
110 DIFF_DEFER.store(enabled, Ordering::Relaxed);
111 }
112
113 fn diff_max_bytes() -> u64 {
114 DIFF_MAX_BYTES.load(Ordering::Relaxed)
115 }
116
117 fn full_context_max_bytes() -> u64 {
118 FULL_CONTEXT_MAX_BYTES.load(Ordering::Relaxed)
119 }
120
121 fn diff_defer_enabled() -> bool {
122 DIFF_DEFER.load(Ordering::Relaxed)
123 }
124
125 fn decode_bytes(bytes: Vec<u8>) -> (String, bool) {
126 if bytes.is_empty() {
127 return (String::new(), false);
128 }
129 if bytes.contains(&0) || std::str::from_utf8(&bytes).is_err() {
130 return (String::new(), true);
131 }
132 let text = String::from_utf8_lossy(&bytes).to_string();
133 (Self::normalize_text(text), false)
134 }
135
136 fn text_too_large(size: u64) -> bool {
137 size > Self::MAX_TEXT_BYTES
138 }
139
140 fn read_text_or_binary(path: &Path) -> (String, bool) {
141 if let Ok(metadata) = path.metadata() {
142 if Self::text_too_large(metadata.len()) {
143 return (String::new(), true);
144 }
145 }
146 let bytes = std::fs::read(path).unwrap_or_default();
147 Self::decode_bytes(bytes)
148 }
149
150 fn read_git_commit_or_binary(repo_root: &Path, commit: &str, path: &Path) -> (String, bool) {
151 if let Some(size) = crate::git::get_file_at_commit_size(repo_root, commit, path) {
152 if Self::text_too_large(size) {
153 return (String::new(), true);
154 }
155 }
156 let bytes =
157 crate::git::get_file_at_commit_bytes(repo_root, commit, path).unwrap_or_default();
158 Self::decode_bytes(bytes)
159 }
160
161 fn read_git_index_or_binary(repo_root: &Path, path: &Path) -> (String, bool) {
162 if let Some(size) = crate::git::get_staged_content_size(repo_root, path) {
163 if Self::text_too_large(size) {
164 return (String::new(), true);
165 }
166 }
167 let bytes = crate::git::get_staged_content_bytes(repo_root, path).unwrap_or_default();
168 Self::decode_bytes(bytes)
169 }
170
171 fn diff_strings(old: &str, new: &str) -> crate::diff::DiffResult {
172 let max_len = old.len().max(new.len()) as u64;
173 let word_level = max_len <= Self::MAX_WORD_LEVEL_BYTES;
174 let context_limit = Self::full_context_max_bytes().min(Self::diff_max_bytes());
175 let context_lines = if max_len > context_limit {
176 3
177 } else {
178 usize::MAX
179 };
180 DiffEngine::new()
181 .with_word_level(word_level)
182 .with_context(context_lines)
183 .diff_strings(old, new)
184 }
185
186 pub fn compute_diff(old: &str, new: &str) -> crate::diff::DiffResult {
187 Self::diff_strings(old, new)
188 }
189
190 fn should_defer_diff(old: &str, new: &str) -> bool {
191 let max_len = old.len().max(new.len()) as u64;
192 max_len > Self::diff_max_bytes()
193 }
194
195 fn context_only_diff(text: &str) -> DiffResult {
196 let mut changes = Vec::new();
197 for (change_id, line) in text.split('\n').enumerate() {
198 let line_num = change_id + 1;
199 let span = ChangeSpan::equal(line).with_lines(Some(line_num), Some(line_num));
200 changes.push(Change::single(change_id, span));
201 }
202
203 DiffResult {
204 changes,
205 significant_changes: Vec::new(),
206 hunks: Vec::new(),
207 insertions: 0,
208 deletions: 0,
209 }
210 }
211
212 fn diff_stats(old: &str, new: &str, binary: bool) -> (usize, usize) {
213 if binary {
214 return (0, 0);
215 }
216 let max_len = old.len().max(new.len()) as u64;
217 if max_len > Self::MAX_WORD_LEVEL_BYTES {
218 let old_lines = old.lines().count();
219 let new_lines = new.lines().count();
220 if old_lines == 0 {
221 return (new_lines, 0);
222 }
223 if new_lines == 0 {
224 return (0, old_lines);
225 }
226 return (0, 0);
227 }
228 let diff = Self::diff_strings(old, new);
229 (diff.insertions, diff.deletions)
230 }
231
232 fn normalize_text(text: String) -> String {
233 if !text.lines().any(|line| line.len() > Self::MAX_LINE_CHARS) {
234 return text;
235 }
236 let mut out = String::new();
237 for chunk in text.split_inclusive('\n') {
238 let (line, has_newline) = if let Some(line) = chunk.strip_suffix('\n') {
239 (line, true)
240 } else {
241 (chunk, false)
242 };
243 if line.len() > Self::MAX_LINE_CHARS {
244 let cutoff = line
245 .char_indices()
246 .nth(Self::MAX_LINE_CHARS)
247 .map(|(idx, _)| idx)
248 .unwrap_or_else(|| line.len());
249 out.push_str(&line[..cutoff]);
250 out.push('…');
251 } else {
252 out.push_str(line);
253 }
254 if has_newline {
255 out.push('\n');
256 }
257 }
258 out
259 }
260
261 fn maybe_defer_diff(
262 old_content: String,
263 new_content: String,
264 binary: bool,
265 ) -> (String, String, Option<PrecomputedDiff>, DiffStatus) {
266 if binary {
267 return (String::new(), String::new(), None, DiffStatus::Disabled);
268 }
269 if Self::should_defer_diff(&old_content, &new_content) {
270 let display = if new_content.is_empty() {
271 old_content.clone()
272 } else {
273 new_content.clone()
274 };
275 let diff = Self::context_only_diff(&display);
276 let status = if Self::diff_defer_enabled() {
277 DiffStatus::Deferred
278 } else {
279 DiffStatus::Disabled
280 };
281 return (
282 old_content,
283 new_content,
284 Some(PrecomputedDiff::Placeholder(diff)),
285 status,
286 );
287 }
288 (old_content, new_content, None, DiffStatus::Ready)
289 }
290
291 pub fn from_git_changes(
293 repo_root: PathBuf,
294 changes: Vec<ChangedFile>,
295 ) -> Result<Self, MultiDiffError> {
296 let mut files = Vec::new();
297 let mut old_contents = Vec::new();
298 let mut new_contents = Vec::new();
299 let mut precomputed_diffs = Vec::new();
300 let mut diff_statuses = Vec::new();
301 for change in changes {
302 let (old_content, old_binary) = match change.status {
304 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
305 _ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &change.path),
306 };
307
308 let (new_content, new_binary) = match change.status {
309 FileStatus::Deleted => (String::new(), false),
310 _ => {
311 let full_path = repo_root.join(&change.path);
312 Self::read_text_or_binary(&full_path)
313 }
314 };
315
316 let binary = old_binary || new_binary;
317 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
318 let (old_content, new_content, precomputed, diff_status) =
319 Self::maybe_defer_diff(old_content, new_content, binary);
320
321 files.push(FileEntry {
322 display_name: change.path.display().to_string(),
323 path: change.path,
324 old_path: change.old_path,
325 status: change.status,
326 insertions,
327 deletions,
328 binary,
329 });
330
331 old_contents.push(Arc::from(old_content));
332 new_contents.push(Arc::from(new_content));
333 precomputed_diffs.push(precomputed);
334 diff_statuses.push(diff_status);
335 }
336
337 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
338 let navigator_is_placeholder = vec![false; files.len()];
339
340 Ok(Self {
341 files,
342 selected_index: 0,
343 navigators,
344 navigator_is_placeholder,
345 repo_root: Some(repo_root),
346 git_mode: Some(GitDiffMode::Uncommitted),
347 old_contents,
348 new_contents,
349 precomputed_diffs,
350 diff_statuses,
351 })
352 }
353
354 pub fn from_git_staged(
356 repo_root: PathBuf,
357 changes: Vec<ChangedFile>,
358 ) -> Result<Self, MultiDiffError> {
359 let mut files = Vec::new();
360 let mut old_contents = Vec::new();
361 let mut new_contents = Vec::new();
362 let mut precomputed_diffs = Vec::new();
363 let mut diff_statuses = Vec::new();
364 for change in changes {
365 let old_path = change
366 .old_path
367 .clone()
368 .unwrap_or_else(|| change.path.clone());
369 let (old_content, old_binary) = match change.status {
370 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
371 _ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
372 };
373
374 let (new_content, new_binary) = match change.status {
375 FileStatus::Deleted => (String::new(), false),
376 _ => Self::read_git_index_or_binary(&repo_root, &change.path),
377 };
378
379 let binary = old_binary || new_binary;
380 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
381 let (old_content, new_content, precomputed, diff_status) =
382 Self::maybe_defer_diff(old_content, new_content, binary);
383
384 files.push(FileEntry {
385 display_name: change.path.display().to_string(),
386 path: change.path,
387 old_path: change.old_path,
388 status: change.status,
389 insertions,
390 deletions,
391 binary,
392 });
393
394 old_contents.push(Arc::from(old_content));
395 new_contents.push(Arc::from(new_content));
396 precomputed_diffs.push(precomputed);
397 diff_statuses.push(diff_status);
398 }
399
400 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
401 let navigator_is_placeholder = vec![false; files.len()];
402
403 Ok(Self {
404 files,
405 selected_index: 0,
406 navigators,
407 navigator_is_placeholder,
408 repo_root: Some(repo_root),
409 git_mode: Some(GitDiffMode::Staged),
410 old_contents,
411 new_contents,
412 precomputed_diffs,
413 diff_statuses,
414 })
415 }
416
417 pub fn from_git_index_range(
419 repo_root: PathBuf,
420 changes: Vec<ChangedFile>,
421 from: String,
422 to_index: bool,
423 ) -> Result<Self, MultiDiffError> {
424 let mut files = Vec::new();
425 let mut old_contents = Vec::new();
426 let mut new_contents = Vec::new();
427 let mut precomputed_diffs = Vec::new();
428 let mut diff_statuses = Vec::new();
429 for change in changes {
430 let old_path = change
431 .old_path
432 .clone()
433 .unwrap_or_else(|| change.path.clone());
434 let (old_content, old_binary, new_content, new_binary) = if to_index {
435 let (old_content, old_binary) = match change.status {
436 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
437 _ => Self::read_git_commit_or_binary(&repo_root, &from, &old_path),
438 };
439 let (new_content, new_binary) = match change.status {
440 FileStatus::Deleted => (String::new(), false),
441 _ => Self::read_git_index_or_binary(&repo_root, &change.path),
442 };
443 (old_content, old_binary, new_content, new_binary)
444 } else {
445 let (old_content, old_binary) = match change.status {
446 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
447 _ => Self::read_git_index_or_binary(&repo_root, &old_path),
448 };
449 let (new_content, new_binary) = match change.status {
450 FileStatus::Deleted => (String::new(), false),
451 _ => Self::read_git_commit_or_binary(&repo_root, &from, &change.path),
452 };
453 (old_content, old_binary, new_content, new_binary)
454 };
455
456 let binary = old_binary || new_binary;
457 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
458 let (old_content, new_content, precomputed, diff_status) =
459 Self::maybe_defer_diff(old_content, new_content, binary);
460
461 files.push(FileEntry {
462 display_name: change.path.display().to_string(),
463 path: change.path,
464 old_path: change.old_path,
465 status: change.status,
466 insertions,
467 deletions,
468 binary,
469 });
470
471 old_contents.push(Arc::from(old_content));
472 new_contents.push(Arc::from(new_content));
473 precomputed_diffs.push(precomputed);
474 diff_statuses.push(diff_status);
475 }
476
477 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
478 let navigator_is_placeholder = vec![false; files.len()];
479
480 Ok(Self {
481 files,
482 selected_index: 0,
483 navigators,
484 navigator_is_placeholder,
485 repo_root: Some(repo_root),
486 git_mode: Some(GitDiffMode::IndexRange { from, to_index }),
487 old_contents,
488 new_contents,
489 precomputed_diffs,
490 diff_statuses,
491 })
492 }
493
494 pub fn from_git_range(
496 repo_root: PathBuf,
497 changes: Vec<ChangedFile>,
498 from: String,
499 to: String,
500 ) -> Result<Self, MultiDiffError> {
501 let mut files = Vec::new();
502 let mut old_contents = Vec::new();
503 let mut new_contents = Vec::new();
504 let mut precomputed_diffs = Vec::new();
505 let mut diff_statuses = Vec::new();
506 for change in changes {
507 let old_path = change
508 .old_path
509 .clone()
510 .unwrap_or_else(|| change.path.clone());
511 let (old_content, old_binary) = match change.status {
512 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
513 _ => Self::read_git_commit_or_binary(&repo_root, &from, &old_path),
514 };
515
516 let (new_content, new_binary) = match change.status {
517 FileStatus::Deleted => (String::new(), false),
518 _ => Self::read_git_commit_or_binary(&repo_root, &to, &change.path),
519 };
520
521 let binary = old_binary || new_binary;
522 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
523 let (old_content, new_content, precomputed, diff_status) =
524 Self::maybe_defer_diff(old_content, new_content, binary);
525
526 files.push(FileEntry {
527 display_name: change.path.display().to_string(),
528 path: change.path,
529 old_path: change.old_path,
530 status: change.status,
531 insertions,
532 deletions,
533 binary,
534 });
535
536 old_contents.push(Arc::from(old_content));
537 new_contents.push(Arc::from(new_content));
538 precomputed_diffs.push(precomputed);
539 diff_statuses.push(diff_status);
540 }
541
542 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
543 let navigator_is_placeholder = vec![false; files.len()];
544
545 Ok(Self {
546 files,
547 selected_index: 0,
548 navigators,
549 navigator_is_placeholder,
550 repo_root: Some(repo_root),
551 git_mode: Some(GitDiffMode::Range { from, to }),
552 old_contents,
553 new_contents,
554 precomputed_diffs,
555 diff_statuses,
556 })
557 }
558
559 pub fn from_directories(old_dir: &Path, new_dir: &Path) -> Result<Self, MultiDiffError> {
561 let mut files = Vec::new();
562 let mut old_contents = Vec::new();
563 let mut new_contents = Vec::new();
564 let mut precomputed_diffs = Vec::new();
565 let mut diff_statuses = Vec::new();
566 let mut all_files = std::collections::HashSet::new();
568
569 if old_dir.is_dir() {
570 collect_files(old_dir, old_dir, &mut all_files)?;
571 }
572 if new_dir.is_dir() {
573 collect_files(new_dir, new_dir, &mut all_files)?;
574 }
575
576 let mut all_files: Vec<_> = all_files.into_iter().collect();
577 all_files.sort();
578
579 for rel_path in all_files {
580 let old_path = old_dir.join(&rel_path);
581 let new_path = new_dir.join(&rel_path);
582
583 let old_exists = old_path.exists();
584 let new_exists = new_path.exists();
585
586 let status = if !old_exists {
587 FileStatus::Added
588 } else if !new_exists {
589 FileStatus::Deleted
590 } else {
591 FileStatus::Modified
592 };
593
594 let (old_content, old_binary, old_bytes) = if old_exists {
595 if let Ok(metadata) = old_path.metadata() {
596 if Self::text_too_large(metadata.len()) {
597 (String::new(), true, Vec::new())
598 } else {
599 let bytes = std::fs::read(&old_path).unwrap_or_default();
600 let (content, binary) = Self::decode_bytes(bytes.clone());
601 (content, binary, bytes)
602 }
603 } else {
604 (String::new(), false, Vec::new())
605 }
606 } else {
607 (String::new(), false, Vec::new())
608 };
609 let (new_content, new_binary, new_bytes) = if new_exists {
610 if let Ok(metadata) = new_path.metadata() {
611 if Self::text_too_large(metadata.len()) {
612 (String::new(), true, Vec::new())
613 } else {
614 let bytes = std::fs::read(&new_path).unwrap_or_default();
615 let (content, binary) = Self::decode_bytes(bytes.clone());
616 (content, binary, bytes)
617 }
618 } else {
619 (String::new(), false, Vec::new())
620 }
621 } else {
622 (String::new(), false, Vec::new())
623 };
624 let binary = old_binary || new_binary;
625
626 if !binary && old_bytes == new_bytes {
628 continue;
629 }
630
631 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
632 let (old_content, new_content, precomputed, diff_status) =
633 Self::maybe_defer_diff(old_content, new_content, binary);
634
635 files.push(FileEntry {
636 display_name: rel_path.display().to_string(),
637 path: rel_path,
638 old_path: None,
639 status,
640 insertions,
641 deletions,
642 binary,
643 });
644
645 old_contents.push(Arc::from(old_content));
646 new_contents.push(Arc::from(new_content));
647 precomputed_diffs.push(precomputed);
648 diff_statuses.push(diff_status);
649 }
650
651 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
652 let navigator_is_placeholder = vec![false; files.len()];
653
654 Ok(Self {
655 files,
656 selected_index: 0,
657 navigators,
658 navigator_is_placeholder,
659 repo_root: None,
660 git_mode: None,
661 old_contents,
662 new_contents,
663 precomputed_diffs,
664 diff_statuses,
665 })
666 }
667
668 pub fn from_file_pair(
670 _old_path: PathBuf,
671 new_path: PathBuf,
672 old_content: String,
673 new_content: String,
674 ) -> Self {
675 Self::from_file_pair_bytes(new_path, old_content.into_bytes(), new_content.into_bytes())
676 }
677
678 pub fn from_file_pair_bytes(new_path: PathBuf, old_bytes: Vec<u8>, new_bytes: Vec<u8>) -> Self {
680 let (old_content, old_binary) = Self::decode_bytes(old_bytes);
681 let (new_content, new_binary) = Self::decode_bytes(new_bytes);
682 let binary = old_binary || new_binary;
683 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
684 let (old_content, new_content, precomputed, diff_status) =
685 Self::maybe_defer_diff(old_content, new_content, binary);
686
687 let files = vec![FileEntry {
688 display_name: new_path.display().to_string(),
689 path: new_path,
690 old_path: None,
691 status: FileStatus::Modified,
692 insertions,
693 deletions,
694 binary,
695 }];
696
697 Self {
698 files,
699 selected_index: 0,
700 navigators: vec![None],
701 navigator_is_placeholder: vec![false],
702 repo_root: None,
703 git_mode: None,
704 old_contents: vec![Arc::from(old_content)],
705 new_contents: vec![Arc::from(new_content)],
706 precomputed_diffs: vec![precomputed],
707 diff_statuses: vec![diff_status],
708 }
709 }
710
711 pub fn from_file_pairs(pairs: Vec<(PathBuf, String, String)>) -> Self {
713 let mut files = Vec::with_capacity(pairs.len());
714 let mut old_contents = Vec::with_capacity(pairs.len());
715 let mut new_contents = Vec::with_capacity(pairs.len());
716 let mut precomputed_diffs = Vec::with_capacity(pairs.len());
717 let mut diff_statuses = Vec::with_capacity(pairs.len());
718
719 for (path, old_content, new_content) in pairs {
720 let (old_content, old_binary) = Self::decode_bytes(old_content.into_bytes());
721 let (new_content, new_binary) = Self::decode_bytes(new_content.into_bytes());
722 let binary = old_binary || new_binary;
723 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
724 let (old_content, new_content, precomputed, diff_status) =
725 Self::maybe_defer_diff(old_content, new_content, binary);
726 files.push(FileEntry {
727 display_name: path.display().to_string(),
728 path,
729 old_path: None,
730 status: FileStatus::Modified,
731 insertions,
732 deletions,
733 binary,
734 });
735 old_contents.push(Arc::from(old_content));
736 new_contents.push(Arc::from(new_content));
737 precomputed_diffs.push(precomputed);
738 diff_statuses.push(diff_status);
739 }
740
741 Self {
742 files,
743 selected_index: 0,
744 navigators: (0..old_contents.len()).map(|_| None).collect(),
745 navigator_is_placeholder: vec![false; old_contents.len()],
746 repo_root: None,
747 git_mode: None,
748 old_contents,
749 new_contents,
750 precomputed_diffs,
751 diff_statuses,
752 }
753 }
754
755 pub fn current_navigator(&mut self) -> &mut DiffNavigator {
757 if self.navigators[self.selected_index].is_none() {
758 let mut placeholder = false;
759 let lazy_maps = self.file_is_large(self.selected_index);
760 let diff = if let Some(slot) = self.precomputed_diffs.get_mut(self.selected_index) {
761 match slot.take() {
762 Some(PrecomputedDiff::Placeholder(diff)) => {
763 placeholder = true;
764 diff
765 }
766 Some(PrecomputedDiff::Ready(diff)) => diff,
767 None => Self::diff_strings(
768 self.old_contents[self.selected_index].as_ref(),
769 self.new_contents[self.selected_index].as_ref(),
770 ),
771 }
772 } else {
773 Self::diff_strings(
774 self.old_contents[self.selected_index].as_ref(),
775 self.new_contents[self.selected_index].as_ref(),
776 )
777 };
778 let navigator = DiffNavigator::new(
779 diff,
780 self.old_contents[self.selected_index].clone(),
781 self.new_contents[self.selected_index].clone(),
782 lazy_maps,
783 );
784 self.navigators[self.selected_index] = Some(navigator);
785 if let Some(flag) = self.navigator_is_placeholder.get_mut(self.selected_index) {
786 *flag = placeholder;
787 }
788 }
789 self.navigators[self.selected_index].as_mut().unwrap()
790 }
791
792 pub fn current_file(&self) -> Option<&FileEntry> {
794 self.files.get(self.selected_index)
795 }
796
797 pub fn file_contents(&self, idx: usize) -> Option<(&str, &str)> {
798 let old = self.old_contents.get(idx)?;
799 let new = self.new_contents.get(idx)?;
800 Some((old.as_ref(), new.as_ref()))
801 }
802
803 pub fn file_contents_arc(&self, idx: usize) -> Option<(Arc<str>, Arc<str>)> {
804 let old = self.old_contents.get(idx)?;
805 let new = self.new_contents.get(idx)?;
806 Some((old.clone(), new.clone()))
807 }
808
809 pub fn current_file_is_binary(&self) -> bool {
811 self.files
812 .get(self.selected_index)
813 .map(|f| f.binary)
814 .unwrap_or(false)
815 }
816
817 pub fn current_file_diff_disabled(&self) -> bool {
819 matches!(
820 self.diff_statuses.get(self.selected_index),
821 Some(
822 DiffStatus::Deferred
823 | DiffStatus::Computing
824 | DiffStatus::Failed
825 | DiffStatus::Disabled
826 )
827 )
828 }
829
830 pub fn diff_status(&self, idx: usize) -> DiffStatus {
831 self.diff_statuses
832 .get(idx)
833 .copied()
834 .unwrap_or(DiffStatus::Ready)
835 }
836
837 pub fn file_is_large(&self, idx: usize) -> bool {
838 let old_len = self.old_contents.get(idx).map(|s| s.len()).unwrap_or(0);
839 let new_len = self.new_contents.get(idx).map(|s| s.len()).unwrap_or(0);
840 (old_len.max(new_len) as u64) > Self::diff_max_bytes()
841 }
842
843 pub fn current_file_is_large(&self) -> bool {
844 self.file_is_large(self.selected_index)
845 }
846
847 pub fn current_navigator_is_placeholder(&self) -> bool {
848 self.navigator_is_placeholder
849 .get(self.selected_index)
850 .copied()
851 .unwrap_or(false)
852 }
853
854 pub fn current_file_diff_status(&self) -> DiffStatus {
855 self.diff_status(self.selected_index)
856 }
857
858 pub fn mark_diff_computing(&mut self, idx: usize) {
859 if let Some(status) = self.diff_statuses.get_mut(idx) {
860 *status = DiffStatus::Computing;
861 }
862 }
863
864 pub fn mark_diff_failed(&mut self, idx: usize) {
865 if let Some(status) = self.diff_statuses.get_mut(idx) {
866 *status = DiffStatus::Failed;
867 }
868 }
869
870 pub fn apply_diff_result(&mut self, idx: usize, diff: DiffResult) {
871 if let Some(status) = self.diff_statuses.get_mut(idx) {
872 *status = DiffStatus::Ready;
873 }
874 let insertions = diff.insertions;
875 let deletions = diff.deletions;
876 if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
877 *slot = Some(PrecomputedDiff::Ready(diff));
878 }
879 if let Some(file) = self.files.get_mut(idx) {
880 file.insertions = insertions;
881 file.deletions = deletions;
882 }
883 }
884
885 pub fn ensure_full_navigator(&mut self, idx: usize) {
886 if !matches!(self.diff_status(idx), DiffStatus::Ready) {
887 return;
888 }
889 let needs_refresh = self
890 .navigator_is_placeholder
891 .get(idx)
892 .copied()
893 .unwrap_or(false);
894 if self.navigators.get(idx).and_then(|n| n.as_ref()).is_some() && !needs_refresh {
895 return;
896 }
897 let diff = if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
898 match slot.take() {
899 Some(PrecomputedDiff::Ready(diff)) => diff,
900 Some(PrecomputedDiff::Placeholder(diff)) => diff,
901 None => Self::diff_strings(
902 self.old_contents[idx].as_ref(),
903 self.new_contents[idx].as_ref(),
904 ),
905 }
906 } else {
907 Self::diff_strings(
908 self.old_contents[idx].as_ref(),
909 self.new_contents[idx].as_ref(),
910 )
911 };
912 let lazy_maps = self.file_is_large(idx);
913 let navigator = DiffNavigator::new(
914 diff,
915 self.old_contents[idx].clone(),
916 self.new_contents[idx].clone(),
917 lazy_maps,
918 );
919 if let Some(slot) = self.navigators.get_mut(idx) {
920 *slot = Some(navigator);
921 }
922 if let Some(flag) = self.navigator_is_placeholder.get_mut(idx) {
923 *flag = false;
924 }
925 }
926
927 pub fn next_file(&mut self) -> bool {
929 if self.selected_index < self.files.len().saturating_sub(1) {
930 self.selected_index += 1;
931 true
932 } else {
933 false
934 }
935 }
936
937 pub fn prev_file(&mut self) -> bool {
939 if self.selected_index > 0 {
940 self.selected_index -= 1;
941 true
942 } else {
943 false
944 }
945 }
946
947 pub fn select_file(&mut self, index: usize) {
949 if index < self.files.len() {
950 self.selected_index = index;
951 }
952 }
953
954 pub fn file_count(&self) -> usize {
956 self.files.len()
957 }
958
959 pub fn repo_root(&self) -> Option<&Path> {
961 self.repo_root.as_deref()
962 }
963
964 pub fn is_git_mode(&self) -> bool {
966 self.repo_root.is_some()
967 }
968
969 pub fn git_range_display(&self) -> Option<(String, String)> {
971 let mode = self.git_mode.as_ref()?;
972 match mode {
973 GitDiffMode::Range { from, to } => Some((format_ref(from), format_ref(to))),
974 GitDiffMode::IndexRange { from, to_index } => {
975 let staged = "STAGED".to_string();
976 if *to_index {
977 Some((format_ref(from), staged))
978 } else {
979 Some((staged, format_ref(from)))
980 }
981 }
982 _ => None,
983 }
984 }
985
986 pub fn blame_sources(&self) -> Option<(BlameSource, BlameSource)> {
988 let mode = self.git_mode.as_ref()?;
989 let sources = match mode {
990 GitDiffMode::Uncommitted => (
991 BlameSource::Commit("HEAD".to_string()),
992 BlameSource::Worktree,
993 ),
994 GitDiffMode::Staged => (BlameSource::Commit("HEAD".to_string()), BlameSource::Index),
995 GitDiffMode::Range { from, to } => (
996 BlameSource::Commit(from.clone()),
997 BlameSource::Commit(to.clone()),
998 ),
999 GitDiffMode::IndexRange { from, to_index } => {
1000 if *to_index {
1001 (BlameSource::Commit(from.clone()), BlameSource::Index)
1002 } else {
1003 (BlameSource::Index, BlameSource::Commit(from.clone()))
1004 }
1005 }
1006 };
1007 Some(sources)
1008 }
1009
1010 pub fn current_step_direction(&self) -> StepDirection {
1012 if let Some(Some(nav)) = self.navigators.get(self.selected_index) {
1013 nav.state().step_direction
1014 } else {
1015 StepDirection::None
1016 }
1017 }
1018
1019 pub fn is_multi_file(&self) -> bool {
1021 self.files.len() > 1
1022 }
1023
1024 pub fn total_stats(&self) -> (usize, usize) {
1026 self.files.iter().fold((0, 0), |(ins, del), f| {
1027 (ins + f.insertions, del + f.deletions)
1028 })
1029 }
1030
1031 pub fn current_old_is_empty(&self) -> bool {
1033 self.old_contents
1034 .get(self.selected_index)
1035 .map(|s| s.is_empty())
1036 .unwrap_or(true)
1037 }
1038
1039 pub fn current_new_is_empty(&self) -> bool {
1041 self.new_contents
1042 .get(self.selected_index)
1043 .map(|s| s.is_empty())
1044 .unwrap_or(true)
1045 }
1046
1047 pub fn refresh_all_from_git(&mut self) -> bool {
1050 let repo_root = match &self.repo_root {
1051 Some(root) => root.clone(),
1052 None => return false,
1053 };
1054 let mode = match &self.git_mode {
1055 Some(mode) => mode.clone(),
1056 None => return false,
1057 };
1058
1059 let changes = match mode {
1061 GitDiffMode::Uncommitted => crate::git::get_uncommitted_changes(&repo_root),
1062 GitDiffMode::Staged => crate::git::get_staged_changes(&repo_root),
1063 GitDiffMode::Range { ref from, ref to } => {
1064 crate::git::get_changes_between(&repo_root, from, to)
1065 }
1066 GitDiffMode::IndexRange { ref from, to_index } => {
1067 crate::git::get_changes_between_index(&repo_root, from, !to_index)
1068 }
1069 };
1070 let changes = match changes {
1071 Ok(c) => c,
1072 Err(_) => return false,
1073 };
1074
1075 let mut files = Vec::new();
1077 let mut old_contents = Vec::new();
1078 let mut new_contents = Vec::new();
1079 let mut precomputed_diffs = Vec::new();
1080 let mut diff_statuses = Vec::new();
1081 for change in changes {
1082 let old_path = change
1083 .old_path
1084 .clone()
1085 .unwrap_or_else(|| change.path.clone());
1086 let (old_content, old_binary, new_content, new_binary) = match mode {
1087 GitDiffMode::Uncommitted => {
1088 let (old_content, old_binary) = match change.status {
1089 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1090 _ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
1091 };
1092 let (new_content, new_binary) = match change.status {
1093 FileStatus::Deleted => (String::new(), false),
1094 _ => {
1095 let full_path = repo_root.join(&change.path);
1096 Self::read_text_or_binary(&full_path)
1097 }
1098 };
1099 (old_content, old_binary, new_content, new_binary)
1100 }
1101 GitDiffMode::Staged => {
1102 let (old_content, old_binary) = match change.status {
1103 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1104 _ => Self::read_git_commit_or_binary(&repo_root, "HEAD", &old_path),
1105 };
1106 let (new_content, new_binary) = match change.status {
1107 FileStatus::Deleted => (String::new(), false),
1108 _ => Self::read_git_index_or_binary(&repo_root, &change.path),
1109 };
1110 (old_content, old_binary, new_content, new_binary)
1111 }
1112 GitDiffMode::Range { ref from, ref to } => {
1113 let (old_content, old_binary) = match change.status {
1114 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1115 _ => Self::read_git_commit_or_binary(&repo_root, from, &old_path),
1116 };
1117 let (new_content, new_binary) = match change.status {
1118 FileStatus::Deleted => (String::new(), false),
1119 _ => Self::read_git_commit_or_binary(&repo_root, to, &change.path),
1120 };
1121 (old_content, old_binary, new_content, new_binary)
1122 }
1123 GitDiffMode::IndexRange { ref from, to_index } => {
1124 if to_index {
1125 let (old_content, old_binary) = match change.status {
1126 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1127 _ => Self::read_git_commit_or_binary(&repo_root, from, &old_path),
1128 };
1129 let (new_content, new_binary) = match change.status {
1130 FileStatus::Deleted => (String::new(), false),
1131 _ => Self::read_git_index_or_binary(&repo_root, &change.path),
1132 };
1133 (old_content, old_binary, new_content, new_binary)
1134 } else {
1135 let (old_content, old_binary) = match change.status {
1136 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1137 _ => Self::read_git_index_or_binary(&repo_root, &old_path),
1138 };
1139 let (new_content, new_binary) = match change.status {
1140 FileStatus::Deleted => (String::new(), false),
1141 _ => Self::read_git_commit_or_binary(&repo_root, from, &change.path),
1142 };
1143 (old_content, old_binary, new_content, new_binary)
1144 }
1145 }
1146 };
1147
1148 let binary = old_binary || new_binary;
1149 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
1150 let (old_content, new_content, precomputed, diff_status) =
1151 Self::maybe_defer_diff(old_content, new_content, binary);
1152
1153 files.push(FileEntry {
1154 display_name: change.path.display().to_string(),
1155 path: change.path,
1156 old_path: change.old_path,
1157 status: change.status,
1158 insertions,
1159 deletions,
1160 binary,
1161 });
1162
1163 old_contents.push(Arc::from(old_content));
1164 new_contents.push(Arc::from(new_content));
1165 precomputed_diffs.push(precomputed);
1166 diff_statuses.push(diff_status);
1167 }
1168
1169 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
1171 let navigator_is_placeholder = vec![false; files.len()];
1172 self.files = files;
1173 self.old_contents = old_contents;
1174 self.new_contents = new_contents;
1175 self.precomputed_diffs = precomputed_diffs;
1176 self.diff_statuses = diff_statuses;
1177 self.navigators = navigators;
1178 self.navigator_is_placeholder = navigator_is_placeholder;
1179
1180 if self.selected_index >= self.files.len() {
1182 self.selected_index = self.files.len().saturating_sub(1);
1183 }
1184
1185 true
1186 }
1187
1188 pub fn refresh_current_file(&mut self) {
1190 let idx = self.selected_index;
1191 let file = &self.files[idx];
1192 let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone());
1193
1194 let (old_content, old_binary, new_content, new_binary) =
1196 match (&self.repo_root, &self.git_mode) {
1197 (Some(repo_root), Some(GitDiffMode::Uncommitted)) => {
1198 let (old_content, old_binary) = match file.status {
1199 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1200 _ => Self::read_git_commit_or_binary(repo_root, "HEAD", &old_path),
1201 };
1202 let (new_content, new_binary) = match file.status {
1203 FileStatus::Deleted => (String::new(), false),
1204 _ => {
1205 let full_path = repo_root.join(&file.path);
1206 Self::read_text_or_binary(&full_path)
1207 }
1208 };
1209 (old_content, old_binary, new_content, new_binary)
1210 }
1211 (Some(repo_root), Some(GitDiffMode::Staged)) => {
1212 let (old_content, old_binary) = match file.status {
1213 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1214 _ => Self::read_git_commit_or_binary(repo_root, "HEAD", &old_path),
1215 };
1216 let (new_content, new_binary) = match file.status {
1217 FileStatus::Deleted => (String::new(), false),
1218 _ => Self::read_git_index_or_binary(repo_root, &file.path),
1219 };
1220 (old_content, old_binary, new_content, new_binary)
1221 }
1222 (Some(repo_root), Some(GitDiffMode::Range { from, to })) => {
1223 let (old_content, old_binary) = match file.status {
1224 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1225 _ => Self::read_git_commit_or_binary(repo_root, from, &old_path),
1226 };
1227 let (new_content, new_binary) = match file.status {
1228 FileStatus::Deleted => (String::new(), false),
1229 _ => Self::read_git_commit_or_binary(repo_root, to, &file.path),
1230 };
1231 (old_content, old_binary, new_content, new_binary)
1232 }
1233 (Some(repo_root), Some(GitDiffMode::IndexRange { from, to_index })) => {
1234 if *to_index {
1235 let (old_content, old_binary) = match file.status {
1236 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1237 _ => Self::read_git_commit_or_binary(repo_root, from, &old_path),
1238 };
1239 let (new_content, new_binary) = match file.status {
1240 FileStatus::Deleted => (String::new(), false),
1241 _ => Self::read_git_index_or_binary(repo_root, &file.path),
1242 };
1243 (old_content, old_binary, new_content, new_binary)
1244 } else {
1245 let (old_content, old_binary) = match file.status {
1246 FileStatus::Added | FileStatus::Untracked => (String::new(), false),
1247 _ => Self::read_git_index_or_binary(repo_root, &old_path),
1248 };
1249 let (new_content, new_binary) = match file.status {
1250 FileStatus::Deleted => (String::new(), false),
1251 _ => Self::read_git_commit_or_binary(repo_root, from, &file.path),
1252 };
1253 (old_content, old_binary, new_content, new_binary)
1254 }
1255 }
1256 _ => {
1257 let (new_content, new_binary) = Self::read_text_or_binary(&file.path);
1258 (
1259 self.old_contents[idx].as_ref().to_string(),
1260 false,
1261 new_content,
1262 new_binary,
1263 )
1264 }
1265 };
1266
1267 let binary = old_binary || new_binary;
1268 let (insertions, deletions) = Self::diff_stats(&old_content, &new_content, binary);
1269 let (old_content, new_content, precomputed, diff_status) =
1270 Self::maybe_defer_diff(old_content, new_content, binary);
1271
1272 self.old_contents[idx] = Arc::from(old_content);
1273 self.new_contents[idx] = Arc::from(new_content);
1274 self.files[idx].binary = binary;
1275 self.files[idx].insertions = insertions;
1276 self.files[idx].deletions = deletions;
1277 if let Some(slot) = self.precomputed_diffs.get_mut(idx) {
1278 *slot = precomputed;
1279 }
1280 if let Some(status) = self.diff_statuses.get_mut(idx) {
1281 *status = diff_status;
1282 }
1283
1284 self.navigators[idx] = None;
1286 if let Some(flag) = self.navigator_is_placeholder.get_mut(idx) {
1287 *flag = false;
1288 }
1289 }
1290}
1291
1292fn collect_files(
1293 dir: &Path,
1294 base: &Path,
1295 files: &mut std::collections::HashSet<PathBuf>,
1296) -> Result<(), std::io::Error> {
1297 for entry in std::fs::read_dir(dir)? {
1298 let entry = entry?;
1299 let path = entry.path();
1300
1301 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1303 if name.starts_with('.') || name == "node_modules" || name == "target" {
1304 continue;
1305 }
1306 }
1307
1308 if path.is_dir() {
1309 collect_files(&path, base, files)?;
1310 } else if path.is_file() {
1311 if let Ok(rel) = path.strip_prefix(base) {
1312 files.insert(rel.to_path_buf());
1313 }
1314 }
1315 }
1316 Ok(())
1317}
1318
1319fn format_ref(reference: &str) -> String {
1320 match reference {
1321 "HEAD" => "HEAD".to_string(),
1322 "INDEX" => "STAGED".to_string(),
1323 _ => shorten_hash(reference),
1324 }
1325}
1326
1327fn shorten_hash(hash: &str) -> String {
1328 hash.chars().take(7).collect()
1329}
1330
1331#[cfg(test)]
1332mod tests {
1333 use super::*;
1334 use std::sync::Mutex;
1335
1336 static DIFF_SETTINGS_LOCK: Mutex<()> = Mutex::new(());
1337
1338 #[test]
1339 fn deferred_diff_upgrades_to_ready() {
1340 let _guard = DIFF_SETTINGS_LOCK.lock().unwrap();
1341 MultiFileDiff::set_diff_max_bytes(32);
1342 MultiFileDiff::set_diff_defer(true);
1343
1344 let content = "a".repeat(128);
1345 let mut diff = MultiFileDiff::from_file_pair_bytes(
1346 PathBuf::from("file.txt"),
1347 content.clone().into_bytes(),
1348 content.into_bytes(),
1349 );
1350
1351 assert_eq!(diff.diff_status(0), DiffStatus::Deferred);
1352
1353 let computed = MultiFileDiff::compute_diff(
1354 diff.old_contents[0].as_ref(),
1355 diff.new_contents[0].as_ref(),
1356 );
1357 diff.apply_diff_result(0, computed);
1358 assert_eq!(diff.diff_status(0), DiffStatus::Ready);
1359
1360 MultiFileDiff::set_diff_max_bytes(DEFAULT_DIFF_MAX_BYTES);
1361 MultiFileDiff::set_diff_defer(true);
1362 }
1363}