Skip to main content

lattice_diff/
filler.rs

1//! D.4.c (2026-05-29) / D.6.b (2026-05-31): hunk-alignment
2//! filler rows.
3//!
4//! `FillerRowProvider` is a [`VirtualRowProvider`] that
5//! emits blank virtual rows on whichever side of a
6//! side-by-side diff is shorter for a given hunk, so the
7//! hunks align visually across all panes.
8//!
9//! Composes with D.4.a ([`crate::pane_group`]), D.4.b
10//! ([`crate::pane_group`]), and D.0a virtual rows
11//! ([`lattice_cells::VirtualRowProvider`]); consumed by
12//! D.4.d (`:diffsplit` / `:diffthis` two-way) and D.6.c
13//! (three-way `:diffsplit base remote`).
14//!
15//! See `docs/dev/architecture/diff-system.md` §5.2.
16//!
17//! ## One provider per pane
18//!
19//! A side-by-side session has N panes (2 in two-way, 3 in
20//! three-way), each showing a different buffer. Filler
21//! rows for a pane depend on that buffer's row coordinates;
22//! the virtual-rows worker is per-document
23//! (`docs/dev/architecture/virtual-rows.md` §1), so we
24//! register one provider per pane, each parameterised by
25//! [`Side`].
26//!
27//! ## Algorithm
28//!
29//! For each hunk in the session's published `HunkIndex`,
30//! given the pane's [`Side`]:
31//!
32//! - Let `lens[i] = hunk.ranges[i].len()` for each
33//!   participating side.
34//! - Let `max_len = max(lens)`.
35//! - This pane's `this_len = lens[side.pane_index()]`.
36//! - Emit `max_len - this_len` filler rows on this pane
37//!   (zero when this pane is already the longest).
38//! - **Anchor:** if `this_len == 0` (this pane has no
39//!   lines in this hunk), anchor at `range.start` with
40//!   [`AnchorPosition::Above`] — fillers paint
41//!   immediately before the insertion point. If
42//!   `this_len > 0`, anchor at `range.end - 1` with
43//!   [`AnchorPosition::Below`] — fillers paint after the
44//!   last changed line so the rest of the buffer aligns.
45//!
46//! Conflict hunks: in two-way (`ranges.len() == 2`) they
47//! shouldn't be emitted by `compute_two_way`, so the
48//! provider defensively skips them. In three-way
49//! (`ranges.len() >= 3`) Conflict hunks have the same
50//! row-geometry as Change for alignment purposes, so they
51//! contribute fillers normally.
52
53use std::sync::Arc;
54
55use crate::{HunkIndex, HunkKind};
56use lattice_cells::{AnchorPosition, Cell, ProviderId, VirtualRow, VirtualRowProvider};
57use lattice_core::BufferId;
58
59use crate::subsystem::DiffSession;
60
61/// Which pane of a side-by-side session this provider
62/// emits rows for. Each variant maps to a slot index in
63/// `Hunk::ranges`:
64/// - `Baseline` ⇒ `ranges[0]` (also the base / common
65///   ancestor in three-way merges)
66/// - `Current` ⇒ `ranges[1]` (also "local" in three-way)
67/// - `Remote` ⇒ `ranges[2]` (D.6.b three-way only)
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum Side {
70    Baseline,
71    Current,
72    /// D.6.b (2026-05-31): the third participant of a
73    /// three-way merge.
74    Remote,
75}
76
77impl Side {
78    fn namespace_bit(self) -> u64 {
79        match self {
80            Side::Baseline => DIFF_FILLER_BASELINE_NAMESPACE,
81            Side::Current => DIFF_FILLER_CURRENT_NAMESPACE,
82            Side::Remote => DIFF_FILLER_REMOTE_NAMESPACE,
83        }
84    }
85
86    /// D.6.b: slot index into `Hunk::ranges` for this side.
87    /// 0 = baseline / base, 1 = current / local, 2 = remote.
88    pub fn pane_index(self) -> usize {
89        match self {
90            Side::Baseline => 0,
91            Side::Current => 1,
92            Side::Remote => 2,
93        }
94    }
95}
96
97/// D.4.c: deterministic [`ProviderId`] for the filler-row
98/// provider on `buffer_id` and `side`. Exposed as a free
99/// function so `:diffoff` / D.4.d teardown can unregister
100/// without holding the provider — the namespace encoding
101/// makes the id reproducible.
102pub fn diff_filler_provider_id(buffer_id: BufferId, side: Side) -> ProviderId {
103    side.namespace_bit() | u64::from(buffer_id.0)
104}
105
106/// Namespace prefix for the baseline-side filler provider.
107/// Distinct from `diff::overlay::DIFF_OVERLAY_PROVIDER_NAMESPACE`
108/// (`0xD1FF_0000_0000_0000`) so the two coexist in the
109/// global provider registry without collision.
110const DIFF_FILLER_BASELINE_NAMESPACE: u64 = 0xD1FF_0001_0000_0000;
111
112/// Namespace prefix for the current-side filler provider.
113const DIFF_FILLER_CURRENT_NAMESPACE: u64 = 0xD1FF_0002_0000_0000;
114
115/// D.6.b: namespace prefix for the remote-side filler
116/// provider in a three-way merge.
117const DIFF_FILLER_REMOTE_NAMESPACE: u64 = 0xD1FF_0003_0000_0000;
118
119/// D.4.c: one provider per `(session, side)` pair.
120///
121/// `collect()` is synchronous and pure — it reads the
122/// session's published `HunkIndex` via the lock-free
123/// `current_hunks()` accessor and translates each hunk to
124/// zero or more `VirtualRow`s. No background task is
125/// needed (the work is O(hunks) and trivial); cf. the
126/// inline diff overlay's `DiffOverlayRefreshTask` which
127/// needs the off-thread render because deletion-block
128/// content requires baseline-rope reads + tree-sitter
129/// highlight.
130#[derive(Debug)]
131pub struct FillerRowProvider {
132    session: Arc<DiffSession>,
133    side: Side,
134}
135
136impl FillerRowProvider {
137    pub fn new(session: Arc<DiffSession>, side: Side) -> Self {
138        Self { session, side }
139    }
140
141    pub fn side(&self) -> Side {
142        self.side
143    }
144}
145
146impl VirtualRowProvider for FillerRowProvider {
147    fn id(&self) -> ProviderId {
148        diff_filler_provider_id(self.session.buffer_id(), self.side)
149    }
150
151    fn version(&self) -> u64 {
152        // Fold session revision with side so the worker's
153        // fingerprint distinguishes sides even before any
154        // hunks land. XOR with a small per-side constant
155        // ensures the version differs across sides at
156        // revision 0.
157        let rev = self.session.current_hunks().revision;
158        let side_salt: u64 = match self.side {
159            Side::Baseline => 0,
160            Side::Current => 1,
161            Side::Remote => 2,
162        };
163        rev ^ side_salt
164    }
165
166    fn collect(&self) -> Vec<VirtualRow> {
167        let hunks = self.session.current_hunks();
168        compute_filler_rows(&hunks, self.side)
169    }
170}
171
172/// Pure function from `(HunkIndex, side)` to filler rows.
173/// Exposed for direct unit testing without round-tripping
174/// through a `DiffSession`.
175///
176/// **Two-way vs three-way.** Per-hunk shape is inferred
177/// from `hunk.ranges.len()`:
178/// - `len() == 2` (two-way): this pane's len vs the *one*
179///   other side's len. Conflict hunks shouldn't occur
180///   (`compute_two_way` doesn't emit them) — skipped
181///   defensively.
182/// - `len() >= 3` (three-way): this pane's len vs the
183///   *max* of all participating sides' lens. Conflict
184///   hunks contribute filler the same way Change hunks do
185///   — the conflict is about content, not geometry.
186///
187/// If the requested `side`'s slot isn't present in the
188/// hunk (e.g. `Side::Remote` on a two-way hunk), the hunk
189/// is skipped — the provider degrades gracefully when a
190/// session shape doesn't match the hunk shape.
191pub fn compute_filler_rows(index: &HunkIndex, side: Side) -> Vec<VirtualRow> {
192    let mut rows = Vec::new();
193    let pane_idx = side.pane_index();
194    for hunk in &index.hunks {
195        let participating = hunk.ranges.len();
196        if participating < 2 {
197            // Malformed: need at least 2 ranges. Skip
198            // rather than panic.
199            continue;
200        }
201        let is_three_way_hunk = participating >= 3;
202        if matches!(hunk.kind, HunkKind::Conflict) && !is_three_way_hunk {
203            // Defensive: two-way Conflict shouldn't exist.
204            // Skip to avoid emitting filler against an
205            // engine output the two-way axis won't produce.
206            continue;
207        }
208        let Some(this_range) = hunk.ranges.get(pane_idx).copied() else {
209            // This side's slot isn't in the hunk (e.g.
210            // Side::Remote on a 2-way hunk). Skip.
211            continue;
212        };
213        let this_len = this_range.end.saturating_sub(this_range.start);
214        let target_len = hunk
215            .ranges
216            .iter()
217            .map(|r| r.end.saturating_sub(r.start))
218            .max()
219            .unwrap_or(0);
220        if target_len <= this_len {
221            // This pane is already the longest (or tied)
222            // for this hunk — no fillers needed.
223            continue;
224        }
225        let filler_count = target_len - this_len;
226        let (anchor_line, position) = if this_len == 0 {
227            // Empty range on this side (Add hunk's
228            // baseline, Remove hunk's current, or a
229            // three-way side that didn't touch the base
230            // region). Anchor at the insertion-point line
231            // with `Above`.
232            (this_range.start, AnchorPosition::Above)
233        } else {
234            // Non-empty range — anchor at the last
235            // changed line, position `Below`.
236            (this_range.end - 1, AnchorPosition::Below)
237        };
238        let blank_cells: Arc<[Cell]> = Arc::from([Cell::BLANK]);
239        for _ in 0..filler_count {
240            rows.push(VirtualRow {
241                media: None,
242                anchor_line,
243                position,
244                cells: blank_cells.clone(),
245                height: 1,
246                // D.6.i: filler rows paint with no backdrop —
247                // they're visual padding for side-by-side
248                // alignment, not deleted content.
249                kind: lattice_cells::VirtualRowKind::Filler,
250                bg: None,
251                scales: None,
252                gutter_line: None,
253                gutter_fg: None,
254            });
255        }
256    }
257    rows
258}
259
260#[cfg(test)]
261mod tests {
262    #![allow(clippy::unwrap_used)]
263    use super::*;
264    use crate::{DiffAlgorithm, Hunk, LineRange};
265    use smallvec::smallvec;
266
267    fn hunk(kind: HunkKind, baseline: LineRange, current: LineRange) -> Hunk {
268        Hunk {
269            kind,
270            ranges: smallvec![baseline, current],
271            refine: Default::default(),
272        }
273    }
274
275    fn idx(hunks: Vec<Hunk>) -> HunkIndex {
276        HunkIndex {
277            hunks,
278            algorithm: DiffAlgorithm::Histogram,
279            revision: 1,
280        }
281    }
282
283    #[test]
284    fn empty_index_emits_no_fillers_on_either_side() {
285        let i = HunkIndex::empty(DiffAlgorithm::Histogram);
286        assert!(compute_filler_rows(&i, Side::Baseline).is_empty());
287        assert!(compute_filler_rows(&i, Side::Current).is_empty());
288    }
289
290    #[test]
291    fn add_hunk_emits_fillers_on_baseline_only() {
292        // Add 3 lines on current at position 5; baseline
293        // has 0 lines there. Baseline needs 3 filler rows
294        // above its insertion line (5).
295        let i = idx(vec![hunk(
296            HunkKind::Add,
297            LineRange::new(5, 5),
298            LineRange::new(5, 8),
299        )]);
300        let baseline_fillers = compute_filler_rows(&i, Side::Baseline);
301        let current_fillers = compute_filler_rows(&i, Side::Current);
302        assert_eq!(baseline_fillers.len(), 3);
303        assert!(current_fillers.is_empty());
304        for row in &baseline_fillers {
305            assert_eq!(row.anchor_line, 5);
306            assert_eq!(row.position, AnchorPosition::Above);
307            assert_eq!(row.height, 1);
308        }
309    }
310
311    #[test]
312    fn remove_hunk_emits_fillers_on_current_only() {
313        // Remove 3 lines from baseline at position 5;
314        // current has nothing.
315        let i = idx(vec![hunk(
316            HunkKind::Remove,
317            LineRange::new(5, 8),
318            LineRange::new(5, 5),
319        )]);
320        let baseline_fillers = compute_filler_rows(&i, Side::Baseline);
321        let current_fillers = compute_filler_rows(&i, Side::Current);
322        assert!(baseline_fillers.is_empty());
323        assert_eq!(current_fillers.len(), 3);
324        for row in &current_fillers {
325            assert_eq!(row.anchor_line, 5);
326            assert_eq!(row.position, AnchorPosition::Above);
327        }
328    }
329
330    #[test]
331    fn change_hunk_baseline_longer_emits_fillers_on_current() {
332        // baseline [10, 15) = 5 lines; current [10, 12) = 2 lines.
333        // Current is shorter by 3 ⇒ 3 fillers on current,
334        // anchored at cr.end-1 = 11 with Below.
335        let i = idx(vec![hunk(
336            HunkKind::Change,
337            LineRange::new(10, 15),
338            LineRange::new(10, 12),
339        )]);
340        let baseline_fillers = compute_filler_rows(&i, Side::Baseline);
341        let current_fillers = compute_filler_rows(&i, Side::Current);
342        assert!(baseline_fillers.is_empty());
343        assert_eq!(current_fillers.len(), 3);
344        for row in &current_fillers {
345            assert_eq!(row.anchor_line, 11);
346            assert_eq!(row.position, AnchorPosition::Below);
347        }
348    }
349
350    #[test]
351    fn change_hunk_current_longer_emits_fillers_on_baseline() {
352        // baseline [10, 12) = 2 lines; current [10, 15) = 5 lines.
353        // Baseline is shorter by 3 ⇒ 3 fillers on baseline,
354        // anchored at br.end-1 = 11 with Below.
355        let i = idx(vec![hunk(
356            HunkKind::Change,
357            LineRange::new(10, 12),
358            LineRange::new(10, 15),
359        )]);
360        let baseline_fillers = compute_filler_rows(&i, Side::Baseline);
361        let current_fillers = compute_filler_rows(&i, Side::Current);
362        assert_eq!(baseline_fillers.len(), 3);
363        assert!(current_fillers.is_empty());
364        for row in &baseline_fillers {
365            assert_eq!(row.anchor_line, 11);
366            assert_eq!(row.position, AnchorPosition::Below);
367        }
368    }
369
370    #[test]
371    fn change_hunk_equal_lengths_emits_no_fillers() {
372        let i = idx(vec![hunk(
373            HunkKind::Change,
374            LineRange::new(10, 13),
375            LineRange::new(10, 13),
376        )]);
377        assert!(compute_filler_rows(&i, Side::Baseline).is_empty());
378        assert!(compute_filler_rows(&i, Side::Current).is_empty());
379    }
380
381    #[test]
382    fn multiple_hunks_accumulate_independently() {
383        // Add (+2 on current) then Remove (+3 on baseline).
384        // Baseline-side fillers: 2 from the Add only.
385        // Current-side fillers: 3 from the Remove only.
386        let i = idx(vec![
387            hunk(HunkKind::Add, LineRange::new(5, 5), LineRange::new(5, 7)),
388            hunk(
389                HunkKind::Remove,
390                LineRange::new(20, 23),
391                LineRange::new(22, 22),
392            ),
393        ]);
394        let baseline_fillers = compute_filler_rows(&i, Side::Baseline);
395        let current_fillers = compute_filler_rows(&i, Side::Current);
396        assert_eq!(baseline_fillers.len(), 2);
397        assert_eq!(current_fillers.len(), 3);
398        // Baseline fillers anchored at the Add insertion
399        // point (5), Above.
400        for row in &baseline_fillers {
401            assert_eq!(row.anchor_line, 5);
402            assert_eq!(row.position, AnchorPosition::Above);
403        }
404        // Current fillers anchored at the Remove insertion
405        // point on current side (22), Above.
406        for row in &current_fillers {
407            assert_eq!(row.anchor_line, 22);
408            assert_eq!(row.position, AnchorPosition::Above);
409        }
410    }
411
412    #[test]
413    fn conflict_hunks_skipped_in_two_way_provider() {
414        let i = idx(vec![hunk(
415            HunkKind::Conflict,
416            LineRange::new(5, 10),
417            LineRange::new(5, 8),
418        )]);
419        assert!(compute_filler_rows(&i, Side::Baseline).is_empty());
420        assert!(compute_filler_rows(&i, Side::Current).is_empty());
421    }
422
423    #[test]
424    fn malformed_hunk_with_fewer_than_two_ranges_is_skipped() {
425        let i = idx(vec![Hunk {
426            kind: HunkKind::Add,
427            ranges: smallvec![LineRange::new(0, 0)],
428            refine: Default::default(),
429        }]);
430        assert!(compute_filler_rows(&i, Side::Baseline).is_empty());
431        assert!(compute_filler_rows(&i, Side::Current).is_empty());
432    }
433
434    // ── Provider plumbing ─────────────────────────────────
435
436    #[test]
437    fn provider_ids_distinct_per_side() {
438        let bid = BufferId(7);
439        let baseline_id = diff_filler_provider_id(bid, Side::Baseline);
440        let current_id = diff_filler_provider_id(bid, Side::Current);
441        assert_ne!(baseline_id, current_id);
442        // Buffer-id bits visible in the low 32:
443        assert_eq!(baseline_id as u32, bid.0);
444        assert_eq!(current_id as u32, bid.0);
445    }
446
447    #[test]
448    fn provider_id_does_not_collide_with_overlay_namespace() {
449        let bid = BufferId(7);
450        let overlay = crate::overlay::diff_overlay_provider_id(bid);
451        assert_ne!(diff_filler_provider_id(bid, Side::Baseline), overlay);
452        assert_ne!(diff_filler_provider_id(bid, Side::Current), overlay);
453    }
454
455    #[test]
456    fn provider_collect_reads_published_session_hunks() {
457        let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
458        // Empty session ⇒ no fillers.
459        let provider = FillerRowProvider::new(session.clone(), Side::Baseline);
460        assert!(provider.collect().is_empty());
461        // Publish an Add hunk; baseline side now sees fillers.
462        session.publish(Arc::new(idx(vec![hunk(
463            HunkKind::Add,
464            LineRange::new(5, 5),
465            LineRange::new(5, 8),
466        )])));
467        assert_eq!(provider.collect().len(), 3);
468        // Current-side provider sees nothing for the same Add.
469        let other = FillerRowProvider::new(session, Side::Current);
470        assert!(other.collect().is_empty());
471    }
472
473    #[test]
474    fn provider_version_changes_with_session_revision() {
475        let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
476        let p = FillerRowProvider::new(session.clone(), Side::Baseline);
477        let v0 = p.version();
478        session.publish(Arc::new(idx(vec![hunk(
479            HunkKind::Add,
480            LineRange::new(0, 0),
481            LineRange::new(0, 1),
482        )])));
483        let v1 = p.version();
484        assert_ne!(v0, v1);
485    }
486
487    #[test]
488    fn provider_version_differs_across_sides_at_rev_zero() {
489        let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
490        let baseline = FillerRowProvider::new(session.clone(), Side::Baseline);
491        let current = FillerRowProvider::new(session, Side::Current);
492        assert_ne!(
493            baseline.version(),
494            current.version(),
495            "side salt must distinguish baseline / current at rev 0"
496        );
497    }
498
499    // ──────────────────────────────────────────────────────
500    // D.6.b (2026-05-31): three-pane filler rows
501    // ──────────────────────────────────────────────────────
502
503    fn hunk3(kind: HunkKind, base: LineRange, local: LineRange, remote: LineRange) -> Hunk {
504        Hunk {
505            kind,
506            ranges: smallvec![base, local, remote],
507            refine: Default::default(),
508        }
509    }
510
511    #[test]
512    fn three_way_change_aligns_all_three_panes_to_max_length() {
513        // base [10, 13) = 3 lines; local [10, 14) = 4 lines;
514        // remote [10, 16) = 6 lines. max = 6. So:
515        // - base: 6 - 3 = 3 fillers
516        // - local: 6 - 4 = 2 fillers
517        // - remote: 0 fillers
518        let i = idx(vec![hunk3(
519            HunkKind::Change,
520            LineRange::new(10, 13),
521            LineRange::new(10, 14),
522            LineRange::new(10, 16),
523        )]);
524        let base_fillers = compute_filler_rows(&i, Side::Baseline);
525        let local_fillers = compute_filler_rows(&i, Side::Current);
526        let remote_fillers = compute_filler_rows(&i, Side::Remote);
527        assert_eq!(base_fillers.len(), 3, "base shorter by 3 lines");
528        assert_eq!(local_fillers.len(), 2, "local shorter by 2 lines");
529        assert!(remote_fillers.is_empty(), "remote is longest");
530        // Anchor for non-empty range: range.end - 1, Below.
531        for row in &base_fillers {
532            assert_eq!(row.anchor_line, 12);
533            assert_eq!(row.position, AnchorPosition::Below);
534        }
535        for row in &local_fillers {
536            assert_eq!(row.anchor_line, 13);
537            assert_eq!(row.position, AnchorPosition::Below);
538        }
539    }
540
541    #[test]
542    fn three_way_add_on_local_emits_fillers_on_base_and_remote() {
543        // base [10, 10) empty; local [10, 13) = 3 lines;
544        // remote [10, 10) empty. max = 3. base + remote
545        // each need 3 fillers anchored Above at row 10.
546        let i = idx(vec![hunk3(
547            HunkKind::Add,
548            LineRange::new(10, 10),
549            LineRange::new(10, 13),
550            LineRange::new(10, 10),
551        )]);
552        let base_fillers = compute_filler_rows(&i, Side::Baseline);
553        let local_fillers = compute_filler_rows(&i, Side::Current);
554        let remote_fillers = compute_filler_rows(&i, Side::Remote);
555        assert_eq!(base_fillers.len(), 3);
556        assert!(local_fillers.is_empty(), "local is longest");
557        assert_eq!(remote_fillers.len(), 3);
558        for row in base_fillers.iter().chain(remote_fillers.iter()) {
559            assert_eq!(row.anchor_line, 10);
560            assert_eq!(row.position, AnchorPosition::Above);
561        }
562    }
563
564    #[test]
565    fn three_way_conflict_hunk_emits_fillers_for_alignment() {
566        // Three-way Conflict — both local and remote
567        // mutated the base region differently. The
568        // alignment problem is the same as Change; fillers
569        // pad whichever side(s) are shorter.
570        let i = idx(vec![hunk3(
571            HunkKind::Conflict,
572            LineRange::new(10, 12), // 2 lines
573            LineRange::new(10, 14), // 4 lines
574            LineRange::new(10, 15), // 5 lines
575        )]);
576        let base_fillers = compute_filler_rows(&i, Side::Baseline);
577        let local_fillers = compute_filler_rows(&i, Side::Current);
578        let remote_fillers = compute_filler_rows(&i, Side::Remote);
579        assert_eq!(base_fillers.len(), 3, "base shorter by 3");
580        assert_eq!(local_fillers.len(), 1, "local shorter by 1");
581        assert!(remote_fillers.is_empty(), "remote is longest");
582    }
583
584    #[test]
585    fn three_way_equal_lengths_emit_no_fillers() {
586        let i = idx(vec![hunk3(
587            HunkKind::Change,
588            LineRange::new(10, 13),
589            LineRange::new(10, 13),
590            LineRange::new(10, 13),
591        )]);
592        for side in [Side::Baseline, Side::Current, Side::Remote] {
593            assert!(
594                compute_filler_rows(&i, side).is_empty(),
595                "no fillers when all three lengths match ({side:?})"
596            );
597        }
598    }
599
600    #[test]
601    fn two_way_hunk_via_remote_side_is_skipped_not_panic() {
602        // A two-way hunk (ranges.len() == 2) queried for
603        // Side::Remote (pane 2) must skip the hunk
604        // gracefully — the slot doesn't exist.
605        let i = idx(vec![hunk(
606            HunkKind::Add,
607            LineRange::new(5, 5),
608            LineRange::new(5, 8),
609        )]);
610        assert!(
611            compute_filler_rows(&i, Side::Remote).is_empty(),
612            "remote-side filler on a 2-way hunk should be empty, not panic"
613        );
614    }
615
616    #[test]
617    fn three_way_malformed_single_range_skipped() {
618        let i = idx(vec![Hunk {
619            kind: HunkKind::Change,
620            ranges: smallvec![LineRange::new(0, 5)],
621            refine: Default::default(),
622        }]);
623        for side in [Side::Baseline, Side::Current, Side::Remote] {
624            assert!(compute_filler_rows(&i, side).is_empty());
625        }
626    }
627
628    // ── Provider plumbing ─────────────────────────────────
629
630    #[test]
631    fn remote_provider_id_distinct_from_baseline_and_current() {
632        let bid = BufferId(7);
633        let base = diff_filler_provider_id(bid, Side::Baseline);
634        let cur = diff_filler_provider_id(bid, Side::Current);
635        let rem = diff_filler_provider_id(bid, Side::Remote);
636        assert_ne!(rem, base);
637        assert_ne!(rem, cur);
638        assert_ne!(base, cur);
639        // Buffer-id bits still visible in the low 32.
640        assert_eq!(rem as u32, bid.0);
641    }
642
643    #[test]
644    fn remote_provider_id_does_not_collide_with_overlay_namespace() {
645        let bid = BufferId(7);
646        let overlay = crate::overlay::diff_overlay_provider_id(bid);
647        assert_ne!(diff_filler_provider_id(bid, Side::Remote), overlay);
648    }
649
650    /// D.6.i (2026-05-31): filler-row provider emits
651    /// `VirtualRowKind::Filler` so renderers skip the
652    /// deletion-block backdrop on padding rows.
653    #[test]
654    fn filler_rows_carry_filler_kind() {
655        let i = idx(vec![hunk(
656            HunkKind::Add,
657            LineRange::new(5, 5),
658            LineRange::new(5, 8),
659        )]);
660        let rows = compute_filler_rows(&i, Side::Baseline);
661        assert!(!rows.is_empty());
662        for row in &rows {
663            assert_eq!(
664                row.kind,
665                lattice_cells::VirtualRowKind::Filler,
666                "filler rows must be tagged Filler so renderers skip the \
667                 deletion-block backdrop"
668            );
669        }
670    }
671
672    #[test]
673    fn provider_version_distinct_across_all_three_sides_at_rev_zero() {
674        let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
675        let baseline = FillerRowProvider::new(session.clone(), Side::Baseline);
676        let current = FillerRowProvider::new(session.clone(), Side::Current);
677        let remote = FillerRowProvider::new(session, Side::Remote);
678        // All three salts must be pairwise distinct.
679        let v = (baseline.version(), current.version(), remote.version());
680        assert_ne!(v.0, v.1);
681        assert_ne!(v.0, v.2);
682        assert_ne!(v.1, v.2);
683    }
684
685    #[test]
686    fn remote_provider_collect_reads_published_three_way_hunks() {
687        let session = Arc::new(DiffSession::new(BufferId(1), DiffAlgorithm::Histogram));
688        session.publish(Arc::new(idx(vec![hunk3(
689            HunkKind::Add,
690            LineRange::new(10, 10),
691            LineRange::new(10, 13),
692            LineRange::new(10, 10),
693        )])));
694        let remote = FillerRowProvider::new(session, Side::Remote);
695        assert_eq!(
696            remote.collect().len(),
697            3,
698            "remote pane gets 3 fillers to align with local's Add"
699        );
700    }
701
702    #[test]
703    fn side_pane_index_matches_ranges_slot() {
704        assert_eq!(Side::Baseline.pane_index(), 0);
705        assert_eq!(Side::Current.pane_index(), 1);
706        assert_eq!(Side::Remote.pane_index(), 2);
707    }
708}