1use crate::diff::DiffEngine;
4use crate::git::{ChangedFile, FileStatus};
5use crate::step::{DiffNavigator, StepDirection};
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9#[derive(Error, Debug)]
10pub enum MultiDiffError {
11 #[error("IO error: {0}")]
12 Io(#[from] std::io::Error),
13 #[error("Git error: {0}")]
14 Git(#[from] crate::git::GitError),
15}
16
17#[derive(Debug, Clone)]
19pub struct FileEntry {
20 pub path: PathBuf,
21 pub old_path: Option<PathBuf>,
22 pub display_name: String,
23 pub status: FileStatus,
24 pub insertions: usize,
25 pub deletions: usize,
26}
27
28pub struct MultiFileDiff {
30 pub files: Vec<FileEntry>,
32 pub selected_index: usize,
34 navigators: Vec<Option<DiffNavigator>>,
36 #[allow(dead_code)]
38 repo_root: Option<PathBuf>,
39 git_mode: Option<GitDiffMode>,
41 old_contents: Vec<String>,
43 new_contents: Vec<String>,
45}
46
47#[derive(Debug, Clone)]
48enum GitDiffMode {
49 Uncommitted,
50 Staged,
51 IndexRange { from: String, to_index: bool },
52 Range { from: String, to: String },
53}
54
55impl MultiFileDiff {
56 pub fn from_git_changes(
58 repo_root: PathBuf,
59 changes: Vec<ChangedFile>,
60 ) -> Result<Self, MultiDiffError> {
61 let mut files = Vec::new();
62 let mut old_contents = Vec::new();
63 let mut new_contents = Vec::new();
64 let engine = DiffEngine::new().with_word_level(true);
65
66 for change in changes {
67 let old_content = match change.status {
69 FileStatus::Added | FileStatus::Untracked => String::new(),
70 _ => crate::git::get_head_content(&repo_root, &change.path).unwrap_or_default(),
71 };
72
73 let new_content = match change.status {
74 FileStatus::Deleted => String::new(),
75 _ => {
76 let full_path = repo_root.join(&change.path);
77 std::fs::read_to_string(&full_path).unwrap_or_default()
78 }
79 };
80
81 let diff = engine.diff_strings(&old_content, &new_content);
83
84 files.push(FileEntry {
85 display_name: change.path.display().to_string(),
86 path: change.path,
87 old_path: change.old_path,
88 status: change.status,
89 insertions: diff.insertions,
90 deletions: diff.deletions,
91 });
92
93 old_contents.push(old_content);
94 new_contents.push(new_content);
95 }
96
97 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
98
99 Ok(Self {
100 files,
101 selected_index: 0,
102 navigators,
103 repo_root: Some(repo_root),
104 git_mode: Some(GitDiffMode::Uncommitted),
105 old_contents,
106 new_contents,
107 })
108 }
109
110 pub fn from_git_staged(
112 repo_root: PathBuf,
113 changes: Vec<ChangedFile>,
114 ) -> Result<Self, MultiDiffError> {
115 let mut files = Vec::new();
116 let mut old_contents = Vec::new();
117 let mut new_contents = Vec::new();
118 let engine = DiffEngine::new().with_word_level(true);
119
120 for change in changes {
121 let old_path = change
122 .old_path
123 .clone()
124 .unwrap_or_else(|| change.path.clone());
125 let old_content = match change.status {
126 FileStatus::Added | FileStatus::Untracked => String::new(),
127 _ => crate::git::get_head_content(&repo_root, &old_path).unwrap_or_default(),
128 };
129
130 let new_content = match change.status {
131 FileStatus::Deleted => String::new(),
132 _ => crate::git::get_staged_content(&repo_root, &change.path).unwrap_or_default(),
133 };
134
135 let diff = engine.diff_strings(&old_content, &new_content);
136
137 files.push(FileEntry {
138 display_name: change.path.display().to_string(),
139 path: change.path,
140 old_path: change.old_path,
141 status: change.status,
142 insertions: diff.insertions,
143 deletions: diff.deletions,
144 });
145
146 old_contents.push(old_content);
147 new_contents.push(new_content);
148 }
149
150 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
151
152 Ok(Self {
153 files,
154 selected_index: 0,
155 navigators,
156 repo_root: Some(repo_root),
157 git_mode: Some(GitDiffMode::Staged),
158 old_contents,
159 new_contents,
160 })
161 }
162
163 pub fn from_git_index_range(
165 repo_root: PathBuf,
166 changes: Vec<ChangedFile>,
167 from: String,
168 to_index: bool,
169 ) -> Result<Self, MultiDiffError> {
170 let mut files = Vec::new();
171 let mut old_contents = Vec::new();
172 let mut new_contents = Vec::new();
173 let engine = DiffEngine::new().with_word_level(true);
174
175 for change in changes {
176 let old_path = change
177 .old_path
178 .clone()
179 .unwrap_or_else(|| change.path.clone());
180 let (old_content, new_content) = if to_index {
181 let old_content = match change.status {
182 FileStatus::Added | FileStatus::Untracked => String::new(),
183 _ => crate::git::get_file_at_commit(&repo_root, &from, &old_path)
184 .unwrap_or_default(),
185 };
186 let new_content = match change.status {
187 FileStatus::Deleted => String::new(),
188 _ => {
189 crate::git::get_staged_content(&repo_root, &change.path).unwrap_or_default()
190 }
191 };
192 (old_content, new_content)
193 } else {
194 let old_content = match change.status {
195 FileStatus::Added | FileStatus::Untracked => String::new(),
196 _ => crate::git::get_staged_content(&repo_root, &old_path).unwrap_or_default(),
197 };
198 let new_content = match change.status {
199 FileStatus::Deleted => String::new(),
200 _ => crate::git::get_file_at_commit(&repo_root, &from, &change.path)
201 .unwrap_or_default(),
202 };
203 (old_content, new_content)
204 };
205
206 let diff = engine.diff_strings(&old_content, &new_content);
207
208 files.push(FileEntry {
209 display_name: change.path.display().to_string(),
210 path: change.path,
211 old_path: change.old_path,
212 status: change.status,
213 insertions: diff.insertions,
214 deletions: diff.deletions,
215 });
216
217 old_contents.push(old_content);
218 new_contents.push(new_content);
219 }
220
221 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
222
223 Ok(Self {
224 files,
225 selected_index: 0,
226 navigators,
227 repo_root: Some(repo_root),
228 git_mode: Some(GitDiffMode::IndexRange { from, to_index }),
229 old_contents,
230 new_contents,
231 })
232 }
233
234 pub fn from_git_range(
236 repo_root: PathBuf,
237 changes: Vec<ChangedFile>,
238 from: String,
239 to: String,
240 ) -> Result<Self, MultiDiffError> {
241 let mut files = Vec::new();
242 let mut old_contents = Vec::new();
243 let mut new_contents = Vec::new();
244 let engine = DiffEngine::new().with_word_level(true);
245
246 for change in changes {
247 let old_path = change
248 .old_path
249 .clone()
250 .unwrap_or_else(|| change.path.clone());
251 let old_content = match change.status {
252 FileStatus::Added | FileStatus::Untracked => String::new(),
253 _ => {
254 crate::git::get_file_at_commit(&repo_root, &from, &old_path).unwrap_or_default()
255 }
256 };
257
258 let new_content = match change.status {
259 FileStatus::Deleted => String::new(),
260 _ => crate::git::get_file_at_commit(&repo_root, &to, &change.path)
261 .unwrap_or_default(),
262 };
263
264 let diff = engine.diff_strings(&old_content, &new_content);
265
266 files.push(FileEntry {
267 display_name: change.path.display().to_string(),
268 path: change.path,
269 old_path: change.old_path,
270 status: change.status,
271 insertions: diff.insertions,
272 deletions: diff.deletions,
273 });
274
275 old_contents.push(old_content);
276 new_contents.push(new_content);
277 }
278
279 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
280
281 Ok(Self {
282 files,
283 selected_index: 0,
284 navigators,
285 repo_root: Some(repo_root),
286 git_mode: Some(GitDiffMode::Range { from, to }),
287 old_contents,
288 new_contents,
289 })
290 }
291
292 pub fn from_directories(old_dir: &Path, new_dir: &Path) -> Result<Self, MultiDiffError> {
294 let mut files = Vec::new();
295 let mut old_contents = Vec::new();
296 let mut new_contents = Vec::new();
297 let engine = DiffEngine::new().with_word_level(true);
298
299 let mut all_files = std::collections::HashSet::new();
301
302 if old_dir.is_dir() {
303 collect_files(old_dir, old_dir, &mut all_files)?;
304 }
305 if new_dir.is_dir() {
306 collect_files(new_dir, new_dir, &mut all_files)?;
307 }
308
309 let mut all_files: Vec<_> = all_files.into_iter().collect();
310 all_files.sort();
311
312 for rel_path in all_files {
313 let old_path = old_dir.join(&rel_path);
314 let new_path = new_dir.join(&rel_path);
315
316 let old_exists = old_path.exists();
317 let new_exists = new_path.exists();
318
319 let status = if !old_exists {
320 FileStatus::Added
321 } else if !new_exists {
322 FileStatus::Deleted
323 } else {
324 FileStatus::Modified
325 };
326
327 let old_content = if old_exists {
328 std::fs::read_to_string(&old_path).unwrap_or_default()
329 } else {
330 String::new()
331 };
332
333 let new_content = if new_exists {
334 std::fs::read_to_string(&new_path).unwrap_or_default()
335 } else {
336 String::new()
337 };
338
339 if old_content == new_content {
341 continue;
342 }
343
344 let diff = engine.diff_strings(&old_content, &new_content);
345
346 files.push(FileEntry {
347 display_name: rel_path.display().to_string(),
348 path: rel_path,
349 old_path: None,
350 status,
351 insertions: diff.insertions,
352 deletions: diff.deletions,
353 });
354
355 old_contents.push(old_content);
356 new_contents.push(new_content);
357 }
358
359 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
360
361 Ok(Self {
362 files,
363 selected_index: 0,
364 navigators,
365 repo_root: None,
366 git_mode: None,
367 old_contents,
368 new_contents,
369 })
370 }
371
372 pub fn from_file_pair(
374 _old_path: PathBuf,
375 new_path: PathBuf,
376 old_content: String,
377 new_content: String,
378 ) -> Self {
379 let engine = DiffEngine::new().with_word_level(true);
380 let diff = engine.diff_strings(&old_content, &new_content);
381
382 let files = vec![FileEntry {
383 display_name: new_path.display().to_string(),
384 path: new_path,
385 old_path: None,
386 status: FileStatus::Modified,
387 insertions: diff.insertions,
388 deletions: diff.deletions,
389 }];
390
391 Self {
392 files,
393 selected_index: 0,
394 navigators: vec![None],
395 repo_root: None,
396 git_mode: None,
397 old_contents: vec![old_content],
398 new_contents: vec![new_content],
399 }
400 }
401
402 pub fn current_navigator(&mut self) -> &mut DiffNavigator {
404 if self.navigators[self.selected_index].is_none() {
405 let engine = DiffEngine::new().with_word_level(true);
406 let diff = engine.diff_strings(
407 &self.old_contents[self.selected_index],
408 &self.new_contents[self.selected_index],
409 );
410 let navigator = DiffNavigator::new(
411 diff,
412 self.old_contents[self.selected_index].clone(),
413 self.new_contents[self.selected_index].clone(),
414 );
415 self.navigators[self.selected_index] = Some(navigator);
416 }
417 self.navigators[self.selected_index].as_mut().unwrap()
418 }
419
420 pub fn current_file(&self) -> Option<&FileEntry> {
422 self.files.get(self.selected_index)
423 }
424
425 pub fn next_file(&mut self) -> bool {
427 if self.selected_index < self.files.len().saturating_sub(1) {
428 self.selected_index += 1;
429 true
430 } else {
431 false
432 }
433 }
434
435 pub fn prev_file(&mut self) -> bool {
437 if self.selected_index > 0 {
438 self.selected_index -= 1;
439 true
440 } else {
441 false
442 }
443 }
444
445 pub fn select_file(&mut self, index: usize) {
447 if index < self.files.len() {
448 self.selected_index = index;
449 }
450 }
451
452 pub fn file_count(&self) -> usize {
454 self.files.len()
455 }
456
457 pub fn repo_root(&self) -> Option<&Path> {
459 self.repo_root.as_deref()
460 }
461
462 pub fn is_git_mode(&self) -> bool {
464 self.repo_root.is_some()
465 }
466
467 pub fn git_range_display(&self) -> Option<(String, String)> {
469 let mode = self.git_mode.as_ref()?;
470 match mode {
471 GitDiffMode::Range { from, to } => Some((format_ref(from), format_ref(to))),
472 GitDiffMode::IndexRange { from, to_index } => {
473 let staged = "STAGED".to_string();
474 if *to_index {
475 Some((format_ref(from), staged))
476 } else {
477 Some((staged, format_ref(from)))
478 }
479 }
480 _ => None,
481 }
482 }
483
484 pub fn current_step_direction(&self) -> StepDirection {
486 if let Some(Some(nav)) = self.navigators.get(self.selected_index) {
487 nav.state().step_direction
488 } else {
489 StepDirection::None
490 }
491 }
492
493 pub fn is_multi_file(&self) -> bool {
495 self.files.len() > 1
496 }
497
498 pub fn total_stats(&self) -> (usize, usize) {
500 self.files.iter().fold((0, 0), |(ins, del), f| {
501 (ins + f.insertions, del + f.deletions)
502 })
503 }
504
505 pub fn current_old_is_empty(&self) -> bool {
507 self.old_contents
508 .get(self.selected_index)
509 .map(|s| s.is_empty())
510 .unwrap_or(true)
511 }
512
513 pub fn current_new_is_empty(&self) -> bool {
515 self.new_contents
516 .get(self.selected_index)
517 .map(|s| s.is_empty())
518 .unwrap_or(true)
519 }
520
521 pub fn refresh_all_from_git(&mut self) -> bool {
524 let repo_root = match &self.repo_root {
525 Some(root) => root.clone(),
526 None => return false,
527 };
528 let mode = match &self.git_mode {
529 Some(mode) => mode.clone(),
530 None => return false,
531 };
532
533 let changes = match mode {
535 GitDiffMode::Uncommitted => crate::git::get_uncommitted_changes(&repo_root),
536 GitDiffMode::Staged => crate::git::get_staged_changes(&repo_root),
537 GitDiffMode::Range { ref from, ref to } => {
538 crate::git::get_changes_between(&repo_root, from, to)
539 }
540 GitDiffMode::IndexRange { ref from, to_index } => {
541 crate::git::get_changes_between_index(&repo_root, from, !to_index)
542 }
543 };
544 let changes = match changes {
545 Ok(c) => c,
546 Err(_) => return false,
547 };
548
549 let mut files = Vec::new();
551 let mut old_contents = Vec::new();
552 let mut new_contents = Vec::new();
553 let engine = DiffEngine::new().with_word_level(true);
554
555 for change in changes {
556 let old_path = change
557 .old_path
558 .clone()
559 .unwrap_or_else(|| change.path.clone());
560 let (old_content, new_content) =
561 match mode {
562 GitDiffMode::Uncommitted => {
563 let old_content = match change.status {
564 FileStatus::Added | FileStatus::Untracked => String::new(),
565 _ => crate::git::get_head_content(&repo_root, &old_path)
566 .unwrap_or_default(),
567 };
568 let new_content = match change.status {
569 FileStatus::Deleted => String::new(),
570 _ => {
571 let full_path = repo_root.join(&change.path);
572 std::fs::read_to_string(&full_path).unwrap_or_default()
573 }
574 };
575 (old_content, new_content)
576 }
577 GitDiffMode::Staged => {
578 let old_content = match change.status {
579 FileStatus::Added | FileStatus::Untracked => String::new(),
580 _ => crate::git::get_head_content(&repo_root, &old_path)
581 .unwrap_or_default(),
582 };
583 let new_content = match change.status {
584 FileStatus::Deleted => String::new(),
585 _ => crate::git::get_staged_content(&repo_root, &change.path)
586 .unwrap_or_default(),
587 };
588 (old_content, new_content)
589 }
590 GitDiffMode::Range { ref from, ref to } => {
591 let old_content = match change.status {
592 FileStatus::Added | FileStatus::Untracked => String::new(),
593 _ => crate::git::get_file_at_commit(&repo_root, from, &old_path)
594 .unwrap_or_default(),
595 };
596 let new_content = match change.status {
597 FileStatus::Deleted => String::new(),
598 _ => crate::git::get_file_at_commit(&repo_root, to, &change.path)
599 .unwrap_or_default(),
600 };
601 (old_content, new_content)
602 }
603 GitDiffMode::IndexRange { ref from, to_index } => {
604 if to_index {
605 let old_content = match change.status {
606 FileStatus::Added | FileStatus::Untracked => String::new(),
607 _ => crate::git::get_file_at_commit(&repo_root, from, &old_path)
608 .unwrap_or_default(),
609 };
610 let new_content = match change.status {
611 FileStatus::Deleted => String::new(),
612 _ => crate::git::get_staged_content(&repo_root, &change.path)
613 .unwrap_or_default(),
614 };
615 (old_content, new_content)
616 } else {
617 let old_content = match change.status {
618 FileStatus::Added | FileStatus::Untracked => String::new(),
619 _ => crate::git::get_staged_content(&repo_root, &old_path)
620 .unwrap_or_default(),
621 };
622 let new_content = match change.status {
623 FileStatus::Deleted => String::new(),
624 _ => crate::git::get_file_at_commit(&repo_root, from, &change.path)
625 .unwrap_or_default(),
626 };
627 (old_content, new_content)
628 }
629 }
630 };
631
632 let diff = engine.diff_strings(&old_content, &new_content);
633
634 files.push(FileEntry {
635 display_name: change.path.display().to_string(),
636 path: change.path,
637 old_path: change.old_path,
638 status: change.status,
639 insertions: diff.insertions,
640 deletions: diff.deletions,
641 });
642
643 old_contents.push(old_content);
644 new_contents.push(new_content);
645 }
646
647 let navigators: Vec<Option<DiffNavigator>> = (0..files.len()).map(|_| None).collect();
649 self.files = files;
650 self.old_contents = old_contents;
651 self.new_contents = new_contents;
652 self.navigators = navigators;
653
654 if self.selected_index >= self.files.len() {
656 self.selected_index = self.files.len().saturating_sub(1);
657 }
658
659 true
660 }
661
662 pub fn refresh_current_file(&mut self) {
664 let idx = self.selected_index;
665 let file = &self.files[idx];
666 let old_path = file.old_path.clone().unwrap_or_else(|| file.path.clone());
667
668 let (old_content, new_content) = match (&self.repo_root, &self.git_mode) {
670 (Some(repo_root), Some(GitDiffMode::Uncommitted)) => {
671 let old_content = match file.status {
672 FileStatus::Added | FileStatus::Untracked => String::new(),
673 _ => crate::git::get_head_content(repo_root, &old_path).unwrap_or_default(),
674 };
675 let new_content = match file.status {
676 FileStatus::Deleted => String::new(),
677 _ => {
678 let full_path = repo_root.join(&file.path);
679 std::fs::read_to_string(&full_path).unwrap_or_default()
680 }
681 };
682 (old_content, new_content)
683 }
684 (Some(repo_root), Some(GitDiffMode::Staged)) => {
685 let old_content = match file.status {
686 FileStatus::Added | FileStatus::Untracked => String::new(),
687 _ => crate::git::get_head_content(repo_root, &old_path).unwrap_or_default(),
688 };
689 let new_content = match file.status {
690 FileStatus::Deleted => String::new(),
691 _ => crate::git::get_staged_content(repo_root, &file.path).unwrap_or_default(),
692 };
693 (old_content, new_content)
694 }
695 (Some(repo_root), Some(GitDiffMode::Range { from, to })) => {
696 let old_content = match file.status {
697 FileStatus::Added | FileStatus::Untracked => String::new(),
698 _ => crate::git::get_file_at_commit(repo_root, from, &old_path)
699 .unwrap_or_default(),
700 };
701 let new_content = match file.status {
702 FileStatus::Deleted => String::new(),
703 _ => crate::git::get_file_at_commit(repo_root, to, &file.path)
704 .unwrap_or_default(),
705 };
706 (old_content, new_content)
707 }
708 (Some(repo_root), Some(GitDiffMode::IndexRange { from, to_index })) => {
709 if *to_index {
710 let old_content = match file.status {
711 FileStatus::Added | FileStatus::Untracked => String::new(),
712 _ => crate::git::get_file_at_commit(repo_root, from, &old_path)
713 .unwrap_or_default(),
714 };
715 let new_content = match file.status {
716 FileStatus::Deleted => String::new(),
717 _ => crate::git::get_staged_content(repo_root, &file.path)
718 .unwrap_or_default(),
719 };
720 (old_content, new_content)
721 } else {
722 let old_content = match file.status {
723 FileStatus::Added | FileStatus::Untracked => String::new(),
724 _ => {
725 crate::git::get_staged_content(repo_root, &old_path).unwrap_or_default()
726 }
727 };
728 let new_content = match file.status {
729 FileStatus::Deleted => String::new(),
730 _ => crate::git::get_file_at_commit(repo_root, from, &file.path)
731 .unwrap_or_default(),
732 };
733 (old_content, new_content)
734 }
735 }
736 _ => {
737 let new_content = std::fs::read_to_string(&file.path).unwrap_or_default();
738 (self.old_contents[idx].clone(), new_content)
739 }
740 };
741
742 self.old_contents[idx] = old_content;
744 self.new_contents[idx] = new_content;
745
746 let engine = DiffEngine::new().with_word_level(true);
748 let diff = engine.diff_strings(&self.old_contents[idx], &self.new_contents[idx]);
749
750 self.files[idx].insertions = diff.insertions;
752 self.files[idx].deletions = diff.deletions;
753
754 self.navigators[idx] = None;
756 }
757}
758
759fn collect_files(
760 dir: &Path,
761 base: &Path,
762 files: &mut std::collections::HashSet<PathBuf>,
763) -> Result<(), std::io::Error> {
764 for entry in std::fs::read_dir(dir)? {
765 let entry = entry?;
766 let path = entry.path();
767
768 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
770 if name.starts_with('.') || name == "node_modules" || name == "target" {
771 continue;
772 }
773 }
774
775 if path.is_dir() {
776 collect_files(&path, base, files)?;
777 } else if path.is_file() {
778 if let Ok(rel) = path.strip_prefix(base) {
779 files.insert(rel.to_path_buf());
780 }
781 }
782 }
783 Ok(())
784}
785
786fn format_ref(reference: &str) -> String {
787 match reference {
788 "HEAD" => "HEAD".to_string(),
789 "INDEX" => "STAGED".to_string(),
790 _ => shorten_hash(reference),
791 }
792}
793
794fn shorten_hash(hash: &str) -> String {
795 hash.chars().take(7).collect()
796}