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(®);
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}