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