Skip to main content

lattice_magit/providers/
project_diff.rs

1//! PD.1 (2026-08-12): the **project-diff view** — every changed file in
2//! the working tree as one editable multibuffer.
3//!
4//! Design: `docs/dev/architecture/magit-project-diff.md`. Slice plan:
5//! `docs/dev/operations/slice-plans/magit-project-diff.md`.
6//! Catalogue entry: A.1 in `slice-plans/multibuffer-providers.md`.
7//!
8//! ## The gap this fills
9//!
10//! Magit already diffs in two shapes and neither is this one.
11//! `magit-status`'s sections are patch text built for staging;
12//! `*magit:diff:<path>*` reads well but is one file at a time and still
13//! patch text. Missing is *every changed file at once, as real source
14//! you can edit* — you spot a typo in file 19 of a 30-file review and
15//! today you must leave the diff, open the file, fix it, come back.
16//!
17//! ## Excerpts anchor in the working-tree file
18//!
19//! An excerpt is a hunk's post-image range in the file on disk, so
20//! edits propagate through the ordinary M.3 pipeline with no patch
21//! application and no write-back path of its own.
22//!
23//! That anchoring is also the constraint: **only the working tree is a
24//! file.** A staged-vs-HEAD or `rev..rev` comparison has an index blob
25//! as its post-image, with nothing for an edit to land in, so those
26//! open read-only rather than getting an invented index-write-back
27//! path. Read-only there is the correct rendering of a comparison
28//! between two things that are not the file on disk — not a degraded
29//! mode.
30
31use std::collections::HashMap;
32use std::path::PathBuf;
33use std::sync::Arc;
34
35use lattice_config::OptionOverrideSet;
36use lattice_core::{BufferFlags, BufferId, DocumentBuilder};
37use lattice_grammar::{Args, CommandRegistry, CommandRegistryHandle};
38use lattice_mode::{
39    ActionContext, ActionHandlerContribution, CapabilitySet, Keymap, LifecycleFuture, Mode,
40    ModeActivator, ModeContext, ModeId, ModeKind, ModeRegistry,
41};
42use lattice_multibuffer::view::create_multibuffer_view;
43use lattice_multibuffer::{
44    Excerpt, ExcerptHeader, HeaderlineStatus, MultibufferDocumentHandle, MultibufferExcerptsReady,
45    MultibufferRegistryHandle, MultibufferSourceEdited,
46};
47use lattice_runtime::{Document, EventBus, spawn_document};
48use lattice_syntax::LangRegistry;
49
50/// Context lines above and below each hunk. Wider than the ±2 the
51/// reference views use: this surface is for *reading a change in
52/// place*, where a little more surrounding code is what makes an edit
53/// safe to make without opening the file.
54const CONTEXT: u32 = 3;
55
56// ─────────────────────────────────────────────────────────────────
57// What is being compared
58// ─────────────────────────────────────────────────────────────────
59
60/// Which comparison a project-diff view shows.
61///
62/// Editability follows the post-image, and only the working tree is a
63/// file — see the module docs.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum ProjectDiffComparison {
66    /// Working tree vs `HEAD` — the daily driver, and the only
67    /// **editable** one.
68    #[default]
69    WorkingTree,
70    /// Index vs `HEAD`. Read-only: the post-image is an index blob.
71    Staged,
72}
73
74impl ProjectDiffComparison {
75    /// Does this comparison's post-image exist as a file an edit can
76    /// propagate into?
77    pub fn is_editable(self) -> bool {
78        matches!(self, Self::WorkingTree)
79    }
80
81    pub fn label(self) -> &'static str {
82        match self {
83            Self::WorkingTree => "working tree",
84            Self::Staged => "staged",
85        }
86    }
87}
88
89/// Per-view state: what it compares, and where.
90#[derive(Debug, Clone)]
91pub struct ProjectDiffState {
92    pub workdir: PathBuf,
93    pub comparison: ProjectDiffComparison,
94}
95
96/// PD.7c: everything needed to *re-publish* the view's diff styling
97/// after a source is edited, plus which sources have been.
98///
99/// The scan used to hold this in its own task locals, which was enough
100/// while the styling was written once and never touched again. The
101/// staleness policy has to rewrite it later, from a different task, so
102/// it lives with the view instead.
103#[derive(Default)]
104struct ProjectDiffStyling {
105    /// Per-source diff classification, as `composed_diff_spans` wants
106    /// it. An edited source is REMOVED from here — that is how its
107    /// tints stop being published.
108    changed_by_source: HashMap<BufferId, lattice_diff::overlay::DiffSignMap>,
109    /// The deletion-ghost provider, so an edited source's ghosts can be
110    /// cleared too. Ghosts anchor to post-image line numbers, so they
111    /// are wrong the moment the lines above them move.
112    deletion_rows: Option<Arc<ProjectDiffDeletionRows>>,
113    /// The publish seam for composed spans.
114    highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
115    /// The headerline summary the scan finished with, so the staleness
116    /// note can be appended to it rather than replacing what the view
117    /// says about itself.
118    summary: String,
119    /// Sources edited since the last scan. The policy runs once per
120    /// source: after the first edit its styling is already gone, and
121    /// re-publishing on every keystroke would be work for no change.
122    edited: std::collections::HashSet<BufferId>,
123}
124
125/// Per-view state keyed by the view's `BufferId`, plus a
126/// `DocumentId → BufferId` index for cleanup.
127///
128/// The second map is not redundant: `Event::DocumentClosed` carries a
129/// `DocumentId` and the two ids are NOT interchangeable — the
130/// multibuffer registry keeps a separate `remove_by_document_id` for
131/// the same reason.
132#[derive(Default)]
133pub struct ProjectDiffService {
134    views: std::sync::RwLock<HashMap<BufferId, ProjectDiffState>>,
135    by_document: std::sync::RwLock<HashMap<lattice_protocol::ids::DocumentId, BufferId>>,
136    /// PD.7c: per-view styling + staleness bookkeeping.
137    styling: std::sync::RwLock<HashMap<BufferId, ProjectDiffStyling>>,
138}
139
140impl std::fmt::Debug for ProjectDiffService {
141    /// Hand-written because `ProjectDiffStyling` holds provider handles
142    /// that are not `Debug`, and the useful thing to print is how many
143    /// views are tracked rather than their contents.
144    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145        f.debug_struct("ProjectDiffService")
146            .field("views", &self.tracked_views())
147            .finish_non_exhaustive()
148    }
149}
150
151impl ProjectDiffService {
152    pub fn new() -> Self {
153        Self::default()
154    }
155
156    pub fn set_state(&self, view: BufferId, state: ProjectDiffState) {
157        if let Ok(mut w) = self.views.write() {
158            w.insert(view, state);
159        }
160    }
161
162    pub fn state(&self, view: BufferId) -> Option<ProjectDiffState> {
163        self.views.read().ok()?.get(&view).cloned()
164    }
165
166    pub fn index_document(&self, document: lattice_protocol::ids::DocumentId, view: BufferId) {
167        if let Ok(mut w) = self.by_document.write() {
168            w.insert(document, view);
169        }
170    }
171
172    /// Cleanup entry point for the `DocumentClosed` subscriber.
173    pub fn forget_by_document_id(&self, document: lattice_protocol::ids::DocumentId) -> bool {
174        let view = match self.by_document.write() {
175            Ok(mut w) => w.remove(&document),
176            Err(_) => None,
177        };
178        match view {
179            Some(v) => {
180                self.forget(v);
181                true
182            }
183            None => false,
184        }
185    }
186
187    pub fn forget(&self, view: BufferId) {
188        if let Ok(mut w) = self.views.write() {
189            w.remove(&view);
190        }
191        if let Ok(mut w) = self.by_document.write() {
192            w.retain(|_, v| *v != view);
193        }
194    }
195
196    pub fn tracked_views(&self) -> usize {
197        self.views.read().map(|r| r.len()).unwrap_or(0)
198    }
199
200    // ── PD.7c: styling + staleness ───────────────────────────────
201
202    /// Start (or restart) a view's styling bookkeeping. Called at open
203    /// AND at every `gr`, which is what makes a refresh clear the
204    /// "edited" marks: the fresh scan's classification is computed
205    /// against the file as it now is, so nothing about it is stale.
206    pub fn begin_styling(
207        &self,
208        view: BufferId,
209        deletion_rows: Option<Arc<ProjectDiffDeletionRows>>,
210        highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
211    ) {
212        if let Ok(mut w) = self.styling.write() {
213            w.insert(
214                view,
215                ProjectDiffStyling {
216                    deletion_rows,
217                    highlights,
218                    ..Default::default()
219                },
220            );
221        }
222    }
223
224    /// Record one file's classification as the scan produces it.
225    pub fn record_source_styling(
226        &self,
227        view: BufferId,
228        source: BufferId,
229        changed: lattice_diff::overlay::DiffSignMap,
230        removed: Vec<(u32, Vec<String>)>,
231    ) {
232        if let Ok(mut w) = self.styling.write()
233            && let Some(entry) = w.get_mut(&view)
234        {
235            entry.changed_by_source.insert(source, changed);
236            if let Some(rows) = &entry.deletion_rows {
237                rows.set_for_source(source, removed);
238            }
239        }
240    }
241
242    /// Remember what the headerline settled on, so the staleness note
243    /// can extend it instead of replacing what the view says it is.
244    pub fn record_summary(&self, view: BufferId, summary: String) {
245        if let Ok(mut w) = self.styling.write()
246            && let Some(entry) = w.get_mut(&view)
247        {
248            entry.summary = summary;
249        }
250    }
251
252    /// Republish the composed spans from whatever classifications
253    /// survive. Returns `false` when there is nothing wired to publish
254    /// through (a test host with no highlight service) — the view then
255    /// renders uncoloured rather than failing.
256    pub fn republish_spans(&self, view: BufferId, handle: &MultibufferDocumentHandle) -> bool {
257        let Ok(r) = self.styling.read() else {
258            return false;
259        };
260        let Some(entry) = r.get(&view) else {
261            return false;
262        };
263        let Some(highlights) = entry.highlights.clone() else {
264            return false;
265        };
266        let spans = composed_diff_spans(handle, &entry.changed_by_source);
267        drop(r);
268        highlights.store_and_wake(view, spans);
269        true
270    }
271
272    /// **The staleness policy.** Drop everything this view derived from
273    /// `source`'s content, because the user has edited it.
274    ///
275    /// Returns the headerline summary to show, or `None` when this
276    /// source was already marked (every keystroke after the first) or
277    /// the view is not tracked.
278    ///
279    /// Clearing rather than recomputing is the decision PD.7c turns on.
280    /// The classification is computed against a baseline in *source line
281    /// coordinates*; an in-excerpt insert does not resize the excerpt
282    /// (`slide_anchors_for_source` only slides excerpts an edit sits
283    /// above), so the composed rows keep their indices while the text
284    /// beneath them moves — every tint below the edit then describes the
285    /// wrong line, and the deletion ghosts anchor a row out. Nothing
286    /// announces it. Recomputing the tints would fix them and still
287    /// leave the excerpt SET stale (an edit creating a new hunk gets no
288    /// excerpt; one edited back to the baseline keeps an excerpt showing
289    /// unchanged code) — liveness that looks total and is not. The
290    /// honest answer is to show no diff styling for a file we can no
291    /// longer describe, say so, and let `gr` rebuild.
292    pub fn mark_source_edited(&self, view: BufferId, source: BufferId) -> Option<String> {
293        let mut w = self.styling.write().ok()?;
294        let entry = w.get_mut(&view)?;
295        if !entry.edited.insert(source) {
296            return None;
297        }
298        entry.changed_by_source.remove(&source);
299        if let Some(rows) = &entry.deletion_rows {
300            // Empty, not absent: the provider keys on source, so this is
301            // "this file contributes no ghosts" and it bumps the version
302            // so the next frame re-collects.
303            rows.set_for_source(source, Vec::new());
304        }
305        Some(stale_summary(&entry.summary, entry.edited.len()))
306    }
307
308    /// Whether `source` has been edited since the last scan of `view`.
309    pub fn is_source_edited(&self, view: BufferId, source: BufferId) -> bool {
310        self.styling
311            .read()
312            .ok()
313            .and_then(|r| r.get(&view).map(|e| e.edited.contains(&source)))
314            .unwrap_or(false)
315    }
316}
317
318/// PD.7c: the headerline once the view can no longer describe part of
319/// itself.
320///
321/// Extends the scan's own summary rather than replacing it — the view
322/// still IS a working-tree diff of N files, and losing that to say
323/// "stale" would trade one missing fact for another. It names `gr`
324/// because the whole policy rests on the user having a way back to a
325/// correct view, and it counts the files so "I edited one thing" and "I
326/// have been working in here for ten minutes" do not read identically.
327fn stale_summary(summary: &str, edited: usize) -> String {
328    let files = if edited == 1 { "file" } else { "files" };
329    if summary.is_empty() {
330        format!("[project-diff] {edited} edited {files} — gr to refresh")
331    } else {
332        format!("{summary} · {edited} edited {files} — gr to refresh")
333    }
334}
335
336/// Register and look up under THIS alias, never the inner type — the
337/// `ServiceRegistry` keys on `TypeId`, so registering an
338/// `Arc<ProjectDiffService>` and asking for `ProjectDiffService`
339/// silently returns `None`.
340pub type ProjectDiffServiceHandle = Arc<ProjectDiffService>;
341
342// ─────────────────────────────────────────────────────────────────
343// MagitProjectDiffMode — identity marker
344// ─────────────────────────────────────────────────────────────────
345
346/// `magit-project-diff-mode` — the provider-minor activated on a
347/// project-diff view.
348///
349/// **Declares no keymap of its own.** It activates `magit-core-mode`
350/// alongside, which is where magit's cross-buffer chords already live
351/// (`gr`, `q`, `]]` / `[[`, …) — so this view inherits the family's
352/// chords by joining the family rather than by copying them. That is
353/// the "shared behaviour is a minor mode, never a copied keymap" rule
354/// paying out in the crate where the missing-`x` gap happened.
355pub struct MagitProjectDiffMode;
356
357impl MagitProjectDiffMode {
358    pub fn mode_id() -> ModeId {
359        ModeId::new("magit-project-diff-mode")
360    }
361}
362
363/// PD.7c: holds the staleness subscription for one view. Dropping it
364/// (buffer closed, mode deactivated) unsubscribes and stops the
365/// forwarder — the same RAII shape `ProjectSearchModeGuard` uses.
366pub struct MagitProjectDiffModeGuard {
367    forwarder: Option<tokio::task::JoinHandle<()>>,
368    subs: Vec<lattice_runtime::SubscriptionId>,
369    bus: Option<Arc<EventBus>>,
370}
371
372impl Drop for MagitProjectDiffModeGuard {
373    fn drop(&mut self) {
374        if let Some(h) = self.forwarder.take() {
375            h.abort();
376        }
377        if let Some(bus) = &self.bus {
378            for id in self.subs.drain(..) {
379                let _ = bus.unsubscribe(id);
380            }
381        }
382    }
383}
384
385impl Mode for MagitProjectDiffMode {
386    type Guard = MagitProjectDiffModeGuard;
387
388    fn id(&self) -> ModeId {
389        Self::mode_id()
390    }
391    fn kind(&self) -> ModeKind {
392        ModeKind::Minor
393    }
394    fn required_capabilities(&self) -> CapabilitySet {
395        CapabilitySet::empty()
396    }
397
398    /// Editable by default — no `ReadOnly` override. A read-only
399    /// comparison (§2.1) sets the per-buffer read-only property at
400    /// creation instead, so read-only is a *property of the view*, not
401    /// a second mode or a renderer kind-branch.
402    fn options(&self) -> OptionOverrideSet {
403        OptionOverrideSet::new()
404    }
405
406    /// No chords. `gr` / `q` arrive from `magit-core-mode`, which this
407    /// mode implies.
408    fn keymap(&self) -> Keymap {
409        Keymap::default()
410    }
411
412    /// Joining the magit family is what supplies the chords; declaring
413    /// them here would be the duplication the standing rule forbids.
414    fn implies(&self) -> &[ModeId] {
415        magit_core_implies()
416    }
417
418    /// PD.7c: what `gr` actually does here.
419    ///
420    /// `refreshable-view-mode` binds the chord and resolves it to
421    /// whichever active mode declared this — so implying that mode
422    /// without declaring an action, which is what PD.9 left behind,
423    /// bound `gr` in this view to nothing at all. The comment on
424    /// `implies` said "`refreshable-view-mode` supplies `gr`"; it
425    /// supplied the binding, and there was no target.
426    ///
427    /// Found while giving the headerline a "gr to refresh" note to point
428    /// at, which is the only reason it surfaced — a chord that resolves
429    /// to nothing produces no error, no log line and no failing test.
430    /// `refreshable_views_declare_their_refresh.rs` now guards the class.
431    fn refresh_action(&self) -> Option<&'static str> {
432        Some(REFRESH_ACTION)
433    }
434
435    /// The refresh body, in the crate that owns the view — the other
436    /// half of the mode-ownership rule. Re-running the provider IS the
437    /// refresh: `open_project_diff` already clears the view and rescans
438    /// (it has to, since the same view is reused across triggers), so
439    /// there is no second rebuild path to keep in step with the first.
440    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
441        vec![ActionHandlerContribution {
442            action_name: REFRESH_ACTION,
443            handler: Arc::new(|ctx: &ActionContext<'_>| {
444                let view = BufferId(ctx.buffer_id.0 as u32);
445                // Re-open with the comparison this view already holds.
446                // Reading it back matters: `gr` in a staged view must
447                // not silently turn it into a working-tree view.
448                let args = ctx
449                    .services
450                    .get::<ProjectDiffServiceHandle>()
451                    .and_then(|svc| svc.state(view))
452                    .map(|state| match state.comparison {
453                        ProjectDiffComparison::WorkingTree => Args::None,
454                        ProjectDiffComparison::Staged => Args::String("staged".to_string()),
455                    })
456                    .unwrap_or(Args::None);
457                Some(lattice_grammar::Effect::AppAction(
458                    lattice_grammar::app_effect::AppEffect::OpenProviderView {
459                        provider: PROVIDER_NAME.to_string(),
460                        args,
461                    },
462                ))
463            }),
464        }]
465    }
466
467    /// PD.7c: watch for edits to this view's sources and apply the
468    /// staleness policy.
469    ///
470    /// The mode owns the policy because the mode owns the view — the
471    /// substrate publishes the *fact* (`MultibufferSourceEdited`, with
472    /// the `DocumentId → source` translation already done, since the
473    /// source map lives there) and this decides what it means for
474    /// magit's diff data. No host involvement in either half.
475    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
476        let view = BufferId(ctx.buffer_id().0 as u32);
477        let bus = ctx.events_handle();
478        let service = ctx
479            .service::<ProjectDiffServiceHandle>()
480            .map(|s| (*s).clone());
481        let mb_registry = ctx.service::<MultibufferRegistryHandle>();
482        Box::pin(async move {
483            let (Some(service), Some(mb_registry)) = (service, mb_registry) else {
484                // A harness without the services: the view still renders,
485                // it just cannot report staleness. Degrade, do not refuse
486                // to activate.
487                tracing::debug!(
488                    "project-diff: no service / multibuffer registry; \
489                     staleness will not be reported"
490                );
491                return Ok(MagitProjectDiffModeGuard {
492                    forwarder: None,
493                    subs: Vec::new(),
494                    bus: None,
495                });
496            };
497            let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<MultibufferSourceEdited>();
498            let sub = bus.subscribe_typed::<MultibufferSourceEdited>(tx);
499            // No runtime in some test paths — the subscription is still
500            // live and harmless; only the drain cannot spawn.
501            let forwarder = if tokio::runtime::Handle::try_current().is_ok() {
502                let bus_for_task = bus.clone();
503                Some(tokio::spawn(async move {
504                    while let Some(edited) = rx.recv().await {
505                        // One bus, many views: ignore other views' sources.
506                        if edited.view != view {
507                            continue;
508                        }
509                        let Some(summary): Option<String> =
510                            service.mark_source_edited(view, edited.source)
511                        else {
512                            // Already marked — every keystroke after the
513                            // first lands here and does nothing.
514                            continue;
515                        };
516                        let Some(handle) = mb_registry.handle(view) else {
517                            continue;
518                        };
519                        service.republish_spans(view, &handle);
520                        handle.set_headerline(HeaderlineStatus::Complete {
521                            summary,
522                            emphasis: None,
523                        });
524                        // The re-publish has to reach the screen without
525                        // a keypress — the user is typing in ANOTHER
526                        // file's excerpt when this fires, and a stale
527                        // tint that clears only on the next unrelated
528                        // keystroke is the bug this policy exists to
529                        // remove.
530                        bus_for_task.publish_typed(MultibufferExcerptsReady { view });
531                    }
532                }))
533            } else {
534                None
535            };
536            Ok(MagitProjectDiffModeGuard {
537                forwarder,
538                subs: vec![sub],
539                bus: Some(bus),
540            })
541        })
542    }
543}
544
545fn magit_core_implies() -> &'static [ModeId] {
546    static IDS: std::sync::OnceLock<Vec<ModeId>> = std::sync::OnceLock::new();
547    // PD.9: `magit-nav-mode`, NOT `magit-core-mode`. This view is
548    // editable, and `magit-core-mode` claims `i`, `C`, `D`, `S`, `U`, `q`
549    // and `yr` — legitimate only because every major it attaches to is a
550    // read-only list, which its `ActivationPolicy::Majors` enforces.
551    // Reaching it through `implies` bypassed that gate, so `i` opened the
552    // .gitignore prompt instead of entering Insert. The same trap
553    // `magit-commit-mode` is excluded by name to avoid.
554    //
555    // `refreshable-view-mode` supplies `gr`, which this view does still
556    // want and which was never magit-core's to give.
557    IDS.get_or_init(|| {
558        vec![
559            crate::magit_nav_mode::MagitNavMode::mode_id(),
560            lattice_mode::RefreshableViewMode::mode_id(),
561        ]
562    })
563}
564
565// ─────────────────────────────────────────────────────────────────
566// View construction
567// ─────────────────────────────────────────────────────────────────
568
569/// Header for one hunk excerpt: the path plus the 1-based start line.
570fn hunk_excerpt_header(path: &std::path::Path, line0: u32) -> ExcerptHeader {
571    let mut header = ExcerptHeader::new(format!("{}", line0.saturating_add(1)));
572    header.path = Some(path.to_path_buf());
573    header
574}
575
576/// One changed file's hunks, as excerpt line ranges over the
577/// working-tree file.
578///
579/// Returns the post-image ranges — the lines as they exist on disk
580/// *now* — because that is what an excerpt anchors to and what an edit
581/// propagates into.
582/// PD.7a: post-image lines the diff touched, as a [`DiffSignMap`].
583///
584/// **Delegates to `lattice_diff::compute_diff_sign_map`.** This function
585/// used to walk the hunks itself and derive its own `(line, kind)` pairs,
586/// which was the same classification written twice — and drifting
587/// already: the hand-rolled version collapsed `Change` into `Add`, and
588/// had no answer for `Conflict` at all.
589///
590/// The distinction that makes this reuse rather than a workaround: diff
591/// classification is a **pure function** of two texts, and that is what
592/// is shared. `DiffSession` is the orthogonal thing — a lifecycle that
593/// watches buffers, debounces and republishes — and this view genuinely
594/// has a different one: its baselines come from git rather than from a
595/// sibling buffer, and it refreshes on a scan rather than on an edit.
596/// Depending on the pure core and not the stateful shell is the whole of
597/// the design here; manufacturing a baseline buffer per file so a session
598/// could be opened would have been contorting the data to fit the tool.
599pub fn changed_lines(before: &str, after: &str) -> lattice_diff::overlay::DiffSignMap {
600    let Ok(idx) = lattice_diff::compute_diff(
601        &[ropey::Rope::from_str(before), ropey::Rope::from_str(after)],
602        lattice_diff::DiffAlgorithm::Histogram,
603    ) else {
604        return lattice_diff::overlay::DiffSignMap::default();
605    };
606    lattice_diff::overlay::compute_diff_sign_map(&idx)
607}
608
609/// PD.7b: text the diff removed, grouped by the post-image line the
610/// removal sits above.
611///
612/// Taken from the PRE-image side (slot 0), which is the only place the
613/// removed text still exists — the post-image is the file, and the file
614/// no longer has it.
615pub fn removed_lines(before: &str, after: &str) -> Vec<(u32, Vec<String>)> {
616    use lattice_diff::{DiffAlgorithm, HunkKind};
617    let Ok(idx) = lattice_diff::compute_diff(
618        &[ropey::Rope::from_str(before), ropey::Rope::from_str(after)],
619        DiffAlgorithm::Histogram,
620    ) else {
621        return Vec::new();
622    };
623    let pre: Vec<&str> = before.lines().collect();
624    let mut out: Vec<(u32, Vec<String>)> = Vec::new();
625    for h in &idx.hunks {
626        if !matches!(h.kind, HunkKind::Remove | HunkKind::Change) {
627            continue;
628        }
629        let (Some(before_r), Some(after_r)) = (h.ranges.first(), h.ranges.get(1)) else {
630            continue;
631        };
632        let text: Vec<String> = (before_r.start..before_r.end)
633            .filter_map(|l| pre.get(l as usize).map(|s| (*s).to_string()))
634            .collect();
635        if text.is_empty() {
636            continue;
637        }
638        out.push((after_r.start, text));
639    }
640    out.sort_by_key(|(l, _)| *l);
641    out
642}
643
644pub fn file_hunk_ranges(before: &str, after: &str) -> Vec<(u32, u32)> {
645    use lattice_diff::{DiffAlgorithm, HunkKind};
646    let idx = lattice_diff::compute_diff(
647        &[ropey::Rope::from_str(before), ropey::Rope::from_str(after)],
648        DiffAlgorithm::Histogram,
649    );
650    let Ok(idx) = idx else {
651        return Vec::new();
652    };
653    let last_line = (after.lines().count() as u32).saturating_sub(1);
654    idx.hunks
655        .iter()
656        .filter_map(|h| {
657            // The post-image side. A pure Remove has an empty range
658            // there — anchor it at the deletion point so the excerpt
659            // still shows where the lines went.
660            let r = match h.kind {
661                HunkKind::Add | HunkKind::Change => *h.ranges.get(1)?,
662                HunkKind::Remove => {
663                    let at = h.ranges.get(1).map(|r| r.start).unwrap_or(0);
664                    lattice_diff::LineRange::new(at, at.saturating_add(1))
665                }
666                _ => return None,
667            };
668            // `LineRange` is half-open (`start..end`); `Excerpt::new`
669            // takes an INCLUSIVE end. Converting needs the `- 1`, and
670            // the clamp is to `last_line`, not `last_line + 1`.
671            //
672            // Both were wrong, and together they made every excerpt name
673            // at least one row the file does not have. That row is
674            // silently dropped when the text is composed
675            // (`compose_text_from_sources` skips a `None` line) but still
676            // gets an entry in the row translation — so the composed text
677            // ran one row SHORT of its own line-number map, and every row
678            // after the first such excerpt was numbered one low. Compounding
679            // per excerpt, which is why `<CR>` landed off by one too: the
680            // jump reads the same map.
681            let start = r.start.saturating_sub(CONTEXT);
682            let last_changed = r.end.saturating_sub(1);
683            let end = last_changed.saturating_add(CONTEXT).min(last_line);
684            Some((start, end.max(start)))
685        })
686        .collect()
687}
688
689/// One changed file, read and diffed: the working-tree text plus the
690/// post-image ranges its hunks occupy.
691///
692/// The two halves of building a batch are split around this type on
693/// purpose. Producing it is filesystem + CPU work
694/// ([`read_and_diff`], `spawn_blocking`-only); consuming it spawns
695/// document actors and touches the view ([`attach_batch`], async side).
696/// Fusing them — the PD.1 shape, where one function read, diffed and
697/// spawned — would have put every `read_to_string` and every diff on
698/// the actor thread the moment a trigger existed to call it.
699#[derive(Debug, Clone)]
700pub struct FileHunks {
701    pub path: PathBuf,
702    /// The working-tree text, as read during the scan.
703    pub text: String,
704    /// Post-image excerpt ranges, one per hunk, context already applied.
705    pub ranges: Vec<(u32, u32)>,
706    /// PD.7a: which post-image lines the diff touched, in **source** line
707    /// coordinates, sorted.
708    ///
709    /// Only `Add` and `Change` appear. A removed line has no post-image
710    /// row to paint — showing it needs a virtual row, which is PD.7b.
711    /// Recording nothing for it here is why this view currently reads as
712    /// "some lines are highlighted" rather than "these lines went away".
713    pub changed: lattice_diff::overlay::DiffSignMap,
714    /// PD.7b: lines the diff removed, keyed by the **post-image line they
715    /// sat above** and carrying the removed text.
716    ///
717    /// These have no row in the working-tree file — that is what "removed"
718    /// means — so they cannot be painted like `changed`. They render as
719    /// virtual rows anchored above the post-image line, which is also why
720    /// they are unselectable and untypeable: a line that is not in the file
721    /// is not a line you can edit, and the ghost row makes that visible
722    /// rather than surprising.
723    pub removed: Vec<(u32, Vec<String>)>,
724}
725
726/// **The blocking half.** Read each changed file and compute its hunk
727/// ranges against the baseline the scan collected.
728///
729/// Pure, no tokio, no view access — call it inside `spawn_blocking`.
730///
731/// A file that fails to read is logged and skipped: one unreadable path
732/// must not cost the user every other changed file. A file whose hunks
733/// all vanished (it was edited back to the baseline between the status
734/// call and the read) contributes nothing rather than an empty group.
735pub fn read_and_diff(files: &[(PathBuf, String)]) -> Vec<FileHunks> {
736    let mut out = Vec::with_capacity(files.len());
737    for (path, baseline) in files {
738        let text = match std::fs::read_to_string(path) {
739            Ok(t) => t,
740            Err(e) => {
741                tracing::warn!(
742                    path = %path.display(),
743                    error = %e,
744                    "project-diff: working-tree file unreadable; skipping it",
745                );
746                continue;
747            }
748        };
749        let ranges = file_hunk_ranges(baseline, &text);
750        if ranges.is_empty() {
751            continue;
752        }
753        let changed = changed_lines(baseline, &text);
754        let removed = removed_lines(baseline, &text);
755        out.push(FileHunks {
756            path: path.clone(),
757            text,
758            ranges,
759            changed,
760            removed,
761        });
762    }
763    out
764}
765
766/// **The view-touching half.** Spawn a source document per file, add it
767/// to `view`'s source map, and append one excerpt per hunk.
768///
769/// Returns the number of excerpts appended, so the caller can keep a
770/// running hunk count for the headerline without re-reading the view.
771///
772/// Appending (rather than replacing) is what makes the view fill
773/// progressively: the user sees the first files while the rest are
774/// still being read.
775/// PD.7b: the removed lines, as virtual rows.
776///
777/// A removed line has no row in the working-tree file — that is what
778/// removed means — so it cannot be painted like a changed one. It renders
779/// as a `DeletionBlock` virtual row anchored above the post-image line the
780/// deletion sat at.
781///
782/// **Composed anchors are computed here, per `collect()`, from the view's
783/// current excerpt list — never cached.** That is what makes the rows
784/// slide when the user types: an edit shifts the excerpts, the next
785/// collect reads the shifted excerpts, and the ghosts move with them. A
786/// stored composed anchor would detach on the first keystroke, which is
787/// the drift PD.7 flagged.
788///
789/// **Known ceiling, chosen deliberately:** these rows are display-only, so
790/// removed text cannot be searched, selected or copied. Zed shipped this
791/// same shape first and later spent a large refactor making deleted hunks
792/// ordinary text in the editor's coordinate space precisely to get those
793/// three back. We take the simpler form because it preserves the
794/// one-composed-row-one-source-line invariant the whole edit-propagation
795/// path rests on — the invariant whose violation caused the line-number
796/// off-by-one. If searchable deletions are wanted later, that is the
797/// change, and it is a substrate change rather than a provider one.
798#[derive(Debug)]
799pub struct ProjectDiffDeletionRows {
800    id: lattice_cells::ProviderId,
801    view: MultibufferDocumentHandle,
802    /// Removed text per source, keyed by the post-image line it sat above.
803    removed: std::sync::Mutex<HashMap<BufferId, Vec<(u32, Vec<String>)>>>,
804    version: std::sync::atomic::AtomicU64,
805}
806
807/// Namespace for the deletion-row provider, distinct from the
808/// multibuffer's own header / status / fold provider ids.
809const DELETION_ROW_NAMESPACE: u64 = 0xBBBB_0005_0000_0000;
810
811impl ProjectDiffDeletionRows {
812    pub fn new(view: MultibufferDocumentHandle, buffer_id: BufferId) -> Self {
813        Self {
814            id: DELETION_ROW_NAMESPACE | buffer_id.0 as u64,
815            view,
816            removed: std::sync::Mutex::new(HashMap::new()),
817            version: std::sync::atomic::AtomicU64::new(0),
818        }
819    }
820
821    /// Record a batch's removals and invalidate, so the next frame
822    /// re-collects rather than serving a cached row set.
823    pub fn set_for_source(&self, source: BufferId, removed: Vec<(u32, Vec<String>)>) {
824        if let Ok(mut map) = self.removed.lock() {
825            map.insert(source, removed);
826        }
827        self.version
828            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
829    }
830}
831
832impl lattice_cells::VirtualRowProvider for ProjectDiffDeletionRows {
833    fn id(&self) -> lattice_cells::ProviderId {
834        self.id
835    }
836
837    fn version(&self) -> u64 {
838        // Folded with the view's own content version so an excerpt
839        // append — which moves every composed anchor below it — also
840        // invalidates, not only a new batch of removals.
841        self.version.load(std::sync::atomic::Ordering::Relaxed) ^ self.view.snapshot().version
842    }
843
844    fn collect(&self) -> Vec<lattice_cells::VirtualRow> {
845        let Ok(removed) = self.removed.lock() else {
846            return Vec::new();
847        };
848        let excerpts = self.view.excerpts();
849        let mut rows = Vec::new();
850        let mut composed = 0u32;
851        for excerpt in &excerpts {
852            if let Some(entries) = removed.get(&excerpt.source) {
853                for (at, text) in entries {
854                    // Only removals that fall INSIDE this excerpt's span
855                    // have a row to anchor to. One outside it belongs to a
856                    // hunk this excerpt does not show.
857                    if *at < excerpt.start_line || *at > excerpt.end_line {
858                        continue;
859                    }
860                    let anchor = composed + (*at - excerpt.start_line);
861                    for line in text {
862                        rows.push(lattice_cells::VirtualRow {
863                            media: None,
864                            anchor_line: anchor,
865                            position: lattice_cells::AnchorPosition::Above,
866                            cells: line
867                                .chars()
868                                .map(|c| lattice_cells::Cell::new(c as u32, 0, 0, 0))
869                                .collect::<Vec<_>>()
870                                .into(),
871                            height: 1,
872                            kind: lattice_cells::VirtualRowKind::DeletionBlock,
873                            bg: None,
874                            scales: None,
875                            gutter_line: None,
876                            gutter_fg: None,
877                        });
878                    }
879                }
880            }
881            composed += excerpt.line_count();
882        }
883        rows
884    }
885}
886
887/// PD.7a: the composed-row spans that make a change visible.
888///
889/// Composed rows are excerpts laid end to end, so a source line's row
890/// index depends on every excerpt appended before it — which is why this
891/// runs after the batch has been appended and reads the view's own
892/// excerpt list rather than trying to predict it.
893///
894/// Published as **styled spans**, not as a diff session. The host already
895/// derives its gutter sign map from `Style::DiffAdd` / `DiffRemove`
896/// (`diff_signs_from_spans`), which is how magit's patch buffers get
897/// their signs — so one publish yields both the row tint and the gutter
898/// mark, and `lattice-multibuffer` gains no diff dependency, which PD.1
899/// asserts it must not.
900fn composed_diff_spans(
901    view: &MultibufferDocumentHandle,
902    changed_by_source: &HashMap<BufferId, lattice_diff::overlay::DiffSignMap>,
903) -> Vec<Vec<lattice_cells::StyledSpan>> {
904    use lattice_cells::style::Style;
905    let excerpts = view.excerpts();
906    let total: usize = excerpts.iter().map(|e| e.line_count() as usize).sum();
907    let mut rows: Vec<Vec<lattice_cells::StyledSpan>> = vec![Vec::new(); total];
908
909    let mut composed = 0usize;
910    for excerpt in &excerpts {
911        let changed = changed_by_source.get(&excerpt.source);
912        for offset in 0..excerpt.line_count() {
913            let source_line = excerpt.start_line + offset;
914            if let Some(kind) = changed.and_then(|c| {
915                let e = c.entries();
916                e.binary_search_by_key(&source_line, |(l, _)| *l)
917                    .ok()
918                    .map(|i| e[i].1)
919            }) {
920                // Only Add and Remove exist as text styles. Every kind
921                // that reaches here describes a line PRESENT in the
922                // post-image — `compute_diff_sign_map` skips `Remove`
923                // outright — so `DiffAdd` is accurate for what this rope
924                // actually contains rather than a fallback. The removal
925                // half is the row that is not there, which PD.7b renders
926                // as a virtual row.
927                let _ = kind;
928                let style = Style::DiffAdd;
929                // Whole-row span. The renderer paints the line background
930                // from it and the host reads the style for the gutter.
931                rows[composed + offset as usize] = vec![lattice_cells::StyledSpan {
932                    start: 0,
933                    end: usize::MAX,
934                    style,
935                }];
936            }
937        }
938        composed += excerpt.line_count() as usize;
939    }
940    rows
941}
942
943pub fn attach_batch(view: &MultibufferDocumentHandle, batch: &[FileHunks]) -> usize {
944    let mut appended = 0usize;
945    for file in batch {
946        let source_id = BufferId::next();
947        let document = DocumentBuilder::default()
948            .with_text(&file.text)
949            .with_path(file.path.clone())
950            .build();
951        // A source document in a provider view gets its own empty
952        // command registry behind the `ArcSwap` handle `spawn_document`
953        // expects — the same shape the search provider's sources use.
954        let source_registry = Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
955        let handle = spawn_document(source_id, document, source_registry);
956        view.add_source(source_id, Arc::new(handle) as Arc<dyn Document>);
957
958        let excerpts: Vec<Excerpt> = file
959            .ranges
960            .iter()
961            .map(|(start, end)| {
962                Excerpt::new(source_id, *start, *end)
963                    .with_header(hunk_excerpt_header(&file.path, *start))
964            })
965            .collect();
966        appended += excerpts.len();
967        view.append_excerpts(excerpts);
968    }
969    appended
970}
971
972// ─────────────────────────────────────────────────────────────────
973// The scan
974// ─────────────────────────────────────────────────────────────────
975
976/// Collect the changed files and their baseline text.
977///
978/// **Blocking by construction, and must run on `spawn_blocking`.** It
979/// shells out to git once for the status and once per changed file for
980/// the baseline blob; on the editor actor's `current_thread` runtime a
981/// bare `tokio::spawn` would put every one of those on the actor
982/// thread. Paramount goal #1 — see the standing rule.
983///
984/// Returns `(path, baseline_text)` pairs. A file whose baseline cannot
985/// be read is **skipped, not fatal**: a newly-added file has no `HEAD`
986/// blob at all, which is a normal state rather than an error, and one
987/// unreadable path must not cost the user every other changed file.
988pub fn scan_changed_files(
989    workdir: &std::path::Path,
990    comparison: ProjectDiffComparison,
991) -> Vec<(PathBuf, String)> {
992    use lattice_vcs::{GitBlob, Repository, WorkingTree};
993
994    let Ok(repo) = Repository::discover(workdir) else {
995        tracing::debug!(
996            workdir = %workdir.display(),
997            "project-diff: not a git repository"
998        );
999        return Vec::new();
1000    };
1001    let Ok(statuses) = WorkingTree::statuses(&repo) else {
1002        tracing::debug!("project-diff: `git status` failed");
1003        return Vec::new();
1004    };
1005
1006    let mut out = Vec::new();
1007    for (rel, change) in statuses {
1008        // Which axis the comparison reads. Working tree vs HEAD wants
1009        // anything that differs from HEAD at all; staged wants only
1010        // what is in the index.
1011        let relevant = match comparison {
1012            ProjectDiffComparison::WorkingTree => {
1013                change.staged.is_some() || change.unstaged.is_some()
1014            }
1015            ProjectDiffComparison::Staged => change.staged.is_some(),
1016        };
1017        if !relevant {
1018            continue;
1019        }
1020
1021        // The baseline side. An added file has no HEAD blob — treat it
1022        // as empty rather than skipping, so the whole file shows as
1023        // added rather than the file vanishing from the view.
1024        let baseline = GitBlob::read_path(&repo, "HEAD", &rel)
1025            .map(|r| r.to_string())
1026            .unwrap_or_default();
1027        out.push((workdir.join(&rel), baseline));
1028    }
1029    out
1030}
1031
1032// ─────────────────────────────────────────────────────────────────
1033// The trigger
1034// ─────────────────────────────────────────────────────────────────
1035
1036/// The name this provider is registered under in the
1037/// [`ProviderViewRegistry`]. Both front-ends —
1038/// `:magit-project-diff` and the Diff transient's `e` row — name it.
1039pub const PROVIDER_NAME: &str = "magit-project-diff";
1040
1041/// PD.7c: the action `gr` resolves to in this view, declared by
1042/// [`MagitProjectDiffMode::refresh_action`] and handled by the same
1043/// mode.
1044///
1045/// Its own id rather than `action:magit-refresh`: that one dispatches
1046/// through the `MagitView` trait to a per-buffer published view, which
1047/// this multibuffer is not — it is a provider view, refreshed by
1048/// re-running its provider.
1049pub const REFRESH_ACTION: &str = "action:magit-project-diff-refresh";
1050
1051/// The view's buffer name. Stable, so re-triggering finds the buffer
1052/// the user already has open instead of stacking a second one beside it
1053/// under the same name (which would make `:b *magit:project-diff*`
1054/// ambiguous).
1055pub const VIEW_NAME: &str = "*magit:project-diff*";
1056
1057/// Files read + diffed per batch before the view is touched.
1058///
1059/// Small enough that the first hunks land almost immediately on a large
1060/// working tree; large enough that a 200-file diff does not pay 200
1061/// round-trips between the blocking pool and the actor. Not a user
1062/// option — the right value is a property of the two costs, not of
1063/// anyone's preference.
1064const SCAN_BATCH: usize = 8;
1065
1066/// Which comparison the trigger's arguments asked for.
1067///
1068/// Anything unrecognised falls back to the working tree rather than
1069/// refusing: the working tree is the daily driver, and a typo in an
1070/// argument is a worse reason to show nothing than to show the default.
1071fn comparison_from_args(args: &Args) -> ProjectDiffComparison {
1072    let raw = match args {
1073        Args::String(s) => Some(s.trim().to_ascii_lowercase()),
1074        Args::List(values) => values.iter().find_map(|v| match v {
1075            lattice_grammar::ArgValue::String(s) => Some(s.trim().to_ascii_lowercase()),
1076            _ => None,
1077        }),
1078        _ => None,
1079    };
1080    match raw.as_deref() {
1081        Some("staged") | Some("index") => ProjectDiffComparison::Staged,
1082        _ => ProjectDiffComparison::WorkingTree,
1083    }
1084}
1085
1086/// Find the project-diff view already open, if there is one.
1087///
1088/// Name lookup alone is not enough — a buffer could carry the name
1089/// without being a live multibuffer (a stale registry entry, a test
1090/// harness) — so the candidate must also resolve to a multibuffer
1091/// handle before it is reused.
1092fn existing_view(services: &lattice_mode::ServiceRegistry) -> Option<BufferId> {
1093    let store = services.get::<lattice_mode::BufferStoreHandle>()?;
1094    let id = store.find_by_name(VIEW_NAME)?;
1095    let registry = services.get::<MultibufferRegistryHandle>()?;
1096    registry.handle(id).map(|_| id)
1097}
1098
1099/// Open (or re-drive) the project-diff view.
1100///
1101/// This is the closure registered on the generic provider-view seam, so
1102/// it is the whole of what the host does for this feature: the host arm
1103/// looks the name up, calls this with itself as the activator, and
1104/// applies the returned [`ProviderViewOutcome`].
1105///
1106/// The view opens **empty and immediately**; the scan runs off-thread
1107/// and streams into it. Re-triggering with the view already open
1108/// re-drives the scan into the same buffer rather than minting a second
1109/// one — which is also why the excerpts are cleared here rather than in
1110/// the task: the clear must be visible before the first batch lands, or
1111/// the old and new scans briefly show together.
1112pub fn open_project_diff(
1113    activator: &mut dyn ModeActivator,
1114    args: &Args,
1115) -> lattice_mode::ProviderViewOutcome {
1116    use lattice_mode::ProviderViewOutcome;
1117
1118    let comparison = comparison_from_args(args);
1119    // MR.6: the repository the view was opened over, through the same
1120    // record every other magit surface reads. `magit_workdir()` stays as
1121    // the fall-back for an activator with no active buffer.
1122    let scopes = activator
1123        .services()
1124        .get::<crate::repo_scope::RepoScopesHandle>();
1125    let store = activator
1126        .services()
1127        .get::<lattice_mode::BufferStoreHandle>();
1128    let resolved = match (activator.active_buffer(), store, scopes) {
1129        (Some(buffer), Some(store), Some(scopes)) => {
1130            crate::repo_scope::active_workdir(&store, &scopes, buffer)
1131        }
1132        _ => None,
1133    };
1134    let Some(workdir) = resolved.or_else(crate::workdir::magit_workdir) else {
1135        return ProviderViewOutcome::Declined {
1136            message: "magit: not inside a git repository".to_string(),
1137        };
1138    };
1139
1140    let services = activator.services();
1141    let Some(registry) = services.get::<CommandRegistryHandle>() else {
1142        return ProviderViewOutcome::Declined {
1143            message: "magit: command registry unavailable; cannot open the project diff"
1144                .to_string(),
1145        };
1146    };
1147    let lang_registry = services.get::<Arc<LangRegistry>>().map(|h| (*h).clone());
1148
1149    let reopened = existing_view(&services);
1150    let view = match reopened {
1151        Some(view) => view,
1152        None => create_multibuffer_view(
1153            activator,
1154            HashMap::new(),
1155            Vec::new(),
1156            Some(VIEW_NAME.to_string()),
1157            BufferFlags::default(),
1158            (*registry).clone(),
1159            lang_registry,
1160            // AF.1: hunks are titled with their LINE NUMBER, so titles are
1161            // neither unique nor a grouping key; the source path is. Default.
1162            lattice_multibuffer::FoldGrouping::SourceFile,
1163        ),
1164    };
1165
1166    let Some(mb_registry) = services.get::<MultibufferRegistryHandle>() else {
1167        return ProviderViewOutcome::Declined {
1168            message: "magit: multibuffer registry unavailable; cannot open the project diff"
1169                .to_string(),
1170        };
1171    };
1172    let Some(handle) = mb_registry.handle(view) else {
1173        return ProviderViewOutcome::Declined {
1174            message: "magit: the project-diff view failed to open".to_string(),
1175        };
1176    };
1177
1178    if let Some(svc) = services.get::<ProjectDiffServiceHandle>() {
1179        svc.set_state(
1180            view,
1181            ProjectDiffState {
1182                workdir: workdir.clone(),
1183                comparison,
1184            },
1185        );
1186        svc.index_document(handle.document_id(), view);
1187    } else {
1188        tracing::debug!("project-diff: service not registered; view state will not be tracked");
1189    }
1190
1191    // Empty the view before the scan starts. On a first open this is a
1192    // no-op; on a re-trigger it is what stops the previous scan's
1193    // excerpts from sitting above the new ones.
1194    handle.replace_excerpts(HashMap::new(), Vec::new());
1195    handle.set_headerline(HeaderlineStatus::InProgress {
1196        label: format!("Computing {} diff", comparison.label()),
1197        count: Some(0),
1198        emphasis: None,
1199    });
1200
1201    // The repository this view is diffing. Its buffer is a multibuffer with
1202    // no path, so it needs saying explicitly — the peer of the `RepoScopes`
1203    // registration that covers magit's name-opened views.
1204    activator.set_buffer_scope_dir(view, workdir.clone());
1205
1206    activator.activate_minor_by_id(view, MagitProjectDiffMode::mode_id());
1207
1208    // PD.4: editability follows the post-image (design §2.1). A working
1209    // tree is a file an edit can propagate into; an index blob is not,
1210    // and neither is a revision's blob — so those open read-only through
1211    // the generic minor, never a magit-local gate or a kind-branch.
1212    //
1213    // Both arms fire, and the `else` is the load-bearing one: the view
1214    // is reused across triggers, so opening the staged diff and then the
1215    // working-tree diff must *clear* the mode. Activating conditionally
1216    // and never deactivating is how the second view ends up silently
1217    // unwritable, in a buffer that looks exactly like a writable one.
1218    if comparison.is_editable() {
1219        activator.deactivate_minor_by_id(view, lattice_mode::modes::ReadOnlyMode::mode_id());
1220    } else {
1221        activator.activate_minor_by_id(view, lattice_mode::modes::ReadOnlyMode::mode_id());
1222    }
1223
1224    let events = services.get::<Arc<EventBus>>().map(|b| (*b).clone());
1225    // PD.7a: the seam the diff styling rides on. Absent in a test host
1226    // that wired no highlight service — the view then renders uncoloured
1227    // rather than failing, which is the graceful-degradation rule.
1228    let synthetic_highlights = services
1229        .get::<lattice_mode::PendingSyntheticHighlightsHandle>()
1230        .map(|h| (*h).clone());
1231    // PD.7b: created and registered HERE, synchronously, because
1232    // `register_virtual_row_provider` needs `&mut` on the activator and
1233    // the scan is async. The scan then only pushes data into it.
1234    let deletion_rows = mb_registry.handle(view).map(|h| {
1235        let rows = Arc::new(ProjectDiffDeletionRows::new((*h).clone(), view));
1236        activator.register_virtual_row_provider(view, rows.clone());
1237        rows
1238    });
1239    // PD.7c: (re)start the styling bookkeeping. On a `gr` this is what
1240    // clears the previous scan's "edited" marks — the fresh
1241    // classification is computed against the files as they now are, so
1242    // nothing about it is stale.
1243    let service = services
1244        .get::<ProjectDiffServiceHandle>()
1245        .map(|s| (*s).clone());
1246    if let Some(svc) = &service {
1247        svc.begin_styling(view, deletion_rows.clone(), synthetic_highlights.clone());
1248    }
1249    spawn_project_diff_scan(
1250        view,
1251        workdir,
1252        comparison,
1253        mb_registry,
1254        events,
1255        synthetic_highlights,
1256        deletion_rows,
1257        service,
1258    );
1259
1260    ProviderViewOutcome::Opened {
1261        view,
1262        message: Some(format!(
1263            "project-diff: scanning the {} …",
1264            comparison.label()
1265        )),
1266    }
1267}
1268
1269/// Run the scan off-thread and stream its batches into `view`.
1270///
1271/// Shape, and why:
1272///
1273/// - The git status + baseline reads and the per-file read + diff both
1274///   run under **`spawn_blocking`**. The editor actor is a
1275///   `current_thread` runtime, so a bare `tokio::spawn` for that work
1276///   would land every `read_to_string` and every diff on the actor
1277///   thread — paramount goal #1's forbidden pattern, and the reason
1278///   PD.2 documented `scan_changed_files` as blocking-only before any
1279///   caller existed.
1280/// - Excerpts are appended **per batch**, so the view fills
1281///   progressively instead of blinking from empty to complete.
1282/// - Each batch publishes [`MultibufferExcerptsReady`], which is the
1283///   registered off-keystroke wake. Without it the excerpts would sit
1284///   invisible until the user happened to press a key — the bug class
1285///   that reads as a rendering fault and is not one.
1286///
1287/// There is no typed batch event + forwarder pair here (the shape
1288/// `providers::search` uses) because producer and consumer are the same
1289/// task: the bus exists to decouple a producer from an unknown set of
1290/// subscribers, and inventing one for a single known consumer would be
1291/// indirection without a reader.
1292fn spawn_project_diff_scan(
1293    view: BufferId,
1294    workdir: PathBuf,
1295    comparison: ProjectDiffComparison,
1296    mb_registry: Arc<MultibufferRegistryHandle>,
1297    events: Option<Arc<EventBus>>,
1298    synthetic_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
1299    deletion_rows: Option<Arc<ProjectDiffDeletionRows>>,
1300    service: Option<ProjectDiffServiceHandle>,
1301) {
1302    let editable_note = if comparison.is_editable() {
1303        ""
1304    } else {
1305        " (read-only)"
1306    };
1307
1308    tokio::spawn(async move {
1309        let scanned = tokio::task::spawn_blocking({
1310            let workdir = workdir.clone();
1311            move || scan_changed_files(&workdir, comparison)
1312        })
1313        .await;
1314        let files = match scanned {
1315            Ok(files) => files,
1316            Err(e) => {
1317                tracing::warn!(error = %e, "project-diff: the scan task failed");
1318                Vec::new()
1319            }
1320        };
1321
1322        // The view may have been closed while the scan ran. Every
1323        // handle lookup below re-checks, so a closed view ends the task
1324        // instead of appending into a registry entry nobody reads.
1325        let Some(handle) = mb_registry.handle(view) else {
1326            return;
1327        };
1328
1329        if files.is_empty() {
1330            handle.set_headerline(HeaderlineStatus::Complete {
1331                summary: format!(
1332                    "[project-diff: {}{editable_note}] no changes",
1333                    comparison.label()
1334                ),
1335                emphasis: None,
1336            });
1337            if let Some(events) = &events {
1338                events.publish_typed(MultibufferExcerptsReady { view });
1339            }
1340            return;
1341        }
1342
1343        let total_files = files.len();
1344        let mut files_done = 0usize;
1345        let mut hunks = 0usize;
1346        // PD.7a: accumulated across batches, because the composed spans
1347        // are rebuilt whole each time and every source's classification
1348        // has to still be there when a later batch triggers the rebuild.
1349        let mut changed_by_source: HashMap<BufferId, lattice_diff::overlay::DiffSignMap> =
1350            HashMap::new();
1351
1352        for chunk in files.chunks(SCAN_BATCH) {
1353            let owned: Vec<(PathBuf, String)> = chunk.to_vec();
1354            let built = match tokio::task::spawn_blocking(move || read_and_diff(&owned)).await {
1355                Ok(built) => built,
1356                Err(e) => {
1357                    tracing::warn!(error = %e, "project-diff: a batch failed to read; skipping it");
1358                    continue;
1359                }
1360            };
1361
1362            let Some(handle) = mb_registry.handle(view) else {
1363                return;
1364            };
1365            hunks += attach_batch(&handle, &built);
1366            // PD.7a: repaint the whole view after each batch. Rebuilding
1367            // every row rather than appending is what keeps the spans
1368            // aligned — an excerpt appended now shifts nothing, but a
1369            // later batch's rows sit after these, and `Replace` is the
1370            // op that cannot drift out of step with the rope.
1371            for f in &built {
1372                if let Some(id) = handle
1373                    .source_buffer_ids()
1374                    .into_iter()
1375                    .find(|id| handle.source_path(*id).as_deref() == Some(f.path.as_path()))
1376                {
1377                    // PD.7c: a source the user has already edited must
1378                    // NOT be re-styled by a batch still landing from the
1379                    // scan that was running when they typed. Its
1380                    // classification describes a file that no longer
1381                    // exists in that shape, and publishing it would undo
1382                    // the clearing a moment after it happened.
1383                    if service
1384                        .as_ref()
1385                        .is_some_and(|svc| svc.is_source_edited(view, id))
1386                    {
1387                        continue;
1388                    }
1389                    changed_by_source.insert(id, f.changed.clone());
1390                    if let Some(rows) = &deletion_rows {
1391                        rows.set_for_source(id, f.removed.clone());
1392                    }
1393                    // PD.7c: the same classification, kept where the
1394                    // edit handler can find it — the scan's locals do
1395                    // not outlive the scan.
1396                    if let Some(svc) = &service {
1397                        svc.record_source_styling(view, id, f.changed.clone(), f.removed.clone());
1398                    }
1399                }
1400            }
1401            if let Some(pending) = &synthetic_highlights {
1402                pending.store_and_wake(view, composed_diff_spans(&handle, &changed_by_source));
1403            }
1404            files_done += chunk.len();
1405
1406            handle.set_headerline(HeaderlineStatus::InProgress {
1407                label: format!(
1408                    "Computing {} diff ({files_done}/{total_files} files)",
1409                    comparison.label()
1410                ),
1411                count: Some(hunks),
1412                emphasis: None,
1413            });
1414            if let Some(events) = &events {
1415                events.publish_typed(MultibufferExcerptsReady { view });
1416            }
1417        }
1418
1419        let Some(handle) = mb_registry.handle(view) else {
1420            return;
1421        };
1422        // The file count is the number of files that actually produced
1423        // hunks, which can be lower than the scanned count: a file can be
1424        // edited back to its baseline between `git status` and the read.
1425        let shown_files = handle.source_buffer_ids().len();
1426        let summary = format!(
1427            "[project-diff: {}{editable_note}] {hunks} hunks in {shown_files} files",
1428            comparison.label()
1429        );
1430        // PD.7c: remember it, so the staleness note extends this line
1431        // rather than replacing what the view says it is.
1432        if let Some(svc) = &service {
1433            svc.record_summary(view, summary.clone());
1434        }
1435        handle.set_headerline(HeaderlineStatus::Complete {
1436            summary,
1437            emphasis: None,
1438        });
1439        if let Some(events) = &events {
1440            events.publish_typed(MultibufferExcerptsReady { view });
1441        }
1442    });
1443}
1444
1445// ─────────────────────────────────────────────────────────────────
1446// Boot integration
1447// ─────────────────────────────────────────────────────────────────
1448
1449pub fn register_project_diff_mode(modes: &mut ModeRegistry) {
1450    modes
1451        .register(MagitProjectDiffMode)
1452        .expect("magit-project-diff-mode registers without conflict at boot");
1453}
1454
1455/// Register the view opener on the generic provider-view seam.
1456///
1457/// This — plus an ex-command and a transient row, both also in this
1458/// crate — is the whole of the trigger. No `Editor::` method, no host
1459/// `Action` variant, no dispatch arm: the acid test a provider crate is
1460/// supposed to pass.
1461///
1462/// A missing registry means the host did not publish the seam (an older
1463/// boot, or a test harness); logged and skipped, because refusing to
1464/// boot over an unavailable optional surface is the worse failure.
1465pub fn register_project_diff_provider(services: &lattice_mode::ServiceRegistry) {
1466    let Some(registry) = services.get::<lattice_mode::ProviderViewRegistryHandle>() else {
1467        tracing::debug!(
1468            "project-diff: no ProviderViewRegistry; `:magit-project-diff` will not be available"
1469        );
1470        return;
1471    };
1472    if !registry.register(
1473        PROVIDER_NAME,
1474        Arc::new(|activator: &mut dyn ModeActivator, args: &Args| {
1475            open_project_diff(activator, args)
1476        }),
1477    ) {
1478        tracing::warn!(
1479            provider = PROVIDER_NAME,
1480            "project-diff: a provider view is already registered under this name"
1481        );
1482    }
1483}
1484
1485#[cfg(test)]
1486mod tests {
1487    #![allow(clippy::unwrap_used)]
1488    use super::*;
1489
1490    #[test]
1491    fn only_the_working_tree_is_editable() {
1492        assert!(ProjectDiffComparison::WorkingTree.is_editable());
1493        assert!(
1494            !ProjectDiffComparison::Staged.is_editable(),
1495            "an index blob is not a file; an edit has nowhere to land"
1496        );
1497    }
1498
1499    /// PD.7c: `gr` in this view had nothing to resolve to.
1500    ///
1501    /// `refreshable-view-mode` binds the chord and dispatches whatever
1502    /// an active mode *declared*; PD.9 moved this view onto that mode
1503    /// and left the declaration behind, so the comment said `gr` was
1504    /// supplied while the key did nothing. Nothing failed — an
1505    /// unresolved chord is silent — which is why the guard is a test in
1506    /// `lattice-host` over the booted registry as well as this one.
1507    #[test]
1508    fn gr_resolves_to_this_views_own_refresh() {
1509        let m = MagitProjectDiffMode;
1510        assert!(
1511            m.implies()
1512                .contains(&lattice_mode::RefreshableViewMode::mode_id()),
1513            "the chord arrives through the cascade"
1514        );
1515        assert_eq!(
1516            m.refresh_action(),
1517            Some(REFRESH_ACTION),
1518            "…and it must have a target"
1519        );
1520        assert!(
1521            m.action_handlers()
1522                .iter()
1523                .any(|c| c.action_name == REFRESH_ACTION),
1524            "the mode that declares the target also supplies its body — \
1525             declaring it and leaving the handler to the host is the \
1526             half-migration the standing rule forbids"
1527        );
1528    }
1529
1530    /// The refresh must re-run the comparison the view already shows.
1531    /// Defaulting to the working tree would mean `gr` silently turns a
1532    /// staged diff into a different view — a refresh that resets its own
1533    /// arguments.
1534    ///
1535    /// Runs the registered handler, so the service lookup and the
1536    /// arg translation are both under test rather than restated.
1537    #[test]
1538    fn refreshing_re_runs_the_comparison_the_view_already_shows() {
1539        fn refresh_args_for(state: Option<ProjectDiffState>) -> Args {
1540            let mut services = lattice_mode::ServiceRegistry::new();
1541            let svc: ProjectDiffServiceHandle = Arc::new(ProjectDiffService::new());
1542            let view = BufferId(4242);
1543            if let Some(state) = state {
1544                svc.set_state(view, state);
1545            }
1546            services.register::<ProjectDiffServiceHandle>(svc);
1547            let events = lattice_runtime::EventBus::new();
1548            let ctx = ActionContext {
1549                buffer_id: lattice_protocol::ids::BufferId::new(view.0 as u64),
1550                cursor: lattice_protocol::position::Position::new(0, 0),
1551                selection: None,
1552                services: &services,
1553                events: &events,
1554                prompt_value: None,
1555                args: Args::None,
1556                buffer_locals: None,
1557            };
1558            let handler = MagitProjectDiffMode
1559                .action_handlers()
1560                .into_iter()
1561                .find(|c| c.action_name == REFRESH_ACTION)
1562                .expect("the mode contributes its refresh handler")
1563                .handler;
1564            match (handler)(&ctx) {
1565                Some(lattice_grammar::Effect::AppAction(
1566                    lattice_grammar::app_effect::AppEffect::OpenProviderView { provider, args },
1567                )) => {
1568                    assert_eq!(provider, PROVIDER_NAME, "re-runs THIS provider");
1569                    args
1570                }
1571                other => panic!("expected an OpenProviderView effect, got {other:?}"),
1572            }
1573        }
1574
1575        let staged = refresh_args_for(Some(ProjectDiffState {
1576            workdir: PathBuf::from("/tmp"),
1577            comparison: ProjectDiffComparison::Staged,
1578        }));
1579        assert!(
1580            matches!(staged, Args::String(ref s) if s == "staged"),
1581            "a staged view must refresh as staged; got {staged:?}"
1582        );
1583
1584        let working = refresh_args_for(Some(ProjectDiffState {
1585            workdir: PathBuf::from("/tmp"),
1586            comparison: ProjectDiffComparison::WorkingTree,
1587        }));
1588        assert!(matches!(working, Args::None), "got {working:?}");
1589
1590        // No state (the service was never told about this view — a
1591        // stripped harness, or a view whose state was dropped): the
1592        // working tree is the honest default, and refusing to refresh
1593        // at all would leave `gr` dead again.
1594        let unknown = refresh_args_for(None);
1595        assert!(matches!(unknown, Args::None), "got {unknown:?}");
1596    }
1597
1598    /// The mode joins the magit family rather than copying its chords —
1599    /// the thing RV.2 spent a slice undoing elsewhere.
1600    ///
1601    /// PD.9 changed WHICH part of the family it joins. It implied
1602    /// `magit-core-mode`, which claims `i`, `C`, `D`, `S`, `U`, `q` and
1603    /// `yr` — legitimate only on the read-only lists its
1604    /// `ActivationPolicy::Majors` gate allows, and this view is editable,
1605    /// so `i` opened the .gitignore prompt instead of entering Insert.
1606    /// It now implies `magit-nav-mode` (the chords that are safe
1607    /// anywhere) plus `refreshable-view-mode` for `gr`.
1608    #[test]
1609    fn the_mode_inherits_magit_chords_and_declares_none() {
1610        let m = MagitProjectDiffMode;
1611        assert_eq!(m.kind(), ModeKind::Minor);
1612        assert!(
1613            m.keymap().entries.is_empty() && m.keymap().bindings.is_empty(),
1614            "no chords of its own"
1615        );
1616        assert!(
1617            m.implies()
1618                .contains(&crate::magit_nav_mode::MagitNavMode::mode_id()),
1619            "must join magit-nav-mode to get ]] / [[ / <Tab> without the \
1620             read-only letters"
1621        );
1622    }
1623
1624    #[test]
1625    fn a_changed_file_yields_one_excerpt_range_per_hunk() {
1626        let before = "a\nb\nc\nd\ne\nf\ng\nh\n";
1627        let after = "a\nB\nc\nd\ne\nf\nG\nh\n";
1628        let ranges = file_hunk_ranges(before, after);
1629        assert_eq!(ranges.len(), 2, "two separated changes → two hunks");
1630        for (s, e) in &ranges {
1631            assert!(s <= e, "range is ordered: {s}..{e}");
1632        }
1633    }
1634
1635    #[test]
1636    fn an_unchanged_file_yields_no_ranges() {
1637        assert!(file_hunk_ranges("same\n", "same\n").is_empty());
1638    }
1639
1640    /// Context must not run past the end of the file — a hunk at the
1641    /// last line would otherwise produce an out-of-range excerpt.
1642    #[test]
1643    fn context_clamps_at_the_end_of_the_file() {
1644        let before = "a\nb\nc\n";
1645        let after = "a\nb\nZ\n";
1646        let last = (after.lines().count() as u32) - 1;
1647        for (_, end) in file_hunk_ranges(before, after) {
1648            assert!(end <= last + 1, "end {end} past last line {last}");
1649        }
1650    }
1651
1652    #[test]
1653    fn no_files_yields_nothing_to_show() {
1654        assert!(read_and_diff(&[]).is_empty());
1655    }
1656
1657    // ── PD.2: the scan ───────────────────────────────────────────
1658
1659    fn git(dir: &std::path::Path, args: &[&str]) {
1660        let st = std::process::Command::new("git")
1661            .args(args)
1662            .current_dir(dir)
1663            .status()
1664            .expect("git");
1665        assert!(st.success(), "git {args:?} failed");
1666    }
1667
1668    /// A repo with one committed file modified, and one brand-new file.
1669    fn repo_with_changes() -> tempfile::TempDir {
1670        let dir = tempfile::tempdir().expect("tempdir");
1671        let p = dir.path();
1672        git(p, &["init"]);
1673        git(p, &["config", "user.email", "t@lattice.dev"]);
1674        git(p, &["config", "user.name", "lattice-test"]);
1675        std::fs::write(p.join("tracked.rs"), "fn main() {\n    let old = 1;\n}\n").unwrap();
1676        git(p, &["add", "tracked.rs"]);
1677        git(p, &["commit", "-m", "base"]);
1678        std::fs::write(p.join("tracked.rs"), "fn main() {\n    let new = 2;\n}\n").unwrap();
1679        std::fs::write(p.join("added.rs"), "fn fresh() {}\n").unwrap();
1680        git(p, &["add", "added.rs"]);
1681        dir
1682    }
1683
1684    #[test]
1685    fn the_scan_finds_modified_and_added_files() {
1686        let dir = repo_with_changes();
1687        let found = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
1688        let names: Vec<String> = found
1689            .iter()
1690            .filter_map(|(p, _)| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1691            .collect();
1692        assert!(names.contains(&"tracked.rs".to_string()), "got {names:?}");
1693        assert!(names.contains(&"added.rs".to_string()), "got {names:?}");
1694    }
1695
1696    /// A newly-added file has no HEAD blob. That is a normal state, not
1697    /// an error — it must come back with an EMPTY baseline so the whole
1698    /// file reads as added, rather than vanishing from the view.
1699    #[test]
1700    fn an_added_file_gets_an_empty_baseline_rather_than_being_skipped() {
1701        let dir = repo_with_changes();
1702        let found = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
1703        let added = found
1704            .iter()
1705            .find(|(p, _)| p.ends_with("added.rs"))
1706            .expect("the added file is in the scan");
1707        assert!(added.1.is_empty(), "no HEAD blob ⇒ empty baseline");
1708    }
1709
1710    /// The staged comparison reads the index axis only, so a purely
1711    /// unstaged modification is not in it.
1712    #[test]
1713    fn the_staged_comparison_excludes_unstaged_only_changes() {
1714        let dir = repo_with_changes();
1715        let staged = scan_changed_files(dir.path(), ProjectDiffComparison::Staged);
1716        let names: Vec<String> = staged
1717            .iter()
1718            .filter_map(|(p, _)| p.file_name().map(|n| n.to_string_lossy().into_owned()))
1719            .collect();
1720        assert!(
1721            names.contains(&"added.rs".to_string()),
1722            "added.rs was `git add`ed: {names:?}"
1723        );
1724        assert!(
1725            !names.contains(&"tracked.rs".to_string()),
1726            "tracked.rs is modified but unstaged: {names:?}"
1727        );
1728    }
1729
1730    /// Not a repository is a normal thing to point at, not a panic.
1731    #[test]
1732    fn a_non_repository_scans_to_nothing() {
1733        let dir = tempfile::tempdir().unwrap();
1734        assert!(scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree).is_empty());
1735    }
1736
1737    /// End to end across the blocking half: the scan feeds
1738    /// `read_and_diff`, and the modified file comes back with hunks.
1739    #[test]
1740    fn the_scan_feeds_the_blocking_half() {
1741        let dir = repo_with_changes();
1742        let files = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
1743        let built = read_and_diff(&files);
1744        assert!(!built.is_empty(), "changed files yield hunks");
1745        assert!(
1746            built.iter().all(|f| !f.ranges.is_empty()),
1747            "a file with no surviving hunks is dropped, not carried empty"
1748        );
1749        assert!(
1750            built.iter().any(|f| f.path.ends_with("tracked.rs")),
1751            "the modified file is in the built batch: {:?}",
1752            built.iter().map(|f| &f.path).collect::<Vec<_>>()
1753        );
1754    }
1755
1756    // ── PD.8: excerpt ranges must name rows the file has ─────────
1757
1758    /// Reported as "the line numbers are off by one, and `<CR>` lands one
1759    /// line off". One cause: `LineRange` is half-open and `Excerpt::new`
1760    /// takes an inclusive end, so every range named one row too many —
1761    /// and the clamp allowed `last_line + 1`, a row past EOF.
1762    ///
1763    /// The symptom is indirect, which is why it is worth a test at this
1764    /// level: a row the source does not have is skipped when the text is
1765    /// composed but still counted in the row translation, so the composed
1766    /// text runs short of its own line-number map and everything below is
1767    /// numbered low. Compounding per excerpt.
1768    #[test]
1769    fn an_excerpt_never_names_a_row_past_the_end_of_the_file() {
1770        // Change the LAST line, so context pushes the range against EOF.
1771        let before = "a\nb\nc\n";
1772        let after = "a\nb\nCHANGED\n";
1773        let last = (after.lines().count() as u32) - 1;
1774        for (start, end) in file_hunk_ranges(before, after) {
1775            assert!(
1776                end <= last,
1777                "excerpt ({start},{end}) names row {end}, past the last line {last}"
1778            );
1779        }
1780    }
1781
1782    /// The inclusive end must be the last CHANGED line plus context, not
1783    /// the half-open end plus context — otherwise every excerpt is one row
1784    /// long even when it fits inside the file, and the desync is just
1785    /// harder to notice.
1786    #[test]
1787    fn the_excerpt_end_is_inclusive_of_the_last_context_line() {
1788        // 20 lines, one changed in the middle: context is unclamped, so
1789        // the arithmetic is visible.
1790        let before: String = (0..20).map(|i| format!("line{i}\n")).collect();
1791        let after = before.replace("line10\n", "CHANGED\n");
1792        let ranges = file_hunk_ranges(&before, &after);
1793        assert_eq!(ranges.len(), 1, "one hunk; got {ranges:?}");
1794        let (start, end) = ranges[0];
1795        assert_eq!(
1796            start,
1797            10 - CONTEXT,
1798            "start is the changed line minus context"
1799        );
1800        assert_eq!(
1801            end,
1802            10 + CONTEXT,
1803            "end is the changed line plus context, INCLUSIVE"
1804        );
1805    }
1806
1807    /// Every row an excerpt names must exist in the source, for any hunk
1808    /// position — the property the two tests above are instances of.
1809    #[test]
1810    fn every_named_row_exists_for_a_change_anywhere_in_the_file() {
1811        let before: String = (0..12).map(|i| format!("line{i}\n")).collect();
1812        let last = 11u32;
1813        for changed in 0..12u32 {
1814            let after = before.replace(&format!("line{changed}\n"), "CHANGED\n");
1815            for (start, end) in file_hunk_ranges(&before, &after) {
1816                assert!(
1817                    end <= last && start <= end,
1818                    "changing line {changed} produced excerpt ({start},{end}); \
1819                     last line is {last}"
1820                );
1821            }
1822        }
1823    }
1824
1825    // ── PD.7b: removed lines as virtual rows ─────────────────────
1826
1827    #[test]
1828    fn a_removed_line_is_captured_with_its_text() {
1829        let removed = removed_lines("a\ngone\nb\n", "a\nb\n");
1830        assert_eq!(removed.len(), 1, "one removal; got {removed:?}");
1831        let (at, text) = &removed[0];
1832        assert_eq!(*at, 1, "anchored at the post-image line it sat above");
1833        assert_eq!(text, &vec!["gone".to_string()], "carries the removed text");
1834    }
1835
1836    /// A rewritten line is BOTH: an added row to paint and a removed row
1837    /// to ghost. Losing the second half would show the new text with no
1838    /// sign of what it replaced, which is most of what a diff is for.
1839    #[test]
1840    fn a_changed_line_keeps_its_removed_half() {
1841        let removed = removed_lines("a\nold\nb\n", "a\nnew\nb\n");
1842        assert!(
1843            removed.iter().any(|(_, t)| t.contains(&"old".to_string())),
1844            "the replaced text must survive as a ghost row; got {removed:?}"
1845        );
1846    }
1847
1848    #[test]
1849    fn a_pure_addition_removes_nothing() {
1850        assert!(removed_lines("a\nb\n", "a\nNEW\nb\n").is_empty());
1851    }
1852
1853    #[test]
1854    fn a_multi_line_removal_keeps_every_line_in_order() {
1855        let removed = removed_lines("a\none\ntwo\nthree\nb\n", "a\nb\n");
1856        let text: Vec<String> = removed.iter().flat_map(|(_, t)| t.clone()).collect();
1857        assert_eq!(text, vec!["one", "two", "three"]);
1858    }
1859
1860    /// The composed anchor is `excerpt_offset + (source_line -
1861    /// excerpt.start_line)`, recomputed per collect. This pins the
1862    /// arithmetic for the SECOND excerpt, where a naive implementation
1863    /// that forgot the running offset would anchor into the first one —
1864    /// and would look correct in any single-excerpt test.
1865    #[test]
1866    fn a_removal_in_the_second_excerpt_anchors_past_the_first() {
1867        // Two excerpts of 3 rows each; a removal at source line 21 in the
1868        // second, whose span starts at 20 — so composed row 3 + 1 = 4.
1869        let first_len = 3u32;
1870        let second_start = 20u32;
1871        let removal_at = 21u32;
1872        let composed = first_len + (removal_at - second_start);
1873        assert_eq!(
1874            composed, 4,
1875            "second excerpt's rows start after the first's, so the anchor \
1876             must include the running offset"
1877        );
1878    }
1879
1880    // ── PD.7a: which lines the diff touched ──────────────────────
1881    //
1882    // The view showed real source with nothing marking what changed, so a
1883    // reader could not tell a changed line from its context — the whole
1884    // point of a diff. `changed_lines` is the classification that fixes
1885    // it, in post-image (working-tree) coordinates, which is the only
1886    // coordinate space the excerpts have rows in.
1887
1888    #[test]
1889    fn an_added_line_is_classified() {
1890        let changed = changed_lines("a\nb\n", "a\nNEW\nb\n");
1891        assert!(
1892            changed.entries().iter().any(|(l, _)| *l == 1),
1893            "the inserted line 1 should be marked; got {:?}",
1894            changed.entries()
1895        );
1896    }
1897
1898    /// Delegating to `compute_diff_sign_map` buys a distinction the
1899    /// hand-rolled version did not make: a rewritten line is `Change`,
1900    /// not `Add`. Pinned because it is the concrete evidence that the
1901    /// duplication had already drifted, and the reason to keep only one
1902    /// implementation.
1903    #[test]
1904    fn a_rewritten_line_is_change_not_add() {
1905        use lattice_diff::overlay::DiffSignKind;
1906        let changed = changed_lines("a\nold\nc\n", "a\nnew\nc\n");
1907        let kind = changed
1908            .entries()
1909            .iter()
1910            .find(|(l, _)| *l == 1)
1911            .map(|(_, k)| *k);
1912        assert_eq!(kind, Some(DiffSignKind::Change));
1913    }
1914
1915    #[test]
1916    fn a_changed_line_is_classified() {
1917        let changed = changed_lines("a\nold\nc\n", "a\nnew\nc\n");
1918        assert!(
1919            changed.entries().iter().any(|(l, _)| *l == 1),
1920            "the rewritten line 1 should be marked; got {:?}",
1921            changed.entries()
1922        );
1923    }
1924
1925    /// Context lines are what the marks are read AGAINST. If everything
1926    /// were marked the view would be as uninformative as marking nothing.
1927    #[test]
1928    fn untouched_lines_are_not_classified() {
1929        let changed = changed_lines("a\nb\nc\n", "a\nNEW\nb\nc\n");
1930        let marked: Vec<u32> = changed.entries().iter().map(|(l, _)| *l).collect();
1931        assert!(
1932            !marked.contains(&0),
1933            "line 0 is unchanged context; got {marked:?}"
1934        );
1935    }
1936
1937    /// A pure deletion has NO post-image row, so it is deliberately absent
1938    /// here — there is no line to paint. Showing the user that something
1939    /// was removed needs a virtual row, which is PD.7b. Pinned so that
1940    /// absence reads as a decision rather than a miss.
1941    #[test]
1942    fn a_pure_removal_marks_nothing_because_it_has_no_row() {
1943        let changed = changed_lines("a\ngone\nb\n", "a\nb\n");
1944        assert!(
1945            changed.is_empty(),
1946            "a removed line has no post-image row to mark; got {:?}",
1947            changed.entries()
1948        );
1949    }
1950
1951    #[test]
1952    fn an_unchanged_file_classifies_nothing() {
1953        assert!(changed_lines("same\n", "same\n").is_empty());
1954    }
1955
1956    /// The classification is sorted and deduplicated, because the span
1957    /// builder binary-searches it once per composed row.
1958    #[test]
1959    fn the_classification_is_sorted_and_unique() {
1960        let changed = changed_lines("a\nb\nc\nd\n", "A\nb\nC\nD\n");
1961        let lines: Vec<u32> = changed.entries().iter().map(|(l, _)| *l).collect();
1962        let mut sorted = lines.clone();
1963        sorted.sort_unstable();
1964        sorted.dedup();
1965        assert_eq!(lines, sorted, "must be sorted for binary search");
1966    }
1967
1968    /// The scan carries the classification through to the batch, or the
1969    /// view has nothing to paint from.
1970    #[test]
1971    fn the_batch_carries_the_classification() {
1972        let dir = repo_with_changes();
1973        let files = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
1974        let built = read_and_diff(&files);
1975        let tracked = built
1976            .iter()
1977            .find(|f| f.path.ends_with("tracked.rs"))
1978            .expect("the modified file is in the batch");
1979        assert!(
1980            !tracked.changed.is_empty(),
1981            "a file with hunks must carry marked lines"
1982        );
1983    }
1984
1985    // ── PD.4: an edit in the view lands in the file ──────────────
1986    //
1987    // Design §2: an excerpt is a hunk's post-image range in the
1988    // WORKING-TREE file, so editing it goes through the ordinary M.3
1989    // propagation pipeline and lands in the file — no patch application,
1990    // no write-back path of its own. These assert the anchoring that
1991    // claim rests on, which is the part that would break silently: an
1992    // excerpt anchored in generated patch text would still render, still
1993    // accept keystrokes, and propagate an edit to nowhere.
1994
1995    /// The source document a batch attaches carries the **working-tree**
1996    /// text and the file's path — not the baseline blob. Anchoring it to
1997    /// the baseline would look identical in the view (the hunk ranges are
1998    /// post-image either way) and send every edit into a document that
1999    /// was never the file.
2000    #[tokio::test(flavor = "multi_thread")]
2001    async fn an_attached_source_is_the_working_tree_file() {
2002        let dir = repo_with_changes();
2003        let files = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
2004        let built = read_and_diff(&files);
2005        let tracked = built
2006            .iter()
2007            .find(|f| f.path.ends_with("tracked.rs"))
2008            .expect("the modified file is in the batch");
2009
2010        assert!(
2011            tracked.text.contains("let new = 2;"),
2012            "the source must hold the working-tree text; got {:?}",
2013            tracked.text
2014        );
2015        assert!(
2016            !tracked.text.contains("let old = 1;"),
2017            "...and not the HEAD baseline, or edits would land in the wrong content"
2018        );
2019        assert_eq!(
2020            tracked.text,
2021            std::fs::read_to_string(&tracked.path).unwrap(),
2022            "byte-identical to the file on disk"
2023        );
2024    }
2025
2026    /// End to end through a live view: attach a real repo's hunks, edit
2027    /// a composed row, and watch the edit arrive in the source document
2028    /// that carries the file's path. That last clause is the assertion
2029    /// that matters — propagation into *some* document proves nothing if
2030    /// it is not the one anchored to the file.
2031    #[tokio::test(flavor = "multi_thread")]
2032    async fn editing_an_excerpt_reaches_the_document_anchored_to_the_file() {
2033        let dir = repo_with_changes();
2034        let files = scan_changed_files(dir.path(), ProjectDiffComparison::WorkingTree);
2035        let built: Vec<FileHunks> = read_and_diff(&files)
2036            .into_iter()
2037            .filter(|f| f.path.ends_with("tracked.rs"))
2038            .collect();
2039        assert_eq!(built.len(), 1, "one changed file for this test");
2040        let path = built[0].path.clone();
2041
2042        let registry: lattice_grammar::CommandRegistryHandle =
2043            Arc::new(arc_swap::ArcSwap::from_pointee(CommandRegistry::new()));
2044        let view =
2045            MultibufferDocumentHandle::new(std::collections::HashMap::new(), Vec::new(), registry)
2046                .expect("empty view");
2047        assert!(attach_batch(&view, &built) > 0, "hunks attached");
2048
2049        let source_id = *view
2050            .source_buffer_ids()
2051            .first()
2052            .expect("the batch attached a source");
2053        assert_eq!(
2054            view.source_path(source_id).as_deref(),
2055            Some(path.as_path()),
2056            "the source is anchored to the file the hunk came from"
2057        );
2058        // Read BEFORE the edit. Propagation turned out to be fast enough
2059        // that sampling afterwards raced the forwarder and saw the
2060        // marker already there — which would have made the assertion
2061        // below unfalsifiable.
2062        assert!(
2063            !view
2064                .source_text(source_id)
2065                .expect("source present")
2066                .contains("// "),
2067            "precondition: the marker is not already in the file"
2068        );
2069
2070        // Type at the very start of the composed view — inside the first
2071        // hunk's post-image range by construction.
2072        view.apply_edit(lattice_protocol::edit::Edit::insert(
2073            lattice_protocol::position::Position::new(0, 0),
2074            "// ",
2075        ))
2076        .await
2077        .expect("the working-tree view is editable");
2078
2079        // Asserted as "the marker arrived", not "the text starts with
2080        // it": composed row 0 is the first row of the first hunk, which
2081        // sits at whatever source row its context begins on. Pinning
2082        // row 0 would pass here and break on a fixture whose first hunk
2083        // is further down, for a reason having nothing to do with
2084        // propagation.
2085        //
2086        // The source catches up through the forwarder task.
2087        for _ in 0..40 {
2088            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2089            if view
2090                .source_text(source_id)
2091                .is_some_and(|t| t.contains("// "))
2092            {
2093                return;
2094            }
2095        }
2096        panic!(
2097            "the edit never reached the file's document; source reads {:?}",
2098            view.source_text(source_id)
2099        );
2100    }
2101
2102    /// The read-only half of §2.1, at the level the table states it:
2103    /// only the working tree is a file, so only it is editable. Pinned
2104    /// alongside the propagation tests because the two claims are one
2105    /// rule — an index blob has no anchor to propagate through, which is
2106    /// exactly why it opens read-only rather than editable-but-broken.
2107    #[test]
2108    fn the_editable_comparisons_are_the_working_tree_ones() {
2109        assert!(ProjectDiffComparison::WorkingTree.is_editable());
2110        assert!(!ProjectDiffComparison::Staged.is_editable());
2111    }
2112
2113    /// The blocking half never panics on a path that vanished between
2114    /// `git status` and the read — a rebase or a `rm` mid-scan is a
2115    /// normal race, not an error state.
2116    #[test]
2117    fn a_file_that_disappeared_mid_scan_is_skipped_not_fatal() {
2118        let dir = tempfile::tempdir().unwrap();
2119        let gone = dir.path().join("never-existed.rs");
2120        let built = read_and_diff(&[(gone, "fn old() {}\n".to_string())]);
2121        assert!(built.is_empty());
2122    }
2123
2124    // ── PD.3: the trigger ────────────────────────────────────────
2125
2126    /// The comparison selector is an argument, not a second entry
2127    /// point — one opener serves `:magit-project-diff` and the Diff
2128    /// transient's `e` row.
2129    #[test]
2130    fn the_comparison_comes_from_the_trigger_arguments() {
2131        assert_eq!(
2132            comparison_from_args(&Args::None),
2133            ProjectDiffComparison::WorkingTree
2134        );
2135        assert_eq!(
2136            comparison_from_args(&Args::String("staged".into())),
2137            ProjectDiffComparison::Staged
2138        );
2139        assert_eq!(
2140            comparison_from_args(&Args::String("  INDEX ".into())),
2141            ProjectDiffComparison::Staged,
2142            "case and surrounding space are not the user's problem"
2143        );
2144    }
2145
2146    /// An unrecognised argument opens the daily driver rather than
2147    /// refusing: showing nothing is a worse answer to a typo than
2148    /// showing the default.
2149    #[test]
2150    fn an_unknown_comparison_falls_back_to_the_working_tree() {
2151        assert_eq!(
2152            comparison_from_args(&Args::String("nonsense".into())),
2153            ProjectDiffComparison::WorkingTree
2154        );
2155    }
2156
2157    #[test]
2158    fn service_tracks_and_forgets_view_state() {
2159        let svc = ProjectDiffService::new();
2160        let view = BufferId::next();
2161        let state = ProjectDiffState {
2162            workdir: PathBuf::from("/tmp/repo"),
2163            comparison: ProjectDiffComparison::WorkingTree,
2164        };
2165        svc.set_state(view, state);
2166        assert_eq!(svc.tracked_views(), 1);
2167        svc.forget(view);
2168        assert!(svc.state(view).is_none());
2169        assert_eq!(svc.tracked_views(), 0);
2170    }
2171}