Skip to main content

oyo_core/
multi.rs

1//! Multi-file diff support
2
3use 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/// A file entry in a multi-file diff
21#[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
32/// Multi-file diff session
33pub struct MultiFileDiff {
34    /// All files being diffed
35    pub files: Vec<FileEntry>,
36    /// Currently selected file index
37    pub selected_index: usize,
38    /// Navigators for each file (lazy loaded)
39    navigators: Vec<Option<DiffNavigator>>,
40    /// True when the current navigator is built from a placeholder diff
41    navigator_is_placeholder: Vec<bool>,
42    /// Repository root (if in git mode)
43    #[allow(dead_code)]
44    repo_root: Option<PathBuf>,
45    /// Git diff mode (if in git mode)
46    git_mode: Option<GitDiffMode>,
47    /// Old contents for each file
48    old_contents: Vec<Arc<str>>,
49    /// New contents for each file
50    new_contents: Vec<Arc<str>>,
51    /// Precomputed diffs (used for large files to avoid expensive diffing on demand)
52    precomputed_diffs: Vec<Option<PrecomputedDiff>>,
53    /// Diff readiness state per file
54    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/// Source for blame lookups.
66#[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    /// Create from a list of changed files (git mode)
292    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            // Get old and new content
303            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    /// Create from staged git changes (index vs HEAD)
355    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    /// Create from a git range where one side is the staged index
418    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    /// Create from a git range (from..to)
495    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    /// Create from two directories
560    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        // Collect all files from both directories
567        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            // Skip if no changes
627            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    /// Create from a single file pair
669    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    /// Create from a single file pair (bytes, with binary detection).
679    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    /// Create from multiple file pairs.
712    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    /// Get the navigator for the currently selected file
756    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    /// Get the current file entry
793    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    /// Check if the current file is binary
810    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    /// True when diffing is not ready for the current file (deferred/disabled)
818    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    /// Select next file
928    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    /// Select previous file
938    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    /// Select file by index
948    pub fn select_file(&mut self, index: usize) {
949        if index < self.files.len() {
950            self.selected_index = index;
951        }
952    }
953
954    /// Total number of files
955    pub fn file_count(&self) -> usize {
956        self.files.len()
957    }
958
959    /// Repository root path (git mode only)
960    pub fn repo_root(&self) -> Option<&Path> {
961        self.repo_root.as_deref()
962    }
963
964    /// True if this diff was created from git changes
965    pub fn is_git_mode(&self) -> bool {
966        self.repo_root.is_some()
967    }
968
969    /// Return a display-friendly git range for header usage (if applicable).
970    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    /// Blame sources for old/new content when in git mode.
987    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    /// Get the step direction of current navigator (if loaded)
1011    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    /// Check if we have multiple files
1020    pub fn is_multi_file(&self) -> bool {
1021        self.files.len() > 1
1022    }
1023
1024    /// Get total stats across all files
1025    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    /// Check if current file's old content is empty
1032    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    /// Check if current file's new content is empty
1040    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    /// Refresh all files from git (re-scan for uncommitted changes)
1048    /// Returns true if successful, false if not in git mode
1049    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        // Get fresh list of changes
1060        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        // Rebuild the entire diff state
1076        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        // Update state
1170        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        // Clamp selected index to valid range
1181        if self.selected_index >= self.files.len() {
1182            self.selected_index = self.files.len().saturating_sub(1);
1183        }
1184
1185        true
1186    }
1187
1188    /// Refresh the current file from disk (re-read and re-diff)
1189    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        // Get fresh content based on mode
1195        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        // Clear the navigator so it gets rebuilt on next access
1285        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        // Skip hidden files and common ignore patterns
1302        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}