Skip to main content

clankerdiff_core/
presentation.rs

1//! Framework-neutral, random-access unified and split row presentation.
2
3use 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
18/// Largest hunk (in total patch lines) served as a fallback syntax sequence.
19/// Larger hunks degrade to per-line highlighting so patch-only rendering work
20/// stays bounded by the viewport rather than the document.
21pub const MAX_HUNK_SEQUENCE_LINES: usize = 512;
22
23/// Requested diff layout.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub enum ViewMode {
26    /// Let an adapter choose based on its own width units.
27    #[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    /// One code column.
56    Unified,
57    /// Old and new columns aligned side by side.
58    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/// Options used while indexing presentation rows.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
70pub struct PresentationOptions {
71    /// Requested layout mode.
72    pub view_mode: ViewMode,
73    /// How `Auto` is resolved by the calling adapter.
74    pub split_when_auto: bool,
75    /// Whether each file begins with a file header row.
76    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    /// Resolves the requested mode to a concrete layout.
91    #[must_use]
92    pub const fn layout(self) -> Layout {
93        self.view_mode.resolve(self.split_when_auto)
94    }
95}
96
97/// Semantic row type.
98#[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/// Stable identity of a gap before, between, or after hunks.
109#[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/// User-controlled revealed ranges at both hunk-adjacent edges of a gap.
116#[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/// One-based half-open source intervals for both sides of a gap.
123#[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/// Adapter-facing state for an expansion affordance.
142#[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    /// Returns the deterministic renderer-independent gap label.
151    #[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/// Reveal state used to project a document over the sources it carries.
164#[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
191/// Renderer-neutral description of the hunk-side line sequence containing a
192/// patch cell, used to keep multiline syntax context for patch-only files.
193pub struct HunkSequence<'a> {
194    /// Content-derived, snapshot-local cache identity for this side's lines.
195    pub id: SourceSequenceId,
196    /// Side-specific repository path usable as a syntax language hint.
197    pub path: &'a str,
198    /// Zero-based index of the cell's line within [`Self::lines`].
199    pub target_line: usize,
200    hunk: &'a Hunk,
201    side: DiffSide,
202}
203
204impl HunkSequence<'_> {
205    /// This side's hunk lines in source order.
206    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/// Stable identity for a presented row.
225#[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/// One side of a presented row.
236#[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/// One cheap renderer-neutral row descriptor.
245#[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/// Eager row indexes and cheap descriptors. Syntax and frontend widgets are not
317/// constructed here.
318#[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    /// Lazily fingerprinted per hunk so presentations that never fall back to
329    /// hunk-sequence highlighting skip hashing the document.
330    sequence_ids: Vec<Vec<OnceLock<[SourceSequenceId; 2]>>>,
331}
332
333impl DiffPresentation {
334    /// Indexes a document once for O(1) row lookup and slicing.
335    #[must_use]
336    pub fn new(document: Arc<DiffDocument>, options: PresentationOptions) -> Self {
337        Self::with_projection(document, options, &ContentProjection::default())
338    }
339
340    /// Builds a windowed projection over the document's complete-file sources.
341    #[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    /// Returns the retained immutable snapshot.
470    #[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    /// Total number of rows.
481    #[must_use]
482    pub fn row_count(&self) -> usize {
483        self.rows.len()
484    }
485
486    /// Returns one row in O(1).
487    #[must_use]
488    pub fn row(&self, index: usize) -> Option<&PresentedRow> {
489        self.rows.get(index)
490    }
491
492    /// Returns a clamped visible row slice without allocating.
493    #[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    /// Returns the row range occupied by a file.
501    #[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    /// Returns the row range occupied by a hunk, including its header.
507    #[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    /// Returns the immutable complete source containing `cell`.
526    #[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    /// Returns the side-specific repository path used as a syntax language hint.
538    #[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    /// Describes the hunk-side line sequence containing a patch cell so hosts
551    /// can keep multiline syntax context when no complete source exists.
552    #[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    /// Repository path of the file a row belongs to, usable as a language hint.
598    #[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    /// Returns the presentation row displaying an anchor in O(1) after its file
614    /// has been located.
615    #[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/// Computes leading, between-hunk, and trailing one-based source intervals.
734#[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}