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}