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