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