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