1use crate::{
4 DiffDocument, DiffSide, FileDiff, FileStatus, Fingerprint, Hunk, LineAnchor, PatchLine,
5 PatchLineKind, RepoPath, SourceDocument, SourceLineRef, SourceLocation, SourceSequenceId,
6 SourceUnavailable, join_lines,
7};
8use serde::{Deserialize, Serialize};
9use similar::{DiffOp, TextDiff};
10use std::{
11 borrow::Cow,
12 collections::{HashMap, HashSet},
13 ops::Range,
14 sync::{Arc, OnceLock},
15};
16
17const NO_NEWLINE_TEXT: &str = "\\ No newline at end of file";
18
19pub const MAX_HUNK_SEQUENCE_LINES: usize = 512;
23
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub enum ViewMode {
27 #[default]
29 Auto,
30 Unified,
31 Split,
32}
33
34impl ViewMode {
35 #[must_use]
36 pub const fn resolve(self, split_when_auto: bool) -> Layout {
37 match self {
38 Self::Split => Layout::Split,
39 Self::Auto if split_when_auto => Layout::Split,
40 Self::Unified | Self::Auto => Layout::Unified,
41 }
42 }
43
44 #[must_use]
45 pub const fn next(self) -> Self {
46 match self {
47 Self::Auto => Self::Unified,
48 Self::Unified => Self::Split,
49 Self::Split => Self::Auto,
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum Layout {
56 Unified,
58 Split,
60}
61
62impl Layout {
63 #[must_use]
64 pub const fn is_split(self) -> bool {
65 matches!(self, Self::Split)
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71pub struct PresentationOptions {
72 pub view_mode: ViewMode,
74 pub split_when_auto: bool,
76 pub include_file_headers: bool,
78}
79
80impl Default for PresentationOptions {
81 fn default() -> Self {
82 Self {
83 view_mode: ViewMode::Auto,
84 split_when_auto: false,
85 include_file_headers: true,
86 }
87 }
88}
89
90impl PresentationOptions {
91 #[must_use]
93 pub const fn layout(self) -> Layout {
94 self.view_mode.resolve(self.split_when_auto)
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
100pub enum RowKind {
101 FileHeader,
102 HunkHeader,
103 Meta,
104 Code,
105 ExpandedContext,
106 ExpandGap,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub struct GapId {
112 pub file_index: usize,
113 pub gap_index: usize,
114}
115
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct GapExpansion {
119 pub revealed_prefix: usize,
120 pub revealed_suffix: usize,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct GapInterval {
126 pub old: Range<usize>,
127 pub new: Range<usize>,
128}
129
130impl GapInterval {
131 #[must_use]
132 pub fn sources_match(&self, old: &SourceDocument, new: &SourceDocument) -> bool {
133 self.old.len() == self.new.len()
134 && self
135 .old
136 .clone()
137 .zip(self.new.clone())
138 .all(|(old_line, new_line)| old.line(old_line) == new.line(new_line))
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct GapInfo {
145 pub id: GapId,
146 pub hidden_lines: usize,
147 pub unavailable: Option<SourceUnavailable>,
148}
149
150impl GapInfo {
151 #[must_use]
153 pub fn message(&self) -> String {
154 match &self.unavailable {
155 None => format!("⋯ {} unchanged lines", self.hidden_lines),
156 Some(SourceUnavailable::TooLarge { .. }) => {
157 "⋯ source is too large to expand".to_owned()
158 }
159 Some(reason) => format!("⋯ {reason}"),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct ContentProjection {
167 pub(crate) expansions: HashMap<GapId, GapExpansion>,
168 pub(crate) full_files: HashSet<RepoPath>,
169}
170
171impl ContentProjection {
172 pub fn set_expansion(&mut self, id: GapId, expansion: GapExpansion) {
173 self.expansions.insert(id, expansion);
174 }
175
176 pub fn set_full_file(&mut self, path: RepoPath, enabled: bool) {
177 if enabled {
178 self.full_files.insert(path);
179 } else {
180 self.full_files.remove(&path);
181 }
182 }
183
184 #[must_use]
185 pub fn is_full_file(&self, path: &RepoPath) -> bool {
186 self.full_files.contains(path)
187 }
188}
189
190pub use clankerdiff_theme::DiffTone;
191
192pub struct HunkSequence<'a> {
195 pub id: SourceSequenceId,
197 pub path: &'a str,
199 pub target_line: usize,
201 hunk: &'a Hunk,
202 side: DiffSide,
203}
204
205impl HunkSequence<'_> {
206 pub fn lines(&self) -> impl Iterator<Item = &str> {
208 self.hunk
209 .lines
210 .iter()
211 .filter(|line| line.line_number(self.side).is_some())
212 .map(|line| line.text.as_ref())
213 }
214}
215
216pub struct CellContext<'a> {
217 pub id: Fingerprint,
218 pub path: &'a str,
219 pub target_line: usize,
220 source: CellContextSource<'a>,
221}
222
223impl CellContext<'_> {
224 #[must_use]
225 pub fn text(&self) -> Cow<'_, str> {
226 match &self.source {
227 CellContextSource::Text(text) => Cow::Borrowed(text),
228 CellContextSource::Hunk(sequence) => Cow::Owned(join_lines(sequence.lines())),
229 }
230 }
231}
232
233enum CellContextSource<'a> {
234 Text(&'a str),
235 Hunk(HunkSequence<'a>),
236}
237
238fn hunk_side_sequence_id(lines: &[PatchLine], side: DiffSide) -> SourceSequenceId {
239 SourceSequenceId::from_lines(
240 lines
241 .iter()
242 .filter(|line| line.line_number(side).is_some())
243 .map(|line| line.text.as_ref()),
244 )
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
249pub struct RowId(pub u64);
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
252pub struct CellSource {
253 pub side: DiffSide,
254 pub hunk_index: usize,
255 pub line_index: usize,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct PresentedCell {
261 pub patch_source: Option<CellSource>,
262 pub source_line: Option<SourceLineRef>,
263 pub text: Arc<str>,
264 pub tone: DiffTone,
265}
266
267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269pub struct PresentedRow {
270 pub id: RowId,
271 pub kind: RowKind,
272 pub file_index: usize,
273 pub hunk_index: Option<usize>,
274 pub left: Option<PresentedCell>,
275 pub right: Option<PresentedCell>,
276}
277
278impl PresentedCell {
279 #[must_use]
280 pub fn line_number(&self) -> Option<usize> {
281 self.source_line.map(|source| source.line_number)
282 }
283}
284
285impl PresentedRow {
286 #[must_use]
287 pub const fn cell(&self, side: DiffSide) -> Option<&PresentedCell> {
288 match side {
289 DiffSide::Old => self.left.as_ref(),
290 DiffSide::New => self.right.as_ref(),
291 }
292 }
293
294 #[must_use]
295 pub const fn primary_cell(&self) -> Option<&PresentedCell> {
296 match &self.right {
297 Some(cell) => Some(cell),
298 None => self.left.as_ref(),
299 }
300 }
301
302 #[must_use]
303 pub const fn preferred_cell(&self, side: DiffSide) -> Option<&PresentedCell> {
304 match self.cell(side) {
305 Some(cell) => Some(cell),
306 None => self.primary_cell(),
307 }
308 }
309
310 pub fn cells(&self) -> impl Iterator<Item = &PresentedCell> {
311 [self.left.as_ref(), self.right.as_ref()]
312 .into_iter()
313 .flatten()
314 }
315
316 pub fn sources(&self) -> impl Iterator<Item = CellSource> + '_ {
317 self.cells().filter_map(|cell| cell.patch_source)
318 }
319
320 pub fn source_lines(&self) -> impl Iterator<Item = SourceLineRef> + '_ {
321 self.cells().filter_map(|cell| cell.source_line)
322 }
323
324 #[must_use]
325 pub fn is_commentable(&self) -> bool {
326 self.kind == RowKind::Code && self.sources().next().is_some()
327 }
328
329 #[must_use]
330 pub fn is_navigable(&self) -> bool {
331 match self.kind {
332 RowKind::Code | RowKind::ExpandedContext => self.source_lines().next().is_some(),
333 RowKind::ExpandGap => true,
334 RowKind::FileHeader | RowKind::HunkHeader | RowKind::Meta => false,
335 }
336 }
337}
338
339#[derive(Debug, Clone)]
342pub struct DiffPresentation {
343 document: Arc<DiffDocument>,
344 layout: Layout,
345 rows: Vec<PresentedRow>,
346 anchor_rows: HashMap<(usize, DiffSide, usize), usize>,
347 source_rows: HashMap<(usize, DiffSide, usize), usize>,
348 gap_info: HashMap<usize, GapInfo>,
349 file_ranges: Vec<Range<usize>>,
350 hunk_ranges: Vec<Vec<Range<usize>>>,
351 sequence_ids: Vec<Vec<OnceLock<[SourceSequenceId; 2]>>>,
354}
355
356impl DiffPresentation {
357 #[must_use]
359 pub fn new(document: Arc<DiffDocument>, options: PresentationOptions) -> Self {
360 Self::with_projection(document, options, &ContentProjection::default())
361 }
362
363 #[must_use]
365 #[allow(clippy::too_many_lines)]
366 pub fn with_projection(
367 document: Arc<DiffDocument>,
368 options: PresentationOptions,
369 projection: &ContentProjection,
370 ) -> Self {
371 let layout = options.layout();
372 let mut rows = Vec::new();
373 let mut gap_info = HashMap::new();
374 let mut file_ranges = Vec::with_capacity(document.files.len());
375 let mut hunk_ranges = Vec::with_capacity(document.files.len());
376 for (file_index, file) in document.files.iter().enumerate() {
377 let file_start = rows.len();
378 if options.include_file_headers {
379 rows.push(header_row(file_index, file));
380 }
381 let old_count = file
382 .source_document(DiffSide::Old)
383 .map_or(0, |source| source.line_count());
384 let new_count = file
385 .source_document(DiffSide::New)
386 .map_or(0, |source| source.line_count());
387 let gaps = gaps_for_file(file, old_count, new_count);
388 let mut file_hunks = Vec::with_capacity(file.hunks.len());
389 for (hunk_index, hunk) in file.hunks.iter().enumerate() {
390 append_gap_projection(
391 &mut rows,
392 &mut gap_info,
393 GapProjection {
394 file_index,
395 gap_index: hunk_index,
396 file,
397 gap: &gaps[hunk_index],
398 layout,
399 projection,
400 },
401 );
402 let hunk_start = rows.len();
403 rows.push(hunk_header_row(file_index, hunk_index, hunk, &file.path));
404 match layout {
405 Layout::Unified => append_unified_rows(&mut rows, file_index, hunk_index, file),
406 Layout::Split => append_split_rows(&mut rows, file_index, hunk_index, file),
407 }
408 file_hunks.push(hunk_start..rows.len());
409 }
410 if file.hunks.is_empty() {
411 if projection.is_full_file(&file.path) {
412 append_gap_projection(
413 &mut rows,
414 &mut gap_info,
415 GapProjection {
416 file_index,
417 gap_index: 0,
418 file,
419 gap: &gaps[0],
420 layout,
421 projection,
422 },
423 );
424 } else {
425 rows.push(meta_row(
426 file_index,
427 None,
428 &file.path,
429 "placeholder",
430 None,
431 placeholder_text(file),
432 ));
433 }
434 } else {
435 append_gap_projection(
436 &mut rows,
437 &mut gap_info,
438 GapProjection {
439 file_index,
440 gap_index: file.hunks.len(),
441 file,
442 gap: &gaps[file.hunks.len()],
443 layout,
444 projection,
445 },
446 );
447 }
448 file_ranges.push(file_start..rows.len());
449 hunk_ranges.push(file_hunks);
450 }
451 let anchor_rows = rows
452 .iter()
453 .enumerate()
454 .flat_map(|(row_index, row)| {
455 row.cells().filter_map(move |cell| {
456 let source = cell.patch_source?;
457 Some((
458 (row.file_index, source.side, cell.line_number()?),
459 row_index,
460 ))
461 })
462 })
463 .collect();
464 let source_rows = rows
465 .iter()
466 .enumerate()
467 .flat_map(|(row_index, row)| {
468 row.cells().filter_map(move |cell| {
469 let source = cell.source_line?;
470 Some(((row.file_index, source.side, source.line_number), row_index))
471 })
472 })
473 .collect();
474 let sequence_ids = document
475 .files
476 .iter()
477 .map(|file| file.hunks.iter().map(|_| OnceLock::new()).collect())
478 .collect();
479 Self {
480 document,
481 layout,
482 rows,
483 anchor_rows,
484 source_rows,
485 gap_info,
486 file_ranges,
487 hunk_ranges,
488 sequence_ids,
489 }
490 }
491
492 #[must_use]
494 pub const fn document(&self) -> &Arc<DiffDocument> {
495 &self.document
496 }
497
498 #[must_use]
499 pub const fn layout(&self) -> Layout {
500 self.layout
501 }
502
503 #[must_use]
505 pub fn row_count(&self) -> usize {
506 self.rows.len()
507 }
508
509 #[must_use]
511 pub fn row(&self, index: usize) -> Option<&PresentedRow> {
512 self.rows.get(index)
513 }
514
515 #[must_use]
517 pub fn rows(&self, range: Range<usize>) -> &[PresentedRow] {
518 let start = range.start.min(self.rows.len());
519 let end = range.end.max(start).min(self.rows.len());
520 &self.rows[start..end]
521 }
522
523 #[must_use]
525 pub fn file_range(&self, file_index: usize) -> Option<Range<usize>> {
526 self.file_ranges.get(file_index).cloned()
527 }
528
529 #[must_use]
531 pub fn hunk_range(&self, file_index: usize, hunk_index: usize) -> Option<Range<usize>> {
532 self.hunk_ranges.get(file_index)?.get(hunk_index).cloned()
533 }
534
535 #[must_use]
536 pub fn cell_anchor(&self, row: &PresentedRow, cell: &PresentedCell) -> Option<LineAnchor> {
537 let source = cell.patch_source?;
538 let file = self.document.files.get(row.file_index)?;
539 LineAnchor::for_line(file, source.side, source.hunk_index, source.line_index)
540 }
541
542 #[must_use]
543 pub fn anchor_at(&self, row_index: usize, side: DiffSide) -> Option<LineAnchor> {
544 let row = self.row(row_index)?;
545 self.cell_anchor(row, row.preferred_cell(side)?)
546 }
547
548 #[must_use]
550 pub fn source_document(
551 &self,
552 row: &PresentedRow,
553 cell: &PresentedCell,
554 ) -> Option<&SourceDocument> {
555 let source = cell.source_line?;
556 let file = self.document.files.get(row.file_index)?;
557 file.source_document(source.side).map(AsRef::as_ref)
558 }
559
560 #[must_use]
562 pub fn source_path<'a>(&'a self, row: &PresentedRow, cell: &PresentedCell) -> Option<&'a str> {
563 let source = cell.source_line?;
564 Some(
565 self.document
566 .files
567 .get(row.file_index)?
568 .path_for_side(source.side)
569 .as_str(),
570 )
571 }
572
573 #[must_use]
576 pub fn hunk_sequence<'a>(
577 &'a self,
578 row: &PresentedRow,
579 cell: &PresentedCell,
580 ) -> Option<HunkSequence<'a>> {
581 let source = cell.patch_source?;
582 let file = self.document.files.get(row.file_index)?;
583 let hunk = file.hunks.get(source.hunk_index)?;
584 if hunk.lines.len() > MAX_HUNK_SEQUENCE_LINES {
585 return None;
586 }
587 hunk.lines.get(source.line_index)?;
588 let target_line = hunk.lines[..source.line_index]
589 .iter()
590 .filter(|line| line.line_number(source.side).is_some())
591 .count();
592 let ids = self
593 .sequence_ids
594 .get(row.file_index)?
595 .get(source.hunk_index)?
596 .get_or_init(|| {
597 [
598 hunk_side_sequence_id(&hunk.lines, DiffSide::Old),
599 hunk_side_sequence_id(&hunk.lines, DiffSide::New),
600 ]
601 });
602 Some(HunkSequence {
603 id: match source.side {
604 DiffSide::Old => ids[0],
605 DiffSide::New => ids[1],
606 },
607 path: file.path_for_side(source.side).as_str(),
608 target_line,
609 hunk,
610 side: source.side,
611 })
612 }
613
614 #[must_use]
615 pub fn cell_context<'a>(
616 &'a self,
617 row: &PresentedRow,
618 cell: &'a PresentedCell,
619 ) -> CellContext<'a> {
620 if let (Some(source), Some(path), Some(line)) = (
621 self.source_document(row, cell),
622 self.source_path(row, cell),
623 cell.line_number().and_then(|line| line.checked_sub(1)),
624 ) {
625 return CellContext {
626 id: source.content_id(),
627 path,
628 target_line: line,
629 source: CellContextSource::Text(source.text()),
630 };
631 }
632 if let Some(sequence) = self.hunk_sequence(row, cell) {
633 return CellContext {
634 id: sequence.id.fingerprint(),
635 path: sequence.path,
636 target_line: sequence.target_line,
637 source: CellContextSource::Hunk(sequence),
638 };
639 }
640 CellContext {
641 id: Fingerprint::of([b"diff-cell-context-v1".as_slice(), cell.text.as_bytes()]),
642 path: self.row_path(row),
643 target_line: 0,
644 source: CellContextSource::Text(&cell.text),
645 }
646 }
647
648 #[must_use]
649 pub fn language_at(&self, row_index: usize) -> &str {
650 self.row(row_index)
651 .map_or("", |row| self.language_at_row(row))
652 }
653
654 #[must_use]
656 pub fn row_path(&self, row: &PresentedRow) -> &str {
657 self.document
658 .files
659 .get(row.file_index)
660 .map_or("", |file| file.path.as_str())
661 }
662
663 fn language_at_row(&self, row: &PresentedRow) -> &str {
664 self.document
665 .files
666 .get(row.file_index)
667 .map_or("", FileDiff::language)
668 }
669
670 #[must_use]
673 pub fn row_showing_anchor(&self, anchor: &LineAnchor) -> Option<usize> {
674 let file_index = self.document.file_index(&anchor.path)?;
675 self.anchor_rows
676 .get(&(file_index, anchor.side, anchor.line_number()?))
677 .copied()
678 }
679
680 #[must_use]
681 pub fn source_location(
682 &self,
683 row: &PresentedRow,
684 cell: &PresentedCell,
685 ) -> Option<SourceLocation> {
686 let source = cell.source_line?;
687 Some(SourceLocation {
688 path: self.document.files.get(row.file_index)?.path.clone(),
689 side: source.side,
690 line_number: source.line_number,
691 })
692 }
693
694 #[must_use]
695 pub fn row_showing_source(&self, location: &SourceLocation) -> Option<usize> {
696 let file_index = self.document.file_index(&location.path)?;
697 self.source_rows
698 .get(&(file_index, location.side, location.line_number))
699 .copied()
700 }
701
702 #[must_use]
703 pub fn gap_info(&self, row_index: usize) -> Option<&GapInfo> {
704 self.gap_info.get(&row_index)
705 }
706
707 #[must_use]
708 pub fn row_shows_anchor(&self, row: &PresentedRow, anchor: &LineAnchor) -> bool {
709 self.document
710 .files
711 .get(row.file_index)
712 .is_some_and(|file| file.path == anchor.path)
713 && row.cells().any(|cell| {
714 cell.patch_source
715 .is_some_and(|source| source.side == anchor.side)
716 && cell.line_number() == anchor.line_number()
717 })
718 }
719
720 #[must_use]
721 pub fn is_commentable(&self, index: usize) -> bool {
722 self.row(index).is_some_and(PresentedRow::is_commentable)
723 }
724
725 #[must_use]
726 pub fn is_navigable(&self, index: usize) -> bool {
727 self.row(index).is_some_and(PresentedRow::is_navigable)
728 }
729
730 #[must_use]
731 pub fn first_navigable(&self, range: Range<usize>) -> Option<usize> {
732 range.into_iter().find(|index| self.is_navigable(*index))
733 }
734
735 #[must_use]
736 pub fn last_navigable(&self, range: Range<usize>) -> Option<usize> {
737 range.rev().find(|index| self.is_navigable(*index))
738 }
739
740 #[must_use]
741 pub fn step_navigable(
742 &self,
743 from: usize,
744 backward: bool,
745 range: &Range<usize>,
746 ) -> Option<usize> {
747 if backward {
748 self.last_navigable(range.start..from.min(range.end))
749 } else {
750 self.first_navigable(from.saturating_add(1).max(range.start)..range.end)
751 }
752 }
753}
754
755pub(crate) fn retained_expansions(
756 previous: &DiffDocument,
757 expansions: &HashMap<GapId, GapExpansion>,
758 next: &DiffDocument,
759) -> HashMap<GapId, GapExpansion> {
760 let mut resolved: HashMap<usize, Option<usize>> = HashMap::new();
761 let mut retained = HashMap::with_capacity(expansions.len());
762 for (id, expansion) in expansions {
763 let next_index = *resolved
764 .entry(id.file_index)
765 .or_insert_with(|| retained_file_index(previous, id.file_index, next));
766 if let Some(file_index) = next_index {
767 retained.insert(
768 GapId {
769 file_index,
770 gap_index: id.gap_index,
771 },
772 *expansion,
773 );
774 }
775 }
776 retained
777}
778
779fn retained_file_index(
780 previous: &DiffDocument,
781 file_index: usize,
782 next: &DiffDocument,
783) -> Option<usize> {
784 let previous = previous.files.get(file_index)?;
785 let next_index = next.file_index(&previous.path)?;
786 let next = next.files.get(next_index)?;
787 (previous.content_id() == next.content_id()).then_some(next_index)
788}
789
790#[must_use]
792pub fn gaps_for_file(
793 file: &FileDiff,
794 old_line_count: usize,
795 new_line_count: usize,
796) -> Vec<GapInterval> {
797 if file.hunks.is_empty() {
798 return vec![GapInterval {
799 old: 1..old_line_count.saturating_add(1),
800 new: 1..new_line_count.saturating_add(1),
801 }];
802 }
803 let boundary = |start: usize, count: usize| {
804 if count == 0 {
805 start.saturating_add(1)
806 } else {
807 start
808 }
809 };
810 let after = |start: usize, count: usize| {
811 if count == 0 {
812 start.saturating_add(1)
813 } else {
814 start.saturating_add(count)
815 }
816 };
817 let mut gaps = Vec::with_capacity(file.hunks.len().saturating_add(1));
818 let (mut old_next, mut new_next) = (1, 1);
819 for hunk in &file.hunks {
820 let old_end = boundary(hunk.old_start, hunk.old_count).max(old_next);
821 let new_end = boundary(hunk.new_start, hunk.new_count).max(new_next);
822 gaps.push(GapInterval {
823 old: old_next..old_end,
824 new: new_next..new_end,
825 });
826 old_next = after(hunk.old_start, hunk.old_count).max(old_end);
827 new_next = after(hunk.new_start, hunk.new_count).max(new_end);
828 }
829 gaps.push(GapInterval {
830 old: old_next..old_line_count.saturating_add(1).max(old_next),
831 new: new_next..new_line_count.saturating_add(1).max(new_next),
832 });
833 gaps
834}
835
836#[derive(Clone, Copy)]
837struct GapProjection<'a> {
838 file_index: usize,
839 gap_index: usize,
840 file: &'a FileDiff,
841 gap: &'a GapInterval,
842 layout: Layout,
843 projection: &'a ContentProjection,
844}
845
846fn append_gap_projection(
847 rows: &mut Vec<PresentedRow>,
848 gap_info: &mut HashMap<usize, GapInfo>,
849 projection_args: GapProjection<'_>,
850) {
851 let GapProjection {
852 file_index,
853 gap_index,
854 file,
855 gap,
856 layout,
857 projection,
858 } = projection_args;
859 let id = GapId {
860 file_index,
861 gap_index,
862 };
863 let expansion = projection.expansions.get(&id).copied();
864 let full_file = projection.is_full_file(&file.path);
865 if expansion.is_none() && !full_file {
866 return;
867 }
868 let expansion = expansion.unwrap_or_default();
869 let old = file.source_document(DiffSide::Old);
870 let new = file.source_document(DiffSide::New);
871 let total = gap.old.len().max(gap.new.len());
872 let loaded = gap_required_sides(layout, file.status)
873 .iter()
874 .all(|side| match side {
875 DiffSide::Old => old.is_some(),
876 DiffSide::New => new.is_some(),
877 });
878 let prefix = if full_file {
879 total
880 } else {
881 expansion.revealed_prefix.min(total)
882 };
883 let suffix = if full_file {
884 total
885 } else {
886 expansion.revealed_suffix.min(total.saturating_sub(prefix))
887 };
888 let hidden = if loaded {
889 total.saturating_sub(prefix.saturating_add(suffix))
890 } else {
891 total
892 };
893 let expanded = ExpandedRow {
894 file_index,
895 file,
896 gap,
897 layout,
898 old,
899 new,
900 };
901
902 if loaded {
903 for offset in 0..prefix {
904 append_expanded_row(rows, expanded, offset);
905 }
906 }
907 if hidden != 0 || !loaded {
908 let unavailable = gap_unavailable(file, layout);
909 let row_index = rows.len();
910 rows.push(PresentedRow {
911 id: row_id(&file.path, "expand-gap", Some(gap_index), None, None),
912 kind: RowKind::ExpandGap,
913 file_index,
914 hunk_index: None,
915 left: None,
916 right: Some(cell(
917 None,
918 None,
919 Arc::<str>::from("unchanged lines"),
920 DiffTone::Meta,
921 )),
922 });
923 gap_info.insert(
924 row_index,
925 GapInfo {
926 id,
927 hidden_lines: hidden,
928 unavailable,
929 },
930 );
931 }
932 if loaded {
933 let start = total.saturating_sub(suffix).max(prefix);
934 for offset in start..total {
935 append_expanded_row(rows, expanded, offset);
936 }
937 }
938}
939
940fn gap_required_sides(layout: Layout, status: FileStatus) -> &'static [DiffSide] {
941 match (layout, status) {
942 (Layout::Split | Layout::Unified, FileStatus::Deleted) => &[DiffSide::Old],
943 (Layout::Split, FileStatus::Added | FileStatus::Untracked) | (Layout::Unified, _) => {
944 &[DiffSide::New]
945 }
946 (Layout::Split, _) => &[DiffSide::Old, DiffSide::New],
947 }
948}
949
950fn gap_unavailable(file: &FileDiff, layout: Layout) -> Option<SourceUnavailable> {
951 gap_required_sides(layout, file.status)
952 .iter()
953 .find_map(|side| file.source_unavailable(*side).cloned())
954}
955
956#[derive(Clone, Copy)]
957struct ExpandedRow<'a> {
958 file_index: usize,
959 file: &'a FileDiff,
960 gap: &'a GapInterval,
961 layout: Layout,
962 old: Option<&'a Arc<SourceDocument>>,
963 new: Option<&'a Arc<SourceDocument>>,
964}
965
966fn append_expanded_row(rows: &mut Vec<PresentedRow>, expanded: ExpandedRow<'_>, offset: usize) {
967 let ExpandedRow {
968 file_index,
969 file,
970 gap,
971 layout,
972 old,
973 new,
974 } = expanded;
975 let make = |side: DiffSide, range: &Range<usize>, source: Option<&Arc<SourceDocument>>| {
976 let line_number = range.start.saturating_add(offset);
977 if line_number >= range.end {
978 return None;
979 }
980 let text = source?.line(line_number)?;
981 Some(cell(
982 None,
983 Some(SourceLineRef { side, line_number }),
984 text,
985 DiffTone::Context,
986 ))
987 };
988 let (left, right) = match layout {
989 Layout::Split => (
990 make(DiffSide::Old, &gap.old, old),
991 make(DiffSide::New, &gap.new, new),
992 ),
993 Layout::Unified if file.status == FileStatus::Deleted => {
994 (make(DiffSide::Old, &gap.old, old), None)
995 }
996 Layout::Unified => (None, make(DiffSide::New, &gap.new, new)),
997 };
998 if left.is_none() && right.is_none() {
999 return;
1000 }
1001 let old_number = left.as_ref().and_then(PresentedCell::line_number);
1002 let new_number = right.as_ref().and_then(PresentedCell::line_number);
1003 rows.push(PresentedRow {
1004 id: row_id(&file.path, "expanded-context", None, old_number, new_number),
1005 kind: RowKind::ExpandedContext,
1006 file_index,
1007 hunk_index: None,
1008 left,
1009 right,
1010 });
1011}
1012
1013fn placeholder_text(file: &FileDiff) -> Arc<str> {
1014 if let Some(bytes) = file.omitted_bytes {
1015 return format!("File content omitted ({bytes} bytes)").into();
1016 }
1017 if file.binary {
1018 "Binary file changed".into()
1019 } else if file.mode.is_some() {
1020 "File mode changed".into()
1021 } else {
1022 "Empty file changed".into()
1023 }
1024}
1025
1026fn header_row(file_index: usize, file: &FileDiff) -> PresentedRow {
1027 let text = file
1028 .old_path
1029 .as_ref()
1030 .filter(|old| *old != &file.path)
1031 .map_or_else(
1032 || file.path.to_string(),
1033 |old| format!("{old} → {}", file.path),
1034 );
1035 PresentedRow {
1036 id: row_id(&file.path, "file", None, None, None),
1037 kind: RowKind::FileHeader,
1038 file_index,
1039 hunk_index: None,
1040 left: None,
1041 right: Some(cell(None, None, text, DiffTone::Meta)),
1042 }
1043}
1044
1045fn hunk_header_row(
1046 file_index: usize,
1047 hunk_index: usize,
1048 hunk: &crate::Hunk,
1049 path: &RepoPath,
1050) -> PresentedRow {
1051 PresentedRow {
1052 id: row_id(path, "hunk", Some(hunk_index), None, None),
1053 kind: RowKind::HunkHeader,
1054 file_index,
1055 hunk_index: Some(hunk_index),
1056 left: None,
1057 right: Some(cell(None, None, hunk.header.as_str(), DiffTone::Meta)),
1058 }
1059}
1060
1061fn append_unified_rows(
1062 rows: &mut Vec<PresentedRow>,
1063 file_index: usize,
1064 hunk_index: usize,
1065 file: &FileDiff,
1066) {
1067 for (line_index, line) in file.hunks[hunk_index].lines.iter().enumerate() {
1068 rows.push(unified_row(
1069 file_index, hunk_index, line_index, &file.path, line,
1070 ));
1071 append_no_newline_meta(rows, file_index, hunk_index, &file.path, line_index, line);
1072 }
1073}
1074
1075fn unified_row(
1076 file_index: usize,
1077 hunk_index: usize,
1078 line_index: usize,
1079 path: &RepoPath,
1080 line: &PatchLine,
1081) -> PresentedRow {
1082 let side = match line.kind {
1083 PatchLineKind::Removed => DiffSide::Old,
1084 _ => DiffSide::New,
1085 };
1086 let presented = code_cell(side, hunk_index, line_index, line);
1087 let (left, right) = match side {
1088 DiffSide::Old => (Some(presented), None),
1089 DiffSide::New => (None, Some(presented)),
1090 };
1091 PresentedRow {
1092 id: code_row_id(path, hunk_index, left.as_ref(), right.as_ref()),
1093 kind: RowKind::Code,
1094 file_index,
1095 hunk_index: Some(hunk_index),
1096 left,
1097 right,
1098 }
1099}
1100
1101fn append_split_rows(
1102 rows: &mut Vec<PresentedRow>,
1103 file_index: usize,
1104 hunk_index: usize,
1105 file: &FileDiff,
1106) {
1107 let lines = &file.hunks[hunk_index].lines;
1108 for group in split_groups(lines) {
1109 match group {
1110 SplitGroup::Single { line, index } => {
1111 let present = Some((index, line));
1112 let (left, right) = match line.kind {
1113 PatchLineKind::Added => (None, present),
1114 PatchLineKind::Removed => (present, None),
1115 _ => (present, present),
1116 };
1117 rows.push(split_code_row(file_index, hunk_index, file, left, right));
1118 append_no_newline_meta(rows, file_index, hunk_index, &file.path, index, line);
1119 }
1120 SplitGroup::Changed { removed, added } => {
1121 for (left, right) in pair_changed_block(&removed, &added) {
1122 rows.push(split_code_row(
1123 file_index,
1124 hunk_index,
1125 file,
1126 left.map(|side| (side.index, side.line)),
1127 right.map(|side| (side.index, side.line)),
1128 ));
1129 if let Some(side) = left
1130 .filter(|side| side.line.no_newline)
1131 .or_else(|| right.filter(|side| side.line.no_newline))
1132 {
1133 append_no_newline_meta(
1134 rows, file_index, hunk_index, &file.path, side.index, side.line,
1135 );
1136 }
1137 }
1138 }
1139 }
1140 }
1141}
1142
1143fn append_no_newline_meta(
1144 rows: &mut Vec<PresentedRow>,
1145 file_index: usize,
1146 hunk_index: usize,
1147 path: &RepoPath,
1148 line_index: usize,
1149 line: &PatchLine,
1150) {
1151 if line.no_newline {
1152 rows.push(meta_row(
1153 file_index,
1154 Some(hunk_index),
1155 path,
1156 "no-newline",
1157 Some(line_index),
1158 NO_NEWLINE_TEXT,
1159 ));
1160 }
1161}
1162
1163fn split_code_row(
1164 file_index: usize,
1165 hunk_index: usize,
1166 file: &FileDiff,
1167 left: Option<(usize, &PatchLine)>,
1168 right: Option<(usize, &PatchLine)>,
1169) -> PresentedRow {
1170 let build = |side: DiffSide, source: Option<(usize, &PatchLine)>| {
1171 let (index, line) = source?;
1172 line.line_number(side)?;
1173 Some(code_cell(side, hunk_index, index, line))
1174 };
1175 let left = build(DiffSide::Old, left);
1176 let right = build(DiffSide::New, right);
1177 PresentedRow {
1178 id: code_row_id(&file.path, hunk_index, left.as_ref(), right.as_ref()),
1179 kind: RowKind::Code,
1180 file_index,
1181 hunk_index: Some(hunk_index),
1182 left,
1183 right,
1184 }
1185}
1186
1187fn cell(
1188 patch_source: Option<CellSource>,
1189 source_line: Option<SourceLineRef>,
1190 text: impl Into<Arc<str>>,
1191 tone: DiffTone,
1192) -> PresentedCell {
1193 PresentedCell {
1194 patch_source,
1195 source_line,
1196 text: text.into(),
1197 tone,
1198 }
1199}
1200
1201fn code_cell(
1202 side: DiffSide,
1203 hunk_index: usize,
1204 line_index: usize,
1205 line: &PatchLine,
1206) -> PresentedCell {
1207 let line_number = (!matches!(line.kind, PatchLineKind::Meta | PatchLineKind::HunkHeader))
1208 .then(|| line.line_number(side))
1209 .flatten();
1210 let real_source = line_number.is_some();
1211 let source = real_source.then_some(CellSource {
1212 side,
1213 hunk_index,
1214 line_index,
1215 });
1216 cell(
1217 source,
1218 line_number.map(|line_number| SourceLineRef { side, line_number }),
1219 Arc::clone(&line.text),
1220 line.kind.tone(),
1221 )
1222}
1223
1224fn meta_row(
1225 file_index: usize,
1226 hunk_index: Option<usize>,
1227 path: &RepoPath,
1228 kind: &str,
1229 line_index: Option<usize>,
1230 text: impl Into<Arc<str>>,
1231) -> PresentedRow {
1232 PresentedRow {
1233 id: row_id(path, kind, hunk_index, line_index, None),
1234 kind: RowKind::Meta,
1235 file_index,
1236 hunk_index,
1237 left: None,
1238 right: Some(cell(None, None, text, DiffTone::Meta)),
1239 }
1240}
1241
1242fn index_field(value: Option<usize>) -> [u8; 9] {
1243 let mut field = [0_u8; 9];
1244 if let Some(index) = value {
1245 field[0] = 1;
1246 field[1..].copy_from_slice(&u64::try_from(index).unwrap_or(u64::MAX).to_le_bytes());
1247 }
1248 field
1249}
1250
1251fn row_id(
1252 path: &RepoPath,
1253 kind: &str,
1254 hunk: Option<usize>,
1255 left: Option<usize>,
1256 right: Option<usize>,
1257) -> RowId {
1258 let hunk = index_field(hunk);
1259 let left = index_field(left);
1260 let right = index_field(right);
1261 RowId(
1262 Fingerprint::of([
1263 path.as_str().as_bytes(),
1264 kind.as_bytes(),
1265 hunk.as_slice(),
1266 left.as_slice(),
1267 right.as_slice(),
1268 ])
1269 .to_u64(),
1270 )
1271}
1272
1273fn code_row_id(
1274 path: &RepoPath,
1275 hunk_index: usize,
1276 left: Option<&PresentedCell>,
1277 right: Option<&PresentedCell>,
1278) -> RowId {
1279 let line_index = |cell: Option<&PresentedCell>| {
1280 cell.and_then(|cell| cell.patch_source)
1281 .map(|source| source.line_index)
1282 };
1283 row_id(
1284 path,
1285 "code",
1286 Some(hunk_index),
1287 line_index(left),
1288 line_index(right),
1289 )
1290}
1291
1292fn split_groups(lines: &[PatchLine]) -> Vec<SplitGroup<'_>> {
1293 let mut groups = Vec::new();
1294 let mut index = 0;
1295 while index < lines.len() {
1296 if lines[index].kind != PatchLineKind::Removed {
1297 groups.push(SplitGroup::Single {
1298 line: &lines[index],
1299 index,
1300 });
1301 index += 1;
1302 continue;
1303 }
1304 let sides = |range: Range<usize>| {
1305 range
1306 .map(|index| SplitSide {
1307 line: &lines[index],
1308 index,
1309 })
1310 .collect::<Vec<_>>()
1311 };
1312 let removed_start = index;
1313 index += lines[index..]
1314 .iter()
1315 .take_while(|line| line.kind == PatchLineKind::Removed)
1316 .count();
1317 let added_start = index;
1318 index += lines[index..]
1319 .iter()
1320 .take_while(|line| line.kind == PatchLineKind::Added)
1321 .count();
1322 groups.push(SplitGroup::Changed {
1323 removed: sides(removed_start..added_start),
1324 added: sides(added_start..index),
1325 });
1326 }
1327 groups
1328}
1329
1330enum SplitGroup<'a> {
1331 Single {
1332 line: &'a PatchLine,
1333 index: usize,
1334 },
1335 Changed {
1336 removed: Vec<SplitSide<'a>>,
1337 added: Vec<SplitSide<'a>>,
1338 },
1339}
1340
1341#[derive(Clone, Copy)]
1342struct SplitSide<'a> {
1343 line: &'a PatchLine,
1344 index: usize,
1345}
1346
1347type ChangedPair<'a> = (Option<&'a SplitSide<'a>>, Option<&'a SplitSide<'a>>);
1348
1349fn pair_changed_block<'a>(
1350 removed: &'a [SplitSide<'a>],
1351 added: &'a [SplitSide<'a>],
1352) -> Vec<ChangedPair<'a>> {
1353 let old: Vec<&str> = removed.iter().map(|side| side.line.text.as_ref()).collect();
1354 let new: Vec<&str> = added.iter().map(|side| side.line.text.as_ref()).collect();
1355 let mut pairs = Vec::new();
1356 let mut align = |old_range: Range<usize>, new_range: Range<usize>| {
1357 let paired = old_range.len().min(new_range.len());
1358 for offset in 0..paired {
1359 pairs.push((
1360 Some(&removed[old_range.start + offset]),
1361 Some(&added[new_range.start + offset]),
1362 ));
1363 }
1364 pairs.extend(
1365 removed[old_range.start + paired..old_range.end]
1366 .iter()
1367 .map(|side| (Some(side), None)),
1368 );
1369 pairs.extend(
1370 added[new_range.start + paired..new_range.end]
1371 .iter()
1372 .map(|side| (None, Some(side))),
1373 );
1374 };
1375 for op in TextDiff::from_slices(&old, &new).ops() {
1376 match *op {
1377 DiffOp::Equal {
1378 old_index,
1379 new_index,
1380 len,
1381 } => align(old_index..old_index + len, new_index..new_index + len),
1382 DiffOp::Delete {
1383 old_index, old_len, ..
1384 } => align(old_index..old_index + old_len, 0..0),
1385 DiffOp::Insert {
1386 new_index, new_len, ..
1387 } => align(0..0, new_index..new_index + new_len),
1388 DiffOp::Replace {
1389 old_index,
1390 old_len,
1391 new_index,
1392 new_len,
1393 } => align(
1394 old_index..old_index + old_len,
1395 new_index..new_index + new_len,
1396 ),
1397 }
1398 }
1399 pairs
1400}
1401
1402#[cfg(test)]
1403mod tests {
1404 use super::*;
1405 use crate::{FileDiff, Hunk, ModeChange, SourceUnavailable, testing::DocumentBuilder};
1406
1407 fn document() -> Arc<DiffDocument> {
1408 Arc::new(DiffDocument {
1409 repo_root: "/repo".into(),
1410 files: vec![
1411 FileDiff::from_texts(
1412 "src/a.rs",
1413 "same\none\nkeep\ntwo\n",
1414 "same\nONE\nkeep\nTWO\nextra\n",
1415 )
1416 .unwrap(),
1417 ],
1418 })
1419 }
1420
1421 fn presentation(view_mode: ViewMode) -> DiffPresentation {
1422 DiffPresentation::new(
1423 document(),
1424 PresentationOptions {
1425 view_mode,
1426 ..PresentationOptions::default()
1427 },
1428 )
1429 }
1430
1431 #[test]
1432 fn hunk_sequences_expose_bounded_side_lines_for_patch_only_cells() {
1433 let presentation = presentation(ViewMode::Unified);
1434 let (row, cell) = (0..presentation.row_count())
1435 .find_map(|index| {
1436 let row = presentation.row(index)?;
1437 let cell = row.cell(DiffSide::New)?;
1438 (cell.text.as_ref() == "ONE").then_some((row, cell))
1439 })
1440 .unwrap();
1441 let sequence = presentation.hunk_sequence(row, cell).unwrap();
1442 let lines: Vec<&str> = sequence.lines().collect();
1443 assert_eq!(lines, ["same", "ONE", "keep", "TWO", "extra"]);
1444 assert_eq!(sequence.target_line, 1);
1445 assert_eq!(sequence.path, "src/a.rs");
1446 assert_eq!(
1447 sequence.id,
1448 SourceSequenceId::from_lines(lines.iter().copied())
1449 );
1450
1451 let big = DocumentBuilder::new()
1452 .generated("src/big.rs", MAX_HUNK_SEQUENCE_LINES + 1)
1453 .build();
1454 let presentation = DiffPresentation::new(big, PresentationOptions::default());
1455 let row = (0..presentation.row_count())
1456 .filter_map(|index| presentation.row(index))
1457 .find(|row| row.kind == RowKind::Code)
1458 .unwrap();
1459 let cell = row.primary_cell().unwrap();
1460 assert!(
1461 presentation.hunk_sequence(row, cell).is_none(),
1462 "oversized hunks degrade to per-line highlighting"
1463 );
1464 }
1465
1466 #[test]
1467 fn view_modes_resolve_and_cycle() {
1468 assert_eq!(ViewMode::Auto.resolve(true), Layout::Split);
1469 assert_eq!(ViewMode::Auto.resolve(false), Layout::Unified);
1470 assert_eq!(ViewMode::Unified.resolve(true), Layout::Unified);
1471 assert_eq!(ViewMode::Split.next().next(), ViewMode::Unified);
1472 }
1473
1474 #[test]
1475 fn random_access_ranges_are_clamped() {
1476 let presentation = DiffPresentation::new(document(), PresentationOptions::default());
1477 assert_eq!(presentation.rows(usize::MAX..usize::MAX).len(), 0);
1478 assert_eq!(
1479 presentation.file_range(0).unwrap(),
1480 0..presentation.row_count()
1481 );
1482 assert!(presentation.hunk_range(0, 0).is_some());
1483 assert_eq!(presentation.language_at(1), "rs");
1484 }
1485
1486 #[test]
1487 fn split_pairs_equal_lines_inside_changed_blocks() {
1488 let first = presentation(ViewMode::Split);
1489 let second = presentation(ViewMode::Split);
1490 let ids = |value: &DiffPresentation| {
1491 value
1492 .rows(0..value.row_count())
1493 .iter()
1494 .map(|row| row.id)
1495 .collect::<Vec<_>>()
1496 };
1497 assert_eq!(ids(&first), ids(&second));
1498 assert!(first.rows(0..first.row_count()).iter().any(|row| {
1499 row.left.as_ref().is_some_and(|c| c.text.as_ref() == "keep")
1500 && row
1501 .right
1502 .as_ref()
1503 .is_some_and(|c| c.text.as_ref() == "keep")
1504 }));
1505 }
1506
1507 #[test]
1508 fn unified_and_split_expose_all_code_anchors() {
1509 let unified = presentation(ViewMode::Unified);
1510 let split = presentation(ViewMode::Split);
1511 let commentable_cells = |value: &DiffPresentation| {
1512 value
1513 .rows(0..value.row_count())
1514 .iter()
1515 .flat_map(PresentedRow::sources)
1516 .count()
1517 };
1518 assert!(split.row_count() <= unified.row_count());
1519 assert!(commentable_cells(&split) >= commentable_cells(&unified));
1520 }
1521
1522 #[test]
1523 fn anchors_are_resolved_on_demand_and_match_the_document() {
1524 let presentation = presentation(ViewMode::Unified);
1525 let index = presentation
1526 .file_range(0)
1527 .unwrap()
1528 .find(|index| presentation.is_commentable(*index))
1529 .unwrap();
1530 let row = presentation.row(index).unwrap();
1531 let cell = row.primary_cell().unwrap();
1532 let source = cell.patch_source.unwrap();
1533 let anchor = presentation.cell_anchor(row, cell).unwrap();
1534 assert_eq!(
1535 anchor,
1536 LineAnchor::for_line(
1537 &presentation.document().files[0],
1538 source.side,
1539 source.hunk_index,
1540 source.line_index
1541 )
1542 .unwrap()
1543 );
1544 assert_eq!(
1545 presentation.anchor_at(index, source.side),
1546 Some(anchor.clone())
1547 );
1548 assert!(presentation.row_shows_anchor(row, &anchor));
1549 assert_eq!(presentation.row_showing_anchor(&anchor), Some(index));
1550 }
1551
1552 #[test]
1553 fn anchors_do_not_match_rows_from_other_files() {
1554 let document = Arc::new(DiffDocument {
1555 repo_root: "/repo".into(),
1556 files: vec![
1557 FileDiff::from_texts("a.rs", "old\n", "new\n").unwrap(),
1558 FileDiff::from_texts("b.rs", "old\n", "new\n").unwrap(),
1559 ],
1560 });
1561 let presentation = DiffPresentation::new(document, PresentationOptions::default());
1562 let first = presentation
1563 .file_range(0)
1564 .unwrap()
1565 .find(|index| presentation.is_commentable(*index))
1566 .unwrap();
1567 let second = presentation
1568 .file_range(1)
1569 .unwrap()
1570 .find(|index| presentation.is_commentable(*index))
1571 .unwrap();
1572 let anchor = presentation.anchor_at(first, DiffSide::New).unwrap();
1573 assert!(presentation.row_shows_anchor(presentation.row(first).unwrap(), &anchor));
1574 assert!(!presentation.row_shows_anchor(presentation.row(second).unwrap(), &anchor));
1575 }
1576
1577 #[test]
1578 fn cells_are_addressable_by_side() {
1579 let split = presentation(ViewMode::Split);
1580 let row = split
1581 .rows(0..split.row_count())
1582 .iter()
1583 .find(|row| row.left.is_some() && row.right.is_some())
1584 .unwrap();
1585 assert_eq!(row.cell(DiffSide::New), row.right.as_ref());
1586 assert_eq!(row.preferred_cell(DiffSide::Old), row.left.as_ref());
1587 assert_eq!(row.cells().count(), 2);
1588 assert!(row.is_commentable());
1589 }
1590
1591 #[test]
1592 fn gap_geometry_covers_leading_middle_and_trailing_intervals() {
1593 let path = RepoPath::new("a.rs").unwrap();
1594 let file = FileDiff {
1595 old_path: Some(path.clone()),
1596 path,
1597 status: FileStatus::Modified,
1598 staged: crate::StageState::Unstaged,
1599 hunks: vec![
1600 Hunk {
1601 header: "@@ -3,2 +3,2 @@".into(),
1602 function_context: None,
1603 old_start: 3,
1604 old_count: 2,
1605 new_start: 3,
1606 new_count: 2,
1607 lines: Vec::new(),
1608 },
1609 Hunk {
1610 header: "@@ -8 +8 @@".into(),
1611 function_context: None,
1612 old_start: 8,
1613 old_count: 1,
1614 new_start: 8,
1615 new_count: 1,
1616 lines: Vec::new(),
1617 },
1618 ],
1619 binary: false,
1620 mode: None,
1621 no_newline_at_end: false,
1622 omitted_bytes: None,
1623 old_source: Err(SourceUnavailable::NotCaptured),
1624 new_source: Err(SourceUnavailable::NotCaptured),
1625 };
1626 assert_eq!(
1627 gaps_for_file(&file, 10, 10),
1628 vec![
1629 GapInterval {
1630 old: 1..3,
1631 new: 1..3,
1632 },
1633 GapInterval {
1634 old: 5..8,
1635 new: 5..8,
1636 },
1637 GapInterval {
1638 old: 9..11,
1639 new: 9..11,
1640 },
1641 ]
1642 );
1643 }
1644
1645 #[test]
1646 fn zero_count_hunks_use_the_next_real_line_as_the_gap_boundary() {
1647 let path = RepoPath::new("a.rs").unwrap();
1648 let file = FileDiff {
1649 old_path: Some(path.clone()),
1650 path,
1651 status: FileStatus::Modified,
1652 staged: crate::StageState::Unstaged,
1653 hunks: vec![Hunk {
1654 header: "@@ -2,0 +3 @@".into(),
1655 function_context: None,
1656 old_start: 2,
1657 old_count: 0,
1658 new_start: 3,
1659 new_count: 1,
1660 lines: Vec::new(),
1661 }],
1662 binary: false,
1663 mode: None,
1664 no_newline_at_end: false,
1665 omitted_bytes: None,
1666 old_source: Err(SourceUnavailable::NotCaptured),
1667 new_source: Err(SourceUnavailable::NotCaptured),
1668 };
1669 assert_eq!(
1670 gaps_for_file(&file, 5, 6),
1671 vec![
1672 GapInterval {
1673 old: 1..3,
1674 new: 1..3,
1675 },
1676 GapInterval {
1677 old: 3..6,
1678 new: 4..7,
1679 },
1680 ]
1681 );
1682 }
1683
1684 #[test]
1685 fn empty_projection_is_row_for_row_identical_to_the_compatibility_path() {
1686 for view_mode in [ViewMode::Auto, ViewMode::Unified, ViewMode::Split] {
1687 let options = PresentationOptions {
1688 view_mode,
1689 ..PresentationOptions::default()
1690 };
1691 let expected = DiffPresentation::new(document(), options);
1692 let actual = DiffPresentation::with_projection(
1693 document(),
1694 options,
1695 &ContentProjection::default(),
1696 );
1697 assert_eq!(
1698 expected.rows(0..expected.row_count()),
1699 actual.rows(0..actual.row_count())
1700 );
1701 }
1702 }
1703
1704 #[test]
1705 #[allow(clippy::format_collect)]
1706 fn split_full_file_projection_orders_both_sources_and_has_stable_row_ids() {
1707 let old = (1..=60)
1708 .map(|line| format!("line {line}\n"))
1709 .collect::<String>();
1710 let new = old.replace("line 31\n", "changed 31\n");
1711 let document = DocumentBuilder::new()
1712 .changed_with_hunk_window("a.rs", &old, &new, 28..=34)
1713 .build();
1714 let path = document.files[0].path.clone();
1715 let mut projection = ContentProjection::default();
1716 projection.set_full_file(path, true);
1717 let options = PresentationOptions {
1718 view_mode: ViewMode::Split,
1719 ..PresentationOptions::default()
1720 };
1721 let first = DiffPresentation::with_projection(document.clone(), options, &projection);
1722 let second = DiffPresentation::with_projection(document, options, &projection);
1723 let source_lines = |presentation: &DiffPresentation, side| {
1724 presentation
1725 .rows(0..presentation.row_count())
1726 .iter()
1727 .filter_map(|row| row.cell(side)?.source_line)
1728 .map(|source| source.line_number)
1729 .collect::<Vec<_>>()
1730 };
1731 assert_eq!(
1732 source_lines(&first, DiffSide::Old),
1733 (1..=60).collect::<Vec<_>>()
1734 );
1735 assert_eq!(
1736 source_lines(&first, DiffSide::New),
1737 (1..=60).collect::<Vec<_>>()
1738 );
1739 assert_eq!(
1740 first
1741 .rows(0..first.row_count())
1742 .iter()
1743 .map(|row| row.id)
1744 .collect::<Vec<_>>(),
1745 second
1746 .rows(0..second.row_count())
1747 .iter()
1748 .map(|row| row.id)
1749 .collect::<Vec<_>>()
1750 );
1751 assert!(first.rows(0..first.row_count()).iter().any(|row| {
1752 row.kind == RowKind::ExpandedContext
1753 && !row.is_commentable()
1754 && row.left.is_some()
1755 && row.right.is_some()
1756 }));
1757 }
1758
1759 #[test]
1760 fn mode_only_full_file_intent_projects_unavailable_and_complete_source() {
1761 let path = RepoPath::new("script.sh").unwrap();
1762 let file = FileDiff {
1763 old_path: Some(path.clone()),
1764 path: path.clone(),
1765 status: FileStatus::Modified,
1766 staged: crate::StageState::Unstaged,
1767 hunks: Vec::new(),
1768 binary: false,
1769 mode: Some(ModeChange {
1770 old: Some("100644".into()),
1771 new: Some("100755".into()),
1772 }),
1773 no_newline_at_end: false,
1774 omitted_bytes: None,
1775 old_source: Err(SourceUnavailable::Absent),
1776 new_source: Err(SourceUnavailable::Absent),
1777 };
1778 let document = |file: FileDiff| {
1779 Arc::new(DiffDocument {
1780 repo_root: "/repo".into(),
1781 files: vec![file],
1782 })
1783 };
1784 let mut projection = ContentProjection::default();
1785 projection.set_full_file(path, true);
1786 let absent = DiffPresentation::with_projection(
1787 document(file.clone()),
1788 PresentationOptions::default(),
1789 &projection,
1790 );
1791 let absent_gap = absent
1792 .rows(0..absent.row_count())
1793 .iter()
1794 .position(|row| row.kind == RowKind::ExpandGap)
1795 .unwrap();
1796 assert_eq!(
1797 absent.gap_info(absent_gap).unwrap().unavailable,
1798 Some(SourceUnavailable::Absent)
1799 );
1800
1801 let unavailable = DiffPresentation::with_projection(
1802 document(FileDiff {
1803 new_source: Err(SourceUnavailable::Binary),
1804 ..file.clone()
1805 }),
1806 PresentationOptions::default(),
1807 &projection,
1808 );
1809 let gap = unavailable
1810 .rows(0..unavailable.row_count())
1811 .iter()
1812 .position(|row| row.kind == RowKind::ExpandGap)
1813 .unwrap();
1814 assert_eq!(
1815 unavailable.gap_info(gap).unwrap().unavailable,
1816 Some(SourceUnavailable::Binary)
1817 );
1818
1819 let loaded = DiffPresentation::with_projection(
1820 document(FileDiff {
1821 new_source: Ok(Arc::new(
1822 SourceDocument::try_from_text("#!/bin/sh\necho ok\n").unwrap(),
1823 )),
1824 ..file
1825 }),
1826 PresentationOptions::default(),
1827 &projection,
1828 );
1829 assert_eq!(
1830 loaded
1831 .rows(0..loaded.row_count())
1832 .iter()
1833 .filter(|row| row.kind == RowKind::ExpandedContext)
1834 .count(),
1835 2
1836 );
1837 }
1838
1839 #[test]
1840 fn presentation_shares_document_text_instead_of_copying_it() {
1841 let document = document();
1842 let presentation = DiffPresentation::new(document.clone(), PresentationOptions::default());
1843 let line = &document.files[0].hunks[0].lines[0];
1844 let cell = presentation
1845 .rows(0..presentation.row_count())
1846 .iter()
1847 .find_map(|row| row.primary_cell().filter(|c| c.text == line.text))
1848 .unwrap();
1849 assert!(Arc::ptr_eq(&cell.text, &line.text));
1850 }
1851}