Skip to main content

lattice_diff/
overlay.rs

1//! D.3.a (2026-05-29) — `DiffOverlayVirtualRowProvider`.
2//!
3//! Bridges [`crate::subsystem::DiffSession`]'s published
4//! `HunkIndex` to the `virtual_rows_worker`'s
5//! [`lattice_cells::VirtualRowProvider`] surface. One provider
6//! per active diff session; registered with
7//! `Editor::virtual_row_providers` at session-open time
8//! (D.3.a.1's `:diff` ex-command) and unregistered at
9//! `:diffoff`.
10//!
11//! ## What this slice (D.3.a) emits
12//!
13//! For each `Remove` or `Change` hunk in the session's
14//! published `HunkIndex`, one `Above`-anchored [`VirtualRow`]
15//! per *baseline line* the hunk deletes / replaces. The
16//! anchor is the **current side's start line** for the hunk —
17//! so the deletion block appears immediately above the
18//! corresponding edit position in the buffer the user is
19//! looking at. `Add` hunks emit nothing (the added lines are
20//! visible in the current buffer; the future gutter sign /
21//! background tint passes — D.3.d / D.3.e — visualise the
22//! add). `Conflict` hunks are emitted like `Change` for v1;
23//! the three-way merge slice (D.6) refines the rendering.
24//!
25//! ## What this slice does NOT emit
26//!
27//! - **No cell content** in the deletion-block rows. D.3.a
28//!   emits empty `cells: Arc<[Cell]>` so the virtual rows take
29//!   visual space (height = 1 each) but render as blank
30//!   placeholders. **D.3.b** lands the baseline-line text:
31//!   the provider snapshots the descriptor's baseline rope
32//!   on each revision bump, caches the rendered cells, and
33//!   serves them from `collect()`.
34//! - **No gutter signs** — D.3.d.
35//! - **No background tints** — D.3.e.
36//!
37//! ## Why empty rows are still useful in D.3.a
38//!
39//! The user can immediately see *that* a hunk exists at the
40//! right place — there's a visual gap above the current line
41//! indicating "something was deleted / changed here." The
42//! semantic plumbing (provider registration, revision
43//! tracking, wake propagation, worker recompute on hunk
44//! publish) all lights up. D.3.b adds the textual content
45//! over the same wiring.
46//!
47//! ## Versioning
48//!
49//! `version()` returns the session's currently-published
50//! `HunkIndex::revision`. Bumps on every successful publish
51//! through [`crate::subsystem::DiffSession::try_publish_if_newer`];
52//! the `virtual_rows_worker`'s fingerprint pass picks up the
53//! change on its next wake and triggers a recompute.
54//! D.3.a.1's `:diff` ex-command also wires a wake forwarder
55//! so a hunk publish fires the worker's `VirtualRowsWake`
56//! directly — without it, the worker would only notice on the
57//! next `publish_render_state` tick.
58
59use std::sync::{Arc, Mutex};
60
61use crate::{HunkIndex, HunkKind};
62use lattice_cells::{AnchorPosition, Cell, ProviderId, VirtualRow, VirtualRowProvider};
63
64use lattice_core::BufferId;
65use tokio::task::JoinHandle;
66use tracing::debug;
67
68use crate::subsystem::{DiffParticipantSource, DiffSession};
69
70// ──────────────────────────────────────────────────────────────
71// D.3.d.0 (2026-05-29): per-line sign classification.
72// ──────────────────────────────────────────────────────────────
73
74/// Per-line gutter sign kind. Renderer-facing surface for
75/// D.3.d.1 (TUI) and D.3.d.2 (GPUI sprite atlas) integrations
76/// — D.3.d.0 lands the data layer only. D.3.e (line tints)
77/// composes on top of the same classification.
78///
79/// `Add` and `Remove` are emitted for the obvious cases.
80/// `Change` is emitted on **both** the baseline-deleted lines
81/// (covered by the deletion-block virtual rows) and the
82/// current-side replaced lines (which sit in the actual
83/// document rows). A row carrying a `Change` sign tells the
84/// renderer "this line replaces baseline content" — useful
85/// for the tint pass.
86///
87/// `Conflict` (D.6.f, 2026-05-31) classifies a current-side
88/// row that sits inside a three-way merge Conflict hunk —
89/// both `local` and `remote` mutated the same `base` region
90/// differently. Renders with a distinct glyph (`?`) and tint
91/// (`theme.diff_conflict_line_bg`). The variant only fires
92/// for three-way sessions; two-way `compute_two_way`
93/// doesn't emit `HunkKind::Conflict`, so two-way overlays
94/// never produce `DiffSignKind::Conflict`.
95#[derive(Copy, Clone, Debug, PartialEq, Eq)]
96pub enum DiffSignKind {
97    Add,
98    Remove,
99    Change,
100    Conflict,
101}
102
103/// Sparse per-line classification of the current-side rope.
104///
105/// Keyed by source line index (0-based, into the current
106/// rope — i.e., the line the user is looking at). Sparse
107/// because most lines have no sign. `entries` is sorted by
108/// line so renderer-side lookup (per-row) is `O(log n)` via
109/// binary search.
110///
111/// Build via [`compute_diff_sign_map`] — pure function of an
112/// `Arc<HunkIndex>`. Published per-session via
113/// `DiffSession::sign_map_cell` (D.3.d.0); the
114/// [`DiffOverlayRefreshTask`] refreshes it on every hunk
115/// publish so renderers see consistent decorations.
116#[derive(Clone, Debug, Default)]
117pub struct DiffSignMap {
118    entries: Vec<(u32, DiffSignKind)>,
119    /// The session revision the map was computed against.
120    /// Renderers can compare against the session's
121    /// `current_hunks().revision` to detect staleness.
122    revision: u64,
123}
124
125impl DiffSignMap {
126    pub fn entries(&self) -> &[(u32, DiffSignKind)] {
127        &self.entries
128    }
129
130    pub fn revision(&self) -> u64 {
131        self.revision
132    }
133
134    pub fn is_empty(&self) -> bool {
135        self.entries.is_empty()
136    }
137
138    pub fn len(&self) -> usize {
139        self.entries.len()
140    }
141
142    /// Build a sign map directly from `(line, kind)` entries (revision
143    /// 0), for producers that classify lines without a `HunkIndex` to
144    /// walk — a buffer whose *content* is already a unified diff
145    /// (magit's diff / revision / stash-show / commit views) knows each
146    /// line's kind by reading it, and has no baseline to diff against.
147    ///
148    /// `entries` MUST be sorted ascending by line: [`Self::sign_at`] is
149    /// a binary search, so an unsorted vec silently mis-reports signs
150    /// rather than failing.
151    pub fn from_entries(entries: Vec<(u32, DiffSignKind)>) -> Self {
152        Self {
153            entries,
154            revision: 0,
155        }
156    }
157
158    /// Lookup the sign for `line`, if any. `O(log n)` binary
159    /// search over `entries`. Renderer hot path.
160    pub fn sign_at(&self, line: u32) -> Option<DiffSignKind> {
161        match self.entries.binary_search_by_key(&line, |(l, _)| *l) {
162            Ok(idx) => Some(self.entries[idx].1),
163            Err(_) => None,
164        }
165    }
166}
167
168/// D.3.d.0: derive a `DiffSignMap` from a `HunkIndex`.
169///
170/// Walks each hunk's current-side range (`ranges[1]`):
171/// - `Add` → every line in the range gets `Add`.
172/// - `Change` / `Conflict` → every line in the range gets
173///   `Change` (the current-side rows are the replacements).
174/// - `Remove` → no current-side lines exist; the deletion is
175///   surfaced through the virtual-row deletion block (D.3.b)
176///   and gets no sign in the sign map. Renderers wanting to
177///   sign the *insertion point* of a removed hunk should
178///   render based on the deletion block's anchor line (a
179///   future D.3.d.1 detail) — keeping the sign map a
180///   strictly-current-rope decoration here avoids
181///   double-counting at the deletion anchor.
182///
183/// Entries are sorted by line before return so
184/// `DiffSignMap::sign_at` binary-searches cheaply.
185pub fn compute_diff_sign_map(hunks: &HunkIndex) -> DiffSignMap {
186    let mut entries: Vec<(u32, DiffSignKind)> = Vec::new();
187    for hunk in &hunks.hunks {
188        let Some(current_range) = hunk.ranges.get(1) else {
189            continue;
190        };
191        let kind = match hunk.kind {
192            HunkKind::Add => DiffSignKind::Add,
193            HunkKind::Change => DiffSignKind::Change,
194            // D.6.f (2026-05-31): three-way Conflict gets its
195            // own classification so renderers can decorate it
196            // distinctly from a plain Change. Two-way sessions
197            // never see Conflict hunks (`compute_two_way`
198            // doesn't emit them) so this arm only fires for
199            // three-way overlays.
200            HunkKind::Conflict => DiffSignKind::Conflict,
201            HunkKind::Remove => continue,
202        };
203        for line in current_range.start..current_range.end {
204            entries.push((line, kind));
205        }
206    }
207    entries.sort_by_key(|(l, _)| *l);
208    DiffSignMap {
209        entries,
210        revision: hunks.revision,
211    }
212}
213
214/// D-fix.3b: derive the BASELINE-side `DiffSignMap` from a `HunkIndex`.
215///
216/// The mirror of [`compute_diff_sign_map`], walking each hunk's
217/// baseline-side range (`ranges[0]`) instead of the current side — so the
218/// baseline (left) pane of a side-by-side diff tints the lines that were
219/// removed / changed relative to the proposed text:
220/// - `Remove` → every baseline line in the range gets `Remove` (these are
221///   the deleted lines; they DO exist on the baseline side, unlike the
222///   current side where `compute_diff_sign_map` skips them).
223/// - `Change` / `Conflict` → every baseline line gets `Change` / `Conflict`.
224/// - `Add` → no baseline-side line exists (the addition isn't in the
225///   baseline), so it contributes nothing here.
226///
227/// Entries are sorted by line so `DiffSignMap::sign_at` binary-searches.
228pub fn compute_baseline_diff_sign_map(hunks: &HunkIndex) -> DiffSignMap {
229    let mut entries: Vec<(u32, DiffSignKind)> = Vec::new();
230    for hunk in &hunks.hunks {
231        let Some(baseline_range) = hunk.ranges.first() else {
232            continue;
233        };
234        let kind = match hunk.kind {
235            HunkKind::Remove => DiffSignKind::Remove,
236            HunkKind::Change => DiffSignKind::Change,
237            HunkKind::Conflict => DiffSignKind::Conflict,
238            // An `Add` has no baseline-side line to sign.
239            HunkKind::Add => continue,
240        };
241        for line in baseline_range.start..baseline_range.end {
242            entries.push((line, kind));
243        }
244    }
245    entries.sort_by_key(|(l, _)| *l);
246    DiffSignMap {
247        entries,
248        revision: hunks.revision,
249    }
250}
251
252/// D.3.a.1 (2026-05-29): the [`ProviderId`] this slice uses
253/// for a given session's overlay provider. Exposed as a free
254/// function so `:diffoff` can unregister without holding the
255/// session — the namespace prefix + buffer-id encoding makes
256/// the id deterministic.
257pub fn diff_overlay_provider_id(buffer_id: BufferId) -> ProviderId {
258    DIFF_OVERLAY_PROVIDER_NAMESPACE | u64::from(buffer_id.0)
259}
260
261/// Namespace prefix for diff-overlay [`ProviderId`]s. We
262/// OR-mix the session's `BufferId` into the low 32 bits;
263/// uniqueness across the `virtual_row_providers` registry
264/// holds as long as no other provider kind uses the same
265/// namespace prefix. Documented here so the constant is
266/// auditable.
267const DIFF_OVERLAY_PROVIDER_NAMESPACE: u64 = 0xD1FF_0000_0000_0000;
268
269/// Cached rendered virtual rows. Refreshed off the worker
270/// thread by [`DiffOverlayRefreshTask`] (D.3.b) so
271/// [`DiffOverlayVirtualRowProvider::collect`] returns without
272/// blocking the virtual-rows worker.
273///
274/// `pub` so [`DiffOverlayRefreshTask::spawn`] and
275/// [`DiffOverlayVirtualRowProvider::cache_handle`] can carry
276/// the type through their signatures. Construction is internal
277/// (use `Default::default`).
278#[derive(Clone, Debug, Default)]
279pub struct DiffOverlayCache {
280    /// Revision the cached rows were rendered against.
281    rendered_revision: u64,
282    /// Monotonic cache-version counter that bumps whenever
283    /// `rows` is replaced. Folded into the provider's
284    /// `version()` so a cache refresh shows up in the worker's
285    /// fingerprint pass.
286    cache_version: u64,
287    rows: Vec<VirtualRow>,
288}
289
290impl DiffOverlayCache {
291    pub fn rendered_revision(&self) -> u64 {
292        self.rendered_revision
293    }
294
295    pub fn cache_version(&self) -> u64 {
296        self.cache_version
297    }
298
299    pub fn rows(&self) -> &[VirtualRow] {
300        &self.rows
301    }
302}
303
304/// One provider per active diff session.
305///
306/// Holds an `Arc<DiffSession>` (RCU reads of
307/// `session.current_hunks()` are lock-free and stay coherent
308/// even if the registry has dropped the session entry) plus a
309/// shared cache of rendered rows. The cache is populated by
310/// [`DiffOverlayRefreshTask`] off the worker thread; `collect()`
311/// only reads.
312#[derive(Debug)]
313pub struct DiffOverlayVirtualRowProvider {
314    session: Arc<DiffSession>,
315    cache: Arc<Mutex<DiffOverlayCache>>,
316}
317
318impl DiffOverlayVirtualRowProvider {
319    pub fn new(session: Arc<DiffSession>) -> Self {
320        Self {
321            session,
322            cache: Arc::new(Mutex::new(DiffOverlayCache::default())),
323        }
324    }
325
326    pub fn session(&self) -> &Arc<DiffSession> {
327        &self.session
328    }
329
330    /// D.3.b (2026-05-29): pure sync render — walk the session's
331    /// current `HunkIndex`, snapshot the baseline rope, render
332    /// each baseline-deleted line into a `Vec<Cell>`, return
333    /// the resulting `Vec<VirtualRow>` plus the revision they
334    /// were rendered against. Public so tests can exercise the
335    /// render path without spinning up the async refresh task.
336    ///
337    /// D.3.b.2 (2026-05-29): `syntax` provides a `Lang`,
338    /// `LangRegistry`, and `Theme` for one-shot tree-sitter
339    /// highlighting of the baseline rope. When `None`, cells
340    /// emit with `fg = 0` and the renderer falls back to
341    /// monochrome — backward-compatible with D.3.b's behavior.
342    pub fn render_rows(
343        session: &DiffSession,
344        baseline: &dyn DiffParticipantSource,
345        syntax: Option<&SyntaxContext>,
346    ) -> (u64, Vec<VirtualRow>) {
347        let hunks = session.current_hunks();
348        let revision = hunks.revision;
349        if hunks.hunks.is_empty() {
350            return (revision, Vec::new());
351        }
352        let baseline_rope = baseline.snapshot();
353        // D.3.b.2: run one-shot tree-sitter parse once for
354        // the whole baseline, then look up spans per
355        // deletion-block line during the hunk walk below.
356        let per_line_spans: Option<Vec<Vec<lattice_syntax::StyledSpan>>> = syntax.and_then(|ctx| {
357            let source = baseline_rope.to_string();
358            let line_count = baseline_rope.len_lines() as u32;
359            lattice_syntax::oneshot_highlight_lines(
360                ctx.lang,
361                ctx.registry.clone(),
362                &source,
363                0,
364                line_count,
365            )
366        });
367        let default_fg: u32 = syntax
368            .map(|ctx| {
369                let s = lattice_syntax::resolve_syntax_style(
370                    &ctx.resolved,
371                    &ctx.ids,
372                    lattice_syntax::Style::Default,
373                );
374                s.fg.map(|c| c.to_rgb_u32(0)).unwrap_or(0)
375            })
376            .unwrap_or(0);
377        let mut rows: Vec<VirtualRow> = Vec::new();
378        for hunk in &hunks.hunks {
379            let (baseline_range, current_anchor) = match hunk.kind {
380                HunkKind::Remove | HunkKind::Change | HunkKind::Conflict => {
381                    let b = match hunk.ranges.first() {
382                        Some(r) if !r.is_empty() => *r,
383                        _ => continue,
384                    };
385                    let anchor = hunk
386                        .ranges
387                        .get(1)
388                        .map(|r| r.start)
389                        .unwrap_or_else(|| b.start);
390                    (b, anchor)
391                }
392                HunkKind::Add => continue,
393            };
394            for line_idx in baseline_range.start..baseline_range.end {
395                // DR.4: this deletion-block row's refinement, computed
396                // at diff time and carried on the hunk. Read, never
397                // recomputed — the render path has only the baseline
398                // rope, so it could not pair lines even if it wanted to.
399                let refine_for_row = hunk
400                    .refine
401                    .removed_line((line_idx - baseline_range.start) as usize);
402                let cells = render_baseline_line(
403                    &baseline_rope,
404                    line_idx,
405                    syntax,
406                    per_line_spans.as_ref(),
407                    default_fg,
408                    refine_for_row,
409                );
410                rows.push(VirtualRow {
411                    media: None,
412                    anchor_line: current_anchor,
413                    position: AnchorPosition::Above,
414                    cells: Arc::from(cells),
415                    height: 1,
416                    // D.6.i: deletion blocks render with the
417                    // diff-deletion-block backdrop.
418                    kind: lattice_cells::VirtualRowKind::DeletionBlock,
419                    bg: None,
420                    scales: None,
421                    gutter_line: None,
422                    gutter_fg: None,
423                });
424            }
425        }
426        (revision, rows)
427    }
428}
429
430/// D.3.b.2 (2026-05-29): the per-session syntax context the
431/// provider needs to populate `Cell.fg` with theme-resolved
432/// token colours.
433///
434/// The refresh task threads this through from
435/// `DiffOverlayRefreshTask::spawn` so the provider doesn't
436/// have to plumb the host's syntax / theme types into its
437/// public surface. `None` (passed as `syntax: Option<&...>`
438/// in [`DiffOverlayVirtualRowProvider::render_rows`]) means
439/// "don't syntax-highlight" — useful for tests and for
440/// languages with no registered grammar.
441#[derive(Clone, Debug)]
442pub struct SyntaxContext {
443    pub lang: lattice_syntax::Lang,
444    pub registry: Arc<lattice_syntax::LangRegistry>,
445    /// T.5.b: the resolved theme table + builtin element ids the
446    /// deletion-block highlighter resolves syntax colours through
447    /// (replaces the old `theme: Theme` field — the only thing it
448    /// was used for was `Theme::syntax_style`, now
449    /// `lattice_syntax::resolve_syntax_style`).
450    pub resolved: Arc<lattice_theme::ResolvedTheme>,
451    pub ids: lattice_theme::BuiltinElementIds,
452}
453
454/// D.3.b: render one source line of `rope` as a sequence of
455/// `Cell`s. `line_idx` is bounds-checked; out-of-range lines
456/// produce an empty cell list (defensive against revisions
457/// where the baseline rope has fewer lines than the hunk
458/// expects — e.g., a session whose baseline file was
459/// truncated mid-edit).
460///
461/// D.3.b.2 (2026-05-29): when `syntax` + `per_line_spans` are
462/// supplied, each cell's `fg` is set from the styled span
463/// covering its byte offset (theme-resolved RGB); bytes not
464/// inside any span get `default_fg`. When `syntax` is `None`,
465/// cells emit with `fg = 0` so renderers fall back to the
466/// terminal / pane default foreground — the pre-D.3.b.2
467/// monochrome behaviour.
468fn render_baseline_line(
469    rope: &ropey::Rope,
470    line_idx: u32,
471    syntax: Option<&SyntaxContext>,
472    per_line_spans: Option<&Vec<Vec<lattice_syntax::StyledSpan>>>,
473    default_fg: u32,
474    // DR.4: byte ranges on THIS line whose background is the refined
475    // removal tint rather than the row's uniform one. Empty when the
476    // pair declined refinement, which renders exactly as before.
477    refine: &[std::ops::Range<usize>],
478) -> Vec<Cell> {
479    let idx = line_idx as usize;
480    if idx >= rope.len_lines() {
481        return Vec::new();
482    }
483    let line = rope.line(idx);
484    let spans: &[lattice_syntax::StyledSpan] = per_line_spans
485        .and_then(|p| p.get(idx))
486        .map(Vec::as_slice)
487        .unwrap_or(&[]);
488    // DR.4: resolve the refined-removal background once per line from
489    // the SAME theme element the magit path uses
490    // (`diff.remove.refine.bg`), rather than a local constant — one
491    // colour, one place to change it. `0` (transparent) without a
492    // syntax context, which is also the no-refinement value, so a
493    // themeless harness renders exactly as before.
494    let refine_bg: u32 = syntax
495        .and_then(|ctx| {
496            ctx.resolved
497                .get(ctx.ids.diff_remove_refine_bg)
498                .bg
499                .map(|c| c.to_rgb_u32(0))
500        })
501        .unwrap_or(0);
502    let mut out: Vec<Cell> = Vec::with_capacity(line.len_chars());
503    let mut byte_idx: usize = 0;
504    for ch in line.chars() {
505        if ch == '\n' || ch == '\r' {
506            break;
507        }
508        // Resolve fg from the styled span covering byte_idx,
509        // or fall back to default_fg / 0 per the contract
510        // above.
511        let fg: u32 = if let Some(ctx) = syntax {
512            let style = spans
513                .iter()
514                .find(|s| {
515                    let start = s.start;
516                    let end = s.end;
517                    start <= byte_idx && byte_idx < end
518                })
519                .map(|s| s.style)
520                .unwrap_or(lattice_syntax::Style::Default);
521            let s = lattice_syntax::resolve_syntax_style(&ctx.resolved, &ctx.ids, style);
522            s.fg.map(|c| c.to_rgb_u32(0)).unwrap_or(default_fg)
523        } else {
524            0
525        };
526        // DR.4: refinement is a BACKGROUND, so the syntax fg resolved
527        // above survives underneath it — the same axis separation the
528        // magit path uses.
529        let bg: u32 = if refine
530            .iter()
531            .any(|r| byte_idx >= r.start && byte_idx < r.end)
532        {
533            refine_bg
534        } else {
535            0
536        };
537        out.push(Cell::new(ch as u32, fg, bg, 0));
538        byte_idx += ch.len_utf8();
539    }
540    out
541}
542
543impl VirtualRowProvider for DiffOverlayVirtualRowProvider {
544    fn id(&self) -> ProviderId {
545        // `BufferId` is `u32`; the namespace prefix lives in
546        // the high bits so the buffer id remains visible in
547        // the low 32 (useful for debug logs).
548        DIFF_OVERLAY_PROVIDER_NAMESPACE | u64::from(self.session.buffer_id().0)
549    }
550
551    fn version(&self) -> u64 {
552        // Fold the session revision + the cache version so a
553        // fresh render shows up in the worker's fingerprint
554        // even if the underlying session revision hasn't
555        // changed (e.g., if the refresh task runs to completion
556        // after the session was already at this revision when
557        // the provider was first registered).
558        let cache = self.cache.lock().expect("DiffOverlayCache mutex poisoned");
559        let session_rev = self.session.current_hunks().revision;
560        // XOR is fine here — the cache_version is bumped on
561        // every install_render, so any flip in either axis
562        // flips the combined value.
563        session_rev ^ cache.cache_version
564    }
565
566    fn collect(&self) -> Vec<VirtualRow> {
567        // Non-blocking: return the cached rows.
568        let cache = self.cache.lock().expect("DiffOverlayCache mutex poisoned");
569        cache.rows.clone()
570    }
571}
572
573/// D.3.b: refresh task that owns the off-worker rendering of
574/// deletion-block cells.
575///
576/// At spawn time it does an initial render so the first
577/// `collect()` from the worker returns content (rather than
578/// the empty default cache). Thereafter it awaits the
579/// session's `publish_notify` and re-renders whenever a new
580/// `HunkIndex` is published. After each render it fires
581/// `virtual_rows_wake` so the worker re-runs its fingerprint
582/// pass and picks up the cache change.
583///
584/// Held by `:diff` via the returned [`JoinHandle`]; `:diffoff`
585/// aborts it.
586pub struct DiffOverlayRefreshTask;
587
588impl DiffOverlayRefreshTask {
589    pub fn spawn(
590        session: Arc<DiffSession>,
591        baseline: Arc<dyn DiffParticipantSource>,
592        cache: Arc<Mutex<DiffOverlayCache>>,
593        virtual_rows_wake: Arc<tokio::sync::Notify>,
594        syntax: Option<SyntaxContext>,
595    ) -> JoinHandle<()> {
596        tokio::spawn(async move {
597            // Initial render so the first `collect()` from the
598            // worker returns the populated cache.
599            Self::run_once(
600                &session,
601                &*baseline,
602                &cache,
603                &virtual_rows_wake,
604                syntax.as_ref(),
605            );
606            let publish_notify = session.publish_notify();
607            loop {
608                publish_notify.notified().await;
609                Self::run_once(
610                    &session,
611                    &*baseline,
612                    &cache,
613                    &virtual_rows_wake,
614                    syntax.as_ref(),
615                );
616            }
617        })
618    }
619
620    fn run_once(
621        session: &DiffSession,
622        baseline: &dyn DiffParticipantSource,
623        cache: &Mutex<DiffOverlayCache>,
624        virtual_rows_wake: &Arc<tokio::sync::Notify>,
625        syntax: Option<&SyntaxContext>,
626    ) {
627        let hunks = session.current_hunks();
628        // D.3.d.0: derive the per-line sign classification
629        // from the same `HunkIndex` revision the deletion
630        // blocks are rendered against. Publishing the sign
631        // map FIRST (before deletion-block rows) means a
632        // renderer reading both in the same paint pass
633        // sees consistent state — same revision on both.
634        let sign_map = compute_diff_sign_map(&hunks);
635        session.publish_sign_map(Arc::new(sign_map));
636        let (rendered_revision, rows) =
637            DiffOverlayVirtualRowProvider::render_rows(session, baseline, syntax);
638        debug!(
639            target: "lattice_host::diff::overlay",
640            buffer_id = ?session.buffer_id(),
641            rendered_revision,
642            n_rows = rows.len(),
643            "diff overlay refresh"
644        );
645        let mut cache = cache.lock().expect("DiffOverlayCache mutex poisoned");
646        cache.rendered_revision = rendered_revision;
647        cache.cache_version = cache.cache_version.wrapping_add(1);
648        cache.rows = rows;
649        drop(cache);
650        virtual_rows_wake.notify_one();
651    }
652}
653
654impl DiffOverlayVirtualRowProvider {
655    /// D.3.b: expose the shared cache so [`DiffOverlayRefreshTask::spawn`]
656    /// can be wired to write to the same `Mutex` the provider's
657    /// `collect`/`version` read from.
658    pub fn cache_handle(&self) -> Arc<Mutex<DiffOverlayCache>> {
659        Arc::clone(&self.cache)
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    use std::sync::Arc;
668
669    use crate::{DiffAlgorithm, Hunk, HunkIndex, HunkKind, LineRange};
670    use lattice_core::BufferId;
671    use smallvec::smallvec;
672
673    fn bid(n: u32) -> BufferId {
674        BufferId(n)
675    }
676
677    fn session_with_hunks(buffer_id: BufferId, hunks: Vec<Hunk>) -> Arc<DiffSession> {
678        let s = DiffSession::new(buffer_id, DiffAlgorithm::Histogram);
679        s.publish(Arc::new(HunkIndex {
680            hunks,
681            algorithm: DiffAlgorithm::Histogram,
682            revision: 1,
683        }));
684        Arc::new(s)
685    }
686
687    #[test]
688    fn id_carries_buffer_id_in_low_bits() {
689        let s = session_with_hunks(bid(0xCAFE), vec![]);
690        let p = DiffOverlayVirtualRowProvider::new(s);
691        let id = p.id();
692        // High prefix preserved.
693        assert_eq!(id & 0xFFFF_FFFF_0000_0000, DIFF_OVERLAY_PROVIDER_NAMESPACE);
694        // Low 32 bits = buffer id.
695        assert_eq!(id & 0xFFFF_FFFF, 0xCAFE);
696    }
697
698    #[test]
699    fn version_follows_session_revision() {
700        let s = session_with_hunks(bid(1), vec![]);
701        let p = DiffOverlayVirtualRowProvider::new(Arc::clone(&s));
702        assert_eq!(p.version(), 1);
703        s.publish(Arc::new(HunkIndex {
704            hunks: vec![],
705            algorithm: DiffAlgorithm::Histogram,
706            revision: 9,
707        }));
708        assert_eq!(p.version(), 9);
709    }
710
711    // D.3.b reshapes the provider so `collect()` returns the
712    // cached rows populated by the off-worker refresh task.
713    // The pure render path is `render_rows(session, baseline)`;
714    // tests below exercise it directly so they stay sync.
715
716    use crate::subsystem::StaticSource;
717    use ropey::Rope;
718
719    fn render(session: &DiffSession, baseline_text: &str) -> Vec<VirtualRow> {
720        let base = StaticSource::new(Rope::from(baseline_text));
721        DiffOverlayVirtualRowProvider::render_rows(session, &base, None).1
722    }
723
724    // D.3.b.2 (2026-05-29): variant that runs the render
725    // pipeline WITH a real `SyntaxContext` so tests can
726    // assert per-cell fg is populated from the one-shot
727    // tree-sitter parse.
728    fn render_with_syntax(session: &DiffSession, baseline_text: &str) -> Vec<VirtualRow> {
729        let base = StaticSource::new(Rope::from(baseline_text));
730        let registry = lattice_syntax::LangRegistry::standard().expect("standard registry");
731        let reg = lattice_theme::InMemoryThemeRegistry::with_defaults();
732        use lattice_theme::ThemeRegistry as _;
733        let resolved = reg.resolved();
734        let ids = lattice_theme::BuiltinElementIds::capture(&reg);
735        let ctx = SyntaxContext {
736            lang: lattice_syntax::Lang::Rust,
737            registry,
738            resolved,
739            ids,
740        };
741        DiffOverlayVirtualRowProvider::render_rows(session, &base, Some(&ctx)).1
742    }
743
744    #[test]
745    fn cells_emit_fg_zero_without_syntax_context() {
746        // D.3.b.2 backward-compat: when syntax = None, cells
747        // keep fg = 0 (pre-D.3.b.2 behaviour). Renderer falls
748        // back to terminal / pane default foreground.
749        let hunk = Hunk {
750            kind: HunkKind::Remove,
751            ranges: smallvec![LineRange::new(0, 1), LineRange::new(10, 10)],
752            refine: Default::default(),
753        };
754        let s = session_with_hunks(bid(1), vec![hunk]);
755        let rows = render(&s, "fn main() {}\n");
756        assert_eq!(rows.len(), 1);
757        // Every cell's fg should be 0 (unstyled).
758        for cell in rows[0].cells.iter() {
759            assert_eq!(cell.fg, 0, "syntax=None must leave cells unstyled");
760        }
761    }
762
763    #[test]
764    fn cells_emit_per_token_fg_with_syntax_context() {
765        // D.3.b.2: the rust grammar should colour the `fn`
766        // keyword distinct from the `main` identifier. The
767        // exact RGB values depend on the theme, but they
768        // must differ between the keyword and identifier
769        // cells.
770        let hunk = Hunk {
771            kind: HunkKind::Remove,
772            ranges: smallvec![LineRange::new(0, 1), LineRange::new(10, 10)],
773            refine: Default::default(),
774        };
775        let s = session_with_hunks(bid(1), vec![hunk]);
776        let rows = render_with_syntax(&s, "fn main() {}\n");
777        assert_eq!(rows.len(), 1);
778        let cells = &rows[0].cells;
779        assert!(cells.len() >= 4, "expected at least 'fn m...' cells");
780        // `f` (idx 0) and `m` (idx 3) sit in different token
781        // kinds; their `fg` must differ from each other unless
782        // the theme has collapsed them to the same colour
783        // (which it shouldn't for a default theme).
784        let fn_fg = cells[0].fg;
785        let main_fg = cells[3].fg;
786        assert_ne!(
787            fn_fg, main_fg,
788            "keyword 'fn' and identifier 'main' should have different fg colours"
789        );
790    }
791
792    #[test]
793    fn empty_hunks_emit_no_rows() {
794        let s = session_with_hunks(bid(1), vec![]);
795        assert!(render(&s, "alpha\nbeta\n").is_empty());
796    }
797
798    #[test]
799    fn add_hunks_emit_no_deletion_block() {
800        // Add: baseline range is empty, current has lines. No
801        // baseline lines to render as deleted.
802        let hunk = Hunk {
803            kind: HunkKind::Add,
804            ranges: smallvec![LineRange::new(5, 5), LineRange::new(5, 8)],
805            refine: Default::default(),
806        };
807        let s = session_with_hunks(bid(1), vec![hunk]);
808        assert!(render(&s, "alpha\nbeta\n").is_empty());
809    }
810
811    #[test]
812    fn remove_hunk_emits_one_row_per_baseline_line() {
813        // Remove: baseline had 3 lines (rows 5..8), current has
814        // 0. Anchor at current's start line (10). D.3.b
815        // renders the baseline-line text into the row's cells.
816        let hunk = Hunk {
817            kind: HunkKind::Remove,
818            ranges: smallvec![LineRange::new(0, 3), LineRange::new(10, 10)],
819            refine: Default::default(),
820        };
821        let s = session_with_hunks(bid(1), vec![hunk]);
822        let rows = render(&s, "alpha\nbeta\ngamma\n");
823        assert_eq!(rows.len(), 3);
824        for row in &rows {
825            assert_eq!(row.anchor_line, 10);
826            assert_eq!(row.position, AnchorPosition::Above);
827            assert_eq!(row.height, 1);
828        }
829        // D.3.b: the rendered cells encode the baseline line text.
830        assert_eq!(rows[0].cells.len(), 5); // "alpha"
831        assert_eq!(rows[1].cells.len(), 4); // "beta"
832        assert_eq!(rows[2].cells.len(), 5); // "gamma"
833    }
834
835    #[test]
836    fn change_hunk_emits_one_row_per_baseline_line() {
837        // Change: baseline had 2 lines, current has 2 different
838        // lines. Anchor at current's start line (20).
839        let hunk = Hunk {
840            kind: HunkKind::Change,
841            ranges: smallvec![LineRange::new(0, 2), LineRange::new(20, 22)],
842            refine: Default::default(),
843        };
844        let s = session_with_hunks(bid(1), vec![hunk]);
845        let rows = render(&s, "first\nsecond\n");
846        assert_eq!(rows.len(), 2);
847        assert_eq!(rows[0].anchor_line, 20);
848        assert_eq!(rows[1].anchor_line, 20);
849        assert_eq!(rows[0].cells.len(), 5); // "first"
850        assert_eq!(rows[1].cells.len(), 6); // "second"
851    }
852
853    /// D.6.i (2026-05-31): deletion-block overlay rows
854    /// carry `VirtualRowKind::DeletionBlock` so the
855    /// renderer paints them with the deletion-block
856    /// backdrop. Distinct from filler rows
857    /// (`VirtualRowKind::Filler`) which paint with no
858    /// backdrop.
859    #[test]
860    fn deletion_block_rows_carry_deletion_block_kind() {
861        let hunk = Hunk {
862            kind: HunkKind::Remove,
863            ranges: smallvec![LineRange::new(0, 2), LineRange::new(5, 5)],
864            refine: Default::default(),
865        };
866        let s = session_with_hunks(bid(1), vec![hunk]);
867        let rows = render(&s, "removed-1\nremoved-2\n");
868        assert!(!rows.is_empty());
869        for row in &rows {
870            assert_eq!(
871                row.kind,
872                lattice_cells::VirtualRowKind::DeletionBlock,
873                "deletion-block rows must be tagged DeletionBlock"
874            );
875        }
876    }
877
878    #[test]
879    fn conflict_hunk_emits_deletion_block_like_change() {
880        let hunk = Hunk {
881            kind: HunkKind::Conflict,
882            ranges: smallvec![
883                LineRange::new(0, 2),
884                LineRange::new(0, 2),
885                LineRange::new(0, 2)
886            ],
887            refine: Default::default(),
888        };
889        let s = session_with_hunks(bid(1), vec![hunk]);
890        let rows = render(&s, "x\ny\n");
891        assert_eq!(rows.len(), 2);
892    }
893
894    #[test]
895    fn out_of_range_baseline_line_renders_empty() {
896        // Defensive: hunk references baseline lines past the
897        // baseline rope's length (e.g., baseline file was
898        // truncated between hunk compute and render). Should
899        // produce empty cells, not panic.
900        let hunk = Hunk {
901            kind: HunkKind::Remove,
902            ranges: smallvec![LineRange::new(50, 53), LineRange::new(10, 10)],
903            refine: Default::default(),
904        };
905        let s = session_with_hunks(bid(1), vec![hunk]);
906        let rows = render(&s, "only one line\n");
907        assert_eq!(rows.len(), 3);
908        for row in &rows {
909            assert_eq!(row.cells.len(), 0);
910        }
911    }
912
913    #[test]
914    fn collect_returns_cached_rows() {
915        // D.3.b: collect() reads the cache populated by the
916        // refresh task. Without a populated cache, collect()
917        // returns empty regardless of hunk state.
918        let hunk = Hunk {
919            kind: HunkKind::Remove,
920            ranges: smallvec![LineRange::new(0, 2), LineRange::new(10, 10)],
921            refine: Default::default(),
922        };
923        let s = session_with_hunks(bid(1), vec![hunk]);
924        let p = DiffOverlayVirtualRowProvider::new(s);
925        assert!(p.collect().is_empty(), "cache empty until refresh runs");
926    }
927
928    #[test]
929    fn version_folds_session_revision_and_cache_version() {
930        // Cache version starts at 0; XOR with session revision
931        // 1 gives 1. After a session republish to revision 9,
932        // version = 9 ^ 0 = 9.
933        let s = session_with_hunks(bid(1), vec![]);
934        let p = DiffOverlayVirtualRowProvider::new(Arc::clone(&s));
935        assert_eq!(p.version(), 1);
936        s.publish(Arc::new(HunkIndex {
937            hunks: vec![],
938            algorithm: DiffAlgorithm::Histogram,
939            revision: 9,
940        }));
941        assert_eq!(p.version(), 9);
942    }
943
944    // ── D.3.d.0: DiffSignMap derivation ─────────────────────
945
946    #[test]
947    fn sign_map_empty_for_no_hunks() {
948        let idx = HunkIndex {
949            hunks: vec![],
950            algorithm: DiffAlgorithm::Histogram,
951            revision: 7,
952        };
953        let map = compute_diff_sign_map(&idx);
954        assert!(map.is_empty());
955        assert_eq!(map.revision(), 7);
956        assert_eq!(map.sign_at(0), None);
957    }
958
959    #[test]
960    fn add_hunk_emits_add_signs_for_each_current_line() {
961        let idx = HunkIndex {
962            hunks: vec![Hunk {
963                kind: HunkKind::Add,
964                ranges: smallvec![LineRange::new(5, 5), LineRange::new(10, 13)],
965                refine: Default::default(),
966            }],
967            algorithm: DiffAlgorithm::Histogram,
968            revision: 1,
969        };
970        let map = compute_diff_sign_map(&idx);
971        assert_eq!(map.len(), 3);
972        assert_eq!(map.sign_at(10), Some(DiffSignKind::Add));
973        assert_eq!(map.sign_at(11), Some(DiffSignKind::Add));
974        assert_eq!(map.sign_at(12), Some(DiffSignKind::Add));
975        assert_eq!(map.sign_at(13), None);
976    }
977
978    #[test]
979    fn change_hunk_emits_change_signs_on_current_side() {
980        let idx = HunkIndex {
981            hunks: vec![Hunk {
982                kind: HunkKind::Change,
983                ranges: smallvec![LineRange::new(0, 2), LineRange::new(20, 22)],
984                refine: Default::default(),
985            }],
986            algorithm: DiffAlgorithm::Histogram,
987            revision: 1,
988        };
989        let map = compute_diff_sign_map(&idx);
990        assert_eq!(map.len(), 2);
991        assert_eq!(map.sign_at(20), Some(DiffSignKind::Change));
992        assert_eq!(map.sign_at(21), Some(DiffSignKind::Change));
993        assert_eq!(map.sign_at(19), None);
994    }
995
996    #[test]
997    fn remove_hunk_emits_no_current_side_signs() {
998        // Remove: baseline lines disappear. The current-side
999        // range is empty (start == end at the deletion
1000        // anchor); there are no current-side lines to sign.
1001        // The deletion is surfaced through the virtual-row
1002        // deletion block (D.3.b).
1003        let idx = HunkIndex {
1004            hunks: vec![Hunk {
1005                kind: HunkKind::Remove,
1006                ranges: smallvec![LineRange::new(5, 8), LineRange::new(10, 10)],
1007                refine: Default::default(),
1008            }],
1009            algorithm: DiffAlgorithm::Histogram,
1010            revision: 1,
1011        };
1012        let map = compute_diff_sign_map(&idx);
1013        assert!(map.is_empty());
1014    }
1015
1016    #[test]
1017    fn baseline_sign_map_signs_removed_and_changed_on_the_baseline_side() {
1018        // D-fix.3b: the baseline-side map tints the LEFT pane. Removed lines
1019        // (which have NO current-side row, so `compute_diff_sign_map` emits
1020        // nothing) DO appear here on their baseline `ranges[0]` lines; a
1021        // Change signs its baseline lines; an Add (no baseline line)
1022        // contributes nothing.
1023        let idx = HunkIndex {
1024            hunks: vec![
1025                Hunk {
1026                    kind: HunkKind::Remove,
1027                    ranges: smallvec![LineRange::new(5, 8), LineRange::new(10, 10)],
1028                    refine: Default::default(),
1029                },
1030                Hunk {
1031                    kind: HunkKind::Change,
1032                    ranges: smallvec![LineRange::new(0, 2), LineRange::new(20, 22)],
1033                    refine: Default::default(),
1034                },
1035                Hunk {
1036                    kind: HunkKind::Add,
1037                    ranges: smallvec![LineRange::new(3, 3), LineRange::new(30, 33)],
1038                    refine: Default::default(),
1039                },
1040            ],
1041            algorithm: DiffAlgorithm::Histogram,
1042            revision: 1,
1043        };
1044        let map = compute_baseline_diff_sign_map(&idx);
1045        // Removed baseline lines 5,6,7 are signed Remove (the current-side
1046        // map emits nothing for a Remove).
1047        assert_eq!(map.sign_at(5), Some(DiffSignKind::Remove));
1048        assert_eq!(map.sign_at(7), Some(DiffSignKind::Remove));
1049        // Changed baseline lines 0,1 are signed Change.
1050        assert_eq!(map.sign_at(0), Some(DiffSignKind::Change));
1051        assert_eq!(map.sign_at(1), Some(DiffSignKind::Change));
1052        // The Add has an empty baseline range → no baseline-side sign.
1053        assert_eq!(map.sign_at(3), None);
1054    }
1055
1056    /// D.6.f (2026-05-31): three-way Conflict hunks now
1057    /// emit their own `DiffSignKind::Conflict` rather than
1058    /// collapsing into Change, so renderers can decorate
1059    /// them with a distinct glyph (`?`) and tint
1060    /// (`diff_conflict_line_bg`).
1061    #[test]
1062    fn conflict_hunk_emits_conflict_signs() {
1063        let idx = HunkIndex {
1064            hunks: vec![Hunk {
1065                kind: HunkKind::Conflict,
1066                ranges: smallvec![
1067                    LineRange::new(0, 2),
1068                    LineRange::new(0, 2),
1069                    LineRange::new(0, 2)
1070                ],
1071                refine: Default::default(),
1072            }],
1073            algorithm: DiffAlgorithm::Histogram,
1074            revision: 1,
1075        };
1076        let map = compute_diff_sign_map(&idx);
1077        assert_eq!(map.len(), 2);
1078        assert_eq!(map.sign_at(0), Some(DiffSignKind::Conflict));
1079        assert_eq!(map.sign_at(1), Some(DiffSignKind::Conflict));
1080    }
1081
1082    /// D.6.f: Conflict signs are emitted for the
1083    /// current-side range only (slot 1), same shape as
1084    /// Change. Other slots (base, remote) carry their own
1085    /// data but the sign map is a *current-side*
1086    /// decoration.
1087    #[test]
1088    fn conflict_signs_use_current_side_range() {
1089        // Conflict on lines 5..7 (current). base + remote
1090        // have different ranges in the hunk but we should
1091        // only see slots [5, 6] in the map.
1092        let idx = HunkIndex {
1093            hunks: vec![Hunk {
1094                kind: HunkKind::Conflict,
1095                ranges: smallvec![
1096                    LineRange::new(10, 12), // base
1097                    LineRange::new(5, 7),   // local / current
1098                    LineRange::new(20, 22), // remote
1099                ],
1100                refine: Default::default(),
1101            }],
1102            algorithm: DiffAlgorithm::Histogram,
1103            revision: 1,
1104        };
1105        let map = compute_diff_sign_map(&idx);
1106        assert_eq!(map.len(), 2);
1107        assert_eq!(map.sign_at(5), Some(DiffSignKind::Conflict));
1108        assert_eq!(map.sign_at(6), Some(DiffSignKind::Conflict));
1109        // Base / remote slots not classified here.
1110        assert_eq!(map.sign_at(10), None);
1111        assert_eq!(map.sign_at(20), None);
1112    }
1113
1114    /// D.6.f: mixed Change + Conflict hunks keep their
1115    /// distinct classifications — a renderer walking the
1116    /// sign map sees both kinds in the order they appear.
1117    #[test]
1118    fn mixed_change_and_conflict_keep_distinct_signs() {
1119        let idx = HunkIndex {
1120            hunks: vec![
1121                Hunk {
1122                    kind: HunkKind::Change,
1123                    ranges: smallvec![LineRange::new(0, 1), LineRange::new(0, 1),],
1124                    refine: Default::default(),
1125                },
1126                Hunk {
1127                    kind: HunkKind::Conflict,
1128                    ranges: smallvec![
1129                        LineRange::new(5, 6),
1130                        LineRange::new(5, 6),
1131                        LineRange::new(5, 6),
1132                    ],
1133                    refine: Default::default(),
1134                },
1135            ],
1136            algorithm: DiffAlgorithm::Histogram,
1137            revision: 1,
1138        };
1139        let map = compute_diff_sign_map(&idx);
1140        assert_eq!(map.sign_at(0), Some(DiffSignKind::Change));
1141        assert_eq!(map.sign_at(5), Some(DiffSignKind::Conflict));
1142    }
1143
1144    #[test]
1145    fn sign_map_entries_sorted_by_line() {
1146        // Out-of-order hunks (a Change at high lines + an Add
1147        // at low) should still produce a sorted entry list so
1148        // binary search works.
1149        let idx = HunkIndex {
1150            hunks: vec![
1151                Hunk {
1152                    kind: HunkKind::Change,
1153                    ranges: smallvec![LineRange::new(0, 1), LineRange::new(100, 102)],
1154                    refine: Default::default(),
1155                },
1156                Hunk {
1157                    kind: HunkKind::Add,
1158                    ranges: smallvec![LineRange::new(5, 5), LineRange::new(10, 12)],
1159                    refine: Default::default(),
1160                },
1161            ],
1162            algorithm: DiffAlgorithm::Histogram,
1163            revision: 1,
1164        };
1165        let map = compute_diff_sign_map(&idx);
1166        let lines: Vec<u32> = map.entries().iter().map(|(l, _)| *l).collect();
1167        assert_eq!(lines, vec![10, 11, 100, 101]);
1168        assert_eq!(map.sign_at(10), Some(DiffSignKind::Add));
1169        assert_eq!(map.sign_at(100), Some(DiffSignKind::Change));
1170    }
1171
1172    #[test]
1173    fn session_sign_map_starts_empty() {
1174        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
1175        let map = s.sign_map();
1176        assert!(map.is_empty());
1177    }
1178
1179    #[test]
1180    fn session_publish_sign_map_round_trips() {
1181        let s = DiffSession::new(bid(1), DiffAlgorithm::Histogram);
1182        let new = compute_diff_sign_map(&HunkIndex {
1183            hunks: vec![Hunk {
1184                kind: HunkKind::Add,
1185                ranges: smallvec![LineRange::new(0, 0), LineRange::new(5, 7)],
1186                refine: Default::default(),
1187            }],
1188            algorithm: DiffAlgorithm::Histogram,
1189            revision: 4,
1190        });
1191        s.publish_sign_map(Arc::new(new));
1192        let snap = s.sign_map();
1193        assert_eq!(snap.len(), 2);
1194        assert_eq!(snap.revision(), 4);
1195        assert_eq!(snap.sign_at(5), Some(DiffSignKind::Add));
1196    }
1197
1198    #[test]
1199    fn multiple_hunks_merge_their_deletion_blocks() {
1200        // Two hunks: one Remove (2 baseline lines) at anchor
1201        // 10, one Change (1 baseline line) at anchor 50.
1202        let hunks = vec![
1203            Hunk {
1204                kind: HunkKind::Remove,
1205                ranges: smallvec![LineRange::new(0, 2), LineRange::new(10, 10)],
1206                refine: Default::default(),
1207            },
1208            Hunk {
1209                kind: HunkKind::Change,
1210                ranges: smallvec![LineRange::new(3, 4), LineRange::new(50, 51)],
1211                refine: Default::default(),
1212            },
1213        ];
1214        let s = session_with_hunks(bid(1), hunks);
1215        let rows = render(&s, "a\nb\nc\nd\n");
1216        assert_eq!(rows.len(), 3);
1217        // First 2 rows from the Remove hunk anchor at 10.
1218        assert_eq!(rows[0].anchor_line, 10);
1219        assert_eq!(rows[1].anchor_line, 10);
1220        // Last row from the Change hunk anchors at 50.
1221        assert_eq!(rows[2].anchor_line, 50);
1222    }
1223}