Skip to main content

lattice_magit/
magit_diff_mode.rs

1//! MG.5: magit-diff major mode.
2//!
3//! Fold audit fix: this used to be a full stub — `on_activate`
4//! registered only a close handler and the buffer opened empty; `s`/
5//! `u` were declared in the keymap with no handler of their own, so
6//! pressing them silently hijacked whatever `magit-status` handler
7//! happened to be registered (operating on magit-status's captured
8//! buffer state, not this buffer's cursor). The design's full
9//! side-by-side `DiffSession` + hunk-level staging (reusing D.4's
10//! pane-group machinery) remains a larger follow-up; this is a
11//! real, scoped middle ground: `git diff HEAD` content (staged +
12//! unstaged changes combined, matching the module's original
13//! "against HEAD" framing) with its own file-level `s`/`u`/`x`
14//! handlers, scoped to this buffer's own state.
15//!
16//! `d` on a file in magit-status's Staged/Unstaged sections
17//! (`action:magit-diff-file` in `actions.rs`) opens one of these
18//! buffers scoped to BOTH a file and a baseline
19//! (`*magit:diff:staged:<path>*` / `*magit:diff:unstaged:<path>*`),
20//! instead of the status buffer's own inline `=` toggle — large
21//! diffs get a real scrollable buffer instead of ballooning the
22//! status buffer's line count (and re-triggering its splice-based
23//! inline-highlight bookkeeping) for a file the user just wants to
24//! read in full.
25
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex, OnceLock};
28
29use lattice_config;
30use lattice_grammar::Effect;
31use lattice_mode::{
32    ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap, KeymapEntry,
33    LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
34};
35use lattice_protocol::position::Position;
36use lattice_vcs::{Index, Repository};
37
38use crate::buffer_state::{
39    BufferStateGuard, BufferStates, DiffSource, MagitView, MagitViewsHandle,
40};
41use crate::headerline;
42
43pub struct MagitDiffMode;
44
45impl MagitDiffMode {
46    pub fn mode_id() -> ModeId {
47        ModeId::new("magit-diff-mode")
48    }
49}
50
51fn magit_diff_keymap_entries() -> &'static [KeymapEntry] {
52    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
53    ENTRIES.get_or_init(|| {
54        vec![
55            // MG.18e: region staging, same chords on the selection.
56        ]
57    })
58}
59
60/// Which baseline a diff buffer compares against — encoded in the
61/// buffer name (see [`parse_buffer_name`]) so the SAME mode serves
62/// `:magit-diff` (against HEAD, combining staged+unstaged), and the
63/// status buffer's per-section `d` binding (against the index, for
64/// exactly one side of the working tree).
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub(crate) enum DiffScope {
67    /// `git diff HEAD` — staged + unstaged changes combined.
68    Head,
69    /// `git diff --cached` — index vs HEAD (the Staged section).
70    Staged,
71    /// `git diff` — working tree vs index (the Unstaged section).
72    Unstaged,
73    /// MG.43e: magit's merge `p` — what merging `branch` would bring
74    /// in, without merging it.
75    ///
76    /// `git diff HEAD...<branch>` (THREE dots) is the right question:
77    /// it shows what `branch` added since the two diverged. The
78    /// two-dot form would also report everything HEAD gained in the
79    /// meantime as though the merge were removing it, which is the
80    /// opposite of what a preview is for.
81    MergePreview(String),
82}
83
84impl DiffScope {
85    /// MG.14: how this scope reads in the headerline. The same three
86    /// words `parse_buffer_name` accepts, so the header echoes the
87    /// buffer name rather than inventing a second vocabulary.
88    fn header_label(&self) -> &'static str {
89        match self {
90            DiffScope::Head => "HEAD",
91            DiffScope::Staged => "staged",
92            DiffScope::Unstaged => "unstaged",
93            DiffScope::MergePreview(_) => "merge preview",
94        }
95    }
96}
97
98pub struct DiffState {
99    buffer_id: lattice_core::BufferId,
100    store: Arc<BufferStoreHandle>,
101    workdir: PathBuf,
102    scope: DiffScope,
103    /// `Some` when this buffer is scoped to one file (opened as
104    /// `*magit:diff:<path>*` / `*magit:diff:staged:<path>*` /
105    /// `*magit:diff:unstaged:<path>*`); `None` for the unscoped
106    /// `*magit:diff*` (`:magit-diff`) view.
107    path: Option<PathBuf>,
108    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
109    /// MG.18d: the wake-baked bus a post-mutation cursor goes back on.
110    cursor_bus: Option<crate::cursor_restore::CursorBusHandle>,
111    /// MG.23k: extra git arguments the `D` menu set, replayed on every
112    /// subsequent refresh so `gr` does not silently revert to the
113    /// default diff.
114    extra_args: Vec<String>,
115    /// MG.22b: the config, not the value. Read per refresh so a
116    /// `:set magit.hunk.context-lines` takes effect on the next `gr`
117    /// rather than only on reopen.
118    config: Option<std::sync::Arc<lattice_config::ConfigRegistry>>,
119    /// DS.4: the grammar registry, for syntax under the diff colouring.
120    /// `None` ⇒ the flat classifier, as before.
121    lang_registry: Option<std::sync::Arc<lattice_syntax::LangRegistry>>,
122}
123
124/// `magit.hunk.context-lines`, or git's own default when there is no
125/// config registry (a stripped harness).
126fn context_lines(config: &Option<std::sync::Arc<lattice_config::ConfigRegistry>>) -> i64 {
127    config
128        .as_ref()
129        .and_then(|c| c.get_typed::<crate::options::MagitHunkContextLines>())
130        .map(|v| *v)
131        .unwrap_or(3)
132}
133
134/// MR.3b: this view's half of the shared name grammar — everything
135/// after the repository segment.
136///
137/// ```text
138/// *magit:diff:<repo>*                        HEAD, whole tree
139/// *magit:diff:<repo>:<path>*                 HEAD, one file
140/// *magit:diff:<repo>:staged[:<path>]*        the index
141/// *magit:diff:<repo>:unstaged[:<path>]*      the working tree
142/// *magit:diff:<repo>:merge-preview:<branch>* what merging would bring
143/// ```
144///
145/// One producer ([`diff_view_rest`]) and one parser, both reading only
146/// the `rest` that `workdir::parse_magit_name` hands back — which is why
147/// the repository moving into the name did not have to be handled here
148/// at all.
149pub(crate) fn diff_view_rest(scope: &DiffScope, path: Option<&std::path::Path>) -> String {
150    let scope_word = match scope {
151        DiffScope::Head => String::new(),
152        DiffScope::Staged => "staged".to_string(),
153        DiffScope::Unstaged => "unstaged".to_string(),
154        // A merge preview is of a branch, never of a path — the branch
155        // takes the slot a path would.
156        DiffScope::MergePreview(branch) => return format!("merge-preview:{branch}"),
157    };
158    match (scope_word.is_empty(), path) {
159        (true, None) => String::new(),
160        (true, Some(p)) => p.display().to_string(),
161        (false, None) => scope_word,
162        (false, Some(p)) => format!("{scope_word}:{}", p.display()),
163    }
164}
165
166fn parse_buffer_name(name: &str) -> (DiffScope, Option<PathBuf>) {
167    let Some(rest) = crate::workdir::parse_magit_name(name).and_then(|n| n.rest) else {
168        return (DiffScope::Head, None);
169    };
170    // MG.43e: checked FIRST. The path arm below would otherwise read
171    // `merge-preview:<branch>` as a PATH, silently diffing a file that
172    // does not exist instead of previewing a merge.
173    if let Some(branch) = rest.strip_prefix("merge-preview:")
174        && !branch.is_empty()
175    {
176        return (DiffScope::MergePreview(branch.to_string()), None);
177    }
178    // The scope word must match WHOLE, or be followed by `:`. A bare
179    // `strip_prefix("staged")` would read a file called
180    // `staged-fixtures.rs` as an index-scoped diff of nothing.
181    for (word, scope) in [
182        ("staged", DiffScope::Staged),
183        ("unstaged", DiffScope::Unstaged),
184    ] {
185        if rest == word {
186            return (scope, None);
187        }
188        if let Some(path) = rest.strip_prefix(word).and_then(|t| t.strip_prefix(':')) {
189            return (scope, (!path.is_empty()).then(|| PathBuf::from(path)));
190        }
191    }
192    (DiffScope::Head, Some(PathBuf::from(rest)))
193}
194
195/// MG.43h: arguments a dispatch row collected for a view it is about
196/// to open.
197///
198/// The `d` / `l` rows became argument transients, but the toggles are
199/// answered BEFORE the buffer exists — there is nothing yet to hold
200/// them. This is the same shape [`crate::magit_blame_mode::BlameRequests`]
201/// uses for a reverse-blame request: the opener leaves the values under
202/// the buffer's name, and the mode takes them when it activates.
203#[derive(Default)]
204pub struct ViewArgsRequests {
205    map: std::sync::Mutex<std::collections::HashMap<String, Vec<String>>>,
206}
207
208impl ViewArgsRequests {
209    pub fn put(&self, buffer_name: String, args: Vec<String>) {
210        if let Ok(mut m) = self.map.lock() {
211            m.insert(buffer_name, args);
212        }
213    }
214
215    /// Read and remove — a request is for one activation. Leaving it
216    /// would make the next plain `:magit-diff` on the same name
217    /// silently inherit the previous menu's toggles.
218    pub fn take(&self, buffer_name: &str) -> Option<Vec<String>> {
219        self.map.lock().ok()?.remove(buffer_name)
220    }
221}
222
223pub type ViewArgsRequestsHandle = Arc<ViewArgsRequests>;
224
225/// MG.13: service alias for this mode's per-buffer state
226/// (`feedback_servicesregistry_arc_typeid`).
227pub type DiffStatesHandle = Arc<BufferStates<DiffState>>;
228
229impl Mode for MagitDiffMode {
230    type Guard = BufferStateGuard<DiffState>;
231
232    fn id(&self) -> ModeId {
233        Self::mode_id()
234    }
235    fn kind(&self) -> ModeKind {
236        ModeKind::Major
237    }
238    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
239        None
240    }
241
242    fn options(&self) -> OptionOverrideSet {
243        lattice_config::overrides! {
244            lattice_config::ReadOnly = true,
245            lattice_config::NoFile = true,
246            lattice_config::Number = false,
247        }
248    }
249
250    /// MG.RO: `read-only-mode` is where the gate actually is.
251    ///
252    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
253    /// `read_only_edit_rejected`, which guards the insert-mode char path;
254    /// operators never reach it, because a `Document`'s grammar dispatch
255    /// applies its own edits and hands the host an already-applied
256    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
257    /// the buffer reported itself read-only — worse than not gating at all,
258    /// because it looks protected.
259    ///
260    /// `read-only-mode` carries the option AND the `invocation_runner`
261    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
262    /// letting motions, `:` and `/` through.
263    ///
264    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
265    /// mode is followed from the mode being ACTIVATED, and the majors are what
266    /// the host activates. Putting it on the shared minor looked right and was
267    /// verified not to fire.
268    fn implies(&self) -> &[lattice_mode::ModeId] {
269        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
270        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
271    }
272
273    fn required_capabilities(&self) -> CapabilitySet {
274        CapabilitySet::empty()
275    }
276    fn keymap(&self) -> Keymap {
277        Keymap::from_entries(magit_diff_keymap_entries())
278    }
279
280    /// MG.13: boot-registered — see `buffer_state`'s module docs. `gr`,
281    /// `s` and `u` are NOT here: they are shared actions owned by
282    /// `magit-core-mode` and reached through this mode's `MagitView`.
283    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
284        vec![
285            // <CR> — visit the file at cursor. Staged scope shows the
286            // INDEX blob (`*magit:file:staged:<path>*`, read-only) —
287            // this diff describes staged content, which may already
288            // differ from the live working-tree file. Unstaged IS the
289            // working tree, and Head combines both (no single frozen
290            // blob to show), so both open the real editable file — same
291            // target magit-status's Unstaged section opens.
292        ]
293    }
294
295    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
296        Box::pin(async move {
297            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
298            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
299            let Some(store) = ctx.service::<BufferStoreHandle>() else {
300                return Ok(orphan());
301            };
302            let Some(handle) = store.handle_for(buffer_id) else {
303                return Ok(orphan());
304            };
305            // MR.3: the repository the trigger resolved for THIS
306            // buffer, not the one the editor was started in.
307            let workdir =
308                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
309
310            // "*magit:diff[:staged|:unstaged]:<path>*" scopes the view
311            // to one file and (optionally) one baseline (mirrors
312            // magit-blame's file-in-buffer-name pattern); bare
313            // "*magit:diff*" (from `:magit-diff`) stays unscoped
314            // against HEAD.
315            let buffer_name = store.name_for(buffer_id).unwrap_or_default();
316            let (scope, path) = parse_buffer_name(&buffer_name);
317
318            // MG.43h: arguments the `d` dispatch row collected before
319            // this buffer existed. Taken (not read), so a later plain
320            // `:magit-diff` on the same name does not inherit them.
321            let requested_args = ctx
322                .service::<ViewArgsRequestsHandle>()
323                .and_then(|r| r.take(&buffer_name))
324                .unwrap_or_default();
325
326            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
327
328            // MG.14: this view's header is fully known from the buffer
329            // name — scope, plus the path when file-scoped — so it is
330            // set here rather than after the diff lands. Neither field
331            // changes under `gr`: re-diffing the same scope of the
332            // same path still describes the same view.
333            let (hl, hl_registration) =
334                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
335                    Some((h, reg)) => (Some(h), Some(reg)),
336                    None => (None, None),
337                };
338            headerline::publish(
339                &hl,
340                headerline::diff_fields(scope.header_label(), path.as_deref()),
341            );
342
343            // MG.13: publish BEFORE the first `.await` — see the note
344            // in `magit_branch_mode::on_activate`.
345            let Some(states) = ctx.service::<DiffStatesHandle>() else {
346                return Ok(orphan());
347            };
348            let state = states.publish(
349                buffer_id,
350                DiffState {
351                    buffer_id,
352                    store: store.clone(),
353                    workdir: workdir.clone(),
354                    scope: scope.clone(),
355                    path: path.clone(),
356                    pending_highlights: pending_highlights.clone(),
357                    cursor_bus: ctx
358                        .service::<crate::cursor_restore::CursorBusHandle>()
359                        .map(|outer| (*outer).clone()),
360                    extra_args: requested_args.clone(),
361                    config: ctx
362                        .service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
363                        .map(|outer| (*outer).clone()),
364                    lang_registry: ctx
365                        .service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
366                        .map(|outer| (*outer).clone()),
367                },
368            );
369            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
370                .with_headerline(hl_registration);
371            if let Some(views) = ctx.service::<MagitViewsHandle>() {
372                views.publish(buffer_id, Arc::new(DiffView(state.clone())));
373                guard = guard.with_views((*views).clone());
374            }
375
376            let wd = workdir.clone();
377            let path_for_task = path.clone();
378            let context = context_lines(
379                &ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
380                    .map(|outer| (*outer).clone()),
381            );
382            let activation_registry = crate::hunk_syntax::syntax_registry(
383                ctx.service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
384                    .map(|outer| (*outer).clone()),
385                ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
386                    .map(|outer| (*outer).clone())
387                    .as_ref(),
388            );
389            let text = tokio::task::spawn_blocking(move || {
390                run_diff(&wd, &scope, path_for_task.as_deref(), &[], context)
391            })
392            .await
393            .unwrap_or_default();
394            let spans = crate::hunk_syntax::diff_spans(&text, activation_registry.as_ref());
395            crate::buffer_io::replace_buffer_text(&handle, text).await;
396            if let Some(ref ph) = pending_highlights {
397                ph.store_and_wake(buffer_id, spans);
398            }
399
400            Ok(guard)
401        })
402    }
403}
404
405fn refresh(s: Arc<Mutex<DiffState>>) -> Option<Effect> {
406    refresh_with(s, None)
407}
408
409/// Rebuild the diff, and — when a mutation supplied one — put the
410/// cursor back on the hunk that took the staged one's place.
411///
412/// MG.18d: the position is resolved against the text this rebuild is
413/// about to write and sent afterwards, so it can neither race the
414/// replace nor be clamped against the outgoing content. The send wakes
415/// the editor, so it lands without the user pressing anything
416/// (`boot-composition.md` §3).
417fn refresh_with(
418    s: Arc<Mutex<DiffState>>,
419    restore: Option<crate::cursor_restore::HunkRestore>,
420) -> Option<Effect> {
421    let (handle, wd, scope, path, pending, buffer_id, cursor_bus, extra, context, registry) = {
422        let g = s.lock().ok()?;
423        (
424            g.store.handle_for(g.buffer_id)?,
425            g.workdir.clone(),
426            g.scope.clone(),
427            g.path.clone(),
428            g.pending_highlights.clone(),
429            g.buffer_id,
430            g.cursor_bus.clone(),
431            g.extra_args.clone(),
432            context_lines(&g.config),
433            crate::hunk_syntax::syntax_registry(g.lang_registry.clone(), g.config.as_ref()),
434        )
435    };
436    tokio::task::spawn(async move {
437        let text = tokio::task::spawn_blocking(move || {
438            run_diff(&wd, &scope, path.as_deref(), &extra, context)
439        })
440        .await
441        .unwrap_or_default();
442        let spans = crate::hunk_syntax::diff_spans(&text, registry.as_ref());
443        let position = restore.and_then(|r| crate::cursor_restore::restore_position(&text, &r));
444        crate::buffer_io::replace_buffer_text(&handle, text).await;
445        if let Some(ph) = pending {
446            ph.store_and_wake(buffer_id, spans);
447        }
448        if let Some(position) = position {
449            crate::cursor_restore::send_cursor(&cursor_bus, buffer_id, position);
450        }
451    });
452    None
453}
454
455/// Run a repository mutation off-thread, report it, then refresh.
456///
457/// MG.54: `mutate` returns a `Result` so the outcome can be published.
458/// It used to be `impl FnOnce()`, which meant every caller discarded
459/// its git result — the operation finished in silence, and a FAILED
460/// one finished in the same silence with the buffer refreshing as
461/// though it had worked.
462fn spawn_mutation_and_refresh(
463    s: Arc<Mutex<DiffState>>,
464    label: String,
465    mutate: impl FnOnce() -> Result<String, String> + Send + 'static,
466) -> Option<Effect> {
467    let (handle, wd, scope, path, pending, buffer_id, extra, context, registry) = {
468        let g = s.lock().ok()?;
469        (
470            g.store.handle_for(g.buffer_id)?,
471            g.workdir.clone(),
472            g.scope.clone(),
473            g.path.clone(),
474            g.pending_highlights.clone(),
475            g.buffer_id,
476            g.extra_args.clone(),
477            context_lines(&g.config),
478            crate::hunk_syntax::syntax_registry(g.lang_registry.clone(), g.config.as_ref()),
479        )
480    };
481    tokio::task::spawn(async move {
482        let result = tokio::task::spawn_blocking(mutate)
483            .await
484            .unwrap_or_else(|e| Err(e.to_string()));
485        crate::magit_global_mode::finish_task(&wd, &label, result);
486        let text = tokio::task::spawn_blocking(move || {
487            run_diff(&wd, &scope, path.as_deref(), &extra, context)
488        })
489        .await
490        .unwrap_or_default();
491        let spans = crate::hunk_syntax::diff_spans(&text, registry.as_ref());
492        crate::buffer_io::replace_buffer_text(&handle, text).await;
493        if let Some(ph) = pending {
494            ph.store_and_wake(buffer_id, spans);
495        }
496    });
497    None
498}
499
500/// Walk upward from `line` to the nearest `diff --git a/<path> b/<path>`
501/// header and extract `<path>` (the `b/` side — the current-tree path).
502/// MG.22: the shared diff-path parser, read through this view's own
503/// buffer. The scan itself lives in `hunk` — three modes had a copy of
504/// it and one of them had a bug the other two did not.
505fn file_at_cursor(state: &DiffState, line: u32) -> Option<PathBuf> {
506    let handle = state.store.handle_for(state.buffer_id)?;
507    let snap = handle.snapshot();
508    crate::hunk::path_at_cursor(
509        |i| u32::try_from(i).ok().and_then(|l| snap.buffer.line(l)),
510        line as usize,
511    )
512}
513
514/// MG.23k: the arguments `D` offers in a diff buffer.
515///
516/// magit's `magit-diff` transient carries many more; these are the
517/// three that change how the SAME diff reads. Arguments that change
518/// *which* diff it is (`--cached`, a revision range) are deliberately
519/// absent — the buffer's scope is in its name, so a menu row that
520/// silently made `*magit:diff:staged:x*` show unstaged content would
521/// leave the headerline and the buffer name both lying.
522pub(crate) const DIFF_ARGS: &[crate::magit_global_mode::RemoteFlag] = &[
523    crate::magit_global_mode::RemoteFlag {
524        name: "ignore-space",
525        arg: "-w",
526        key: "-w",
527        doc: "Ignore whitespace-only changes",
528        kind: crate::magit_global_mode::RemoteArgKind::Flag,
529    },
530    crate::magit_global_mode::RemoteFlag {
531        name: "stat",
532        arg: "--stat",
533        key: "-s",
534        doc: "Show a summary of changed files instead of the patch",
535        kind: crate::magit_global_mode::RemoteArgKind::Flag,
536    },
537    crate::magit_global_mode::RemoteFlag {
538        // Joined, not separated: `git diff -U 3` and `--unified 3` are
539        // both errors. See `RemoteArgKind::ValueJoined`.
540        name: "unified",
541        arg: "--unified=",
542        key: "-U",
543        doc: "Lines of context around each hunk",
544        kind: crate::magit_global_mode::RemoteArgKind::ValueJoined {
545            prompt: "Context lines",
546        },
547    },
548];
549
550/// The `git` argv for one diff run.
551///
552/// Pure and separate from the spawning path, for the reason
553/// `blame_argv` and `tag_argv` already are: the flags and their ORDER
554/// are the part worth testing, and reaching them through the runner
555/// would mean every test needed a repository.
556fn run_diff_argv(
557    scope: &DiffScope,
558    path: Option<&Path>,
559    extra: &[String],
560    context: i64,
561) -> Vec<String> {
562    let mut args = vec!["diff".to_string()];
563    match scope {
564        DiffScope::Head => args.push("HEAD".to_string()),
565        DiffScope::Staged => args.push("--cached".to_string()),
566        // `git diff` with no ref compares the working tree against
567        // the index — exactly the Unstaged section's semantics.
568        DiffScope::Unstaged => {}
569        DiffScope::MergePreview(branch) => args.push(format!("HEAD...{branch}")),
570    }
571    // MG.22b: `magit.hunk.context-lines` is the DEFAULT, so it only
572    // applies when `D` did not set one — the same precedence
573    // `run_log` gives its `-n`. Appending both and letting git take
574    // the last would work, but it puts two contradictory `-U`s in the
575    // argv and the next reader cannot tell which wins.
576    if !extra.iter().any(|a| a.starts_with("--unified")) {
577        args.push(format!("--unified={context}"));
578    }
579    // MG.23k: the `D` menu's arguments go before the `--` separator,
580    // or git reads them as pathspecs.
581    args.extend(extra.iter().cloned());
582    if let Some(p) = path {
583        args.push("--".to_string());
584        args.push(p.to_string_lossy().into_owned());
585    }
586    args
587}
588
589/// MG.43e: the merge-preview argv, for the assertion that its range
590/// uses three dots. Exposed rather than reconstructed in the test so
591/// the test cannot drift from what actually runs.
592#[cfg(test)]
593pub(crate) fn merge_preview_argv_for_test(branch: &str) -> Vec<String> {
594    run_diff_argv(&DiffScope::MergePreview(branch.to_string()), None, &[], 3)
595}
596
597fn run_diff(
598    workdir: &Path,
599    scope: &DiffScope,
600    path: Option<&Path>,
601    extra: &[String],
602    context: i64,
603) -> String {
604    let args = run_diff_argv(scope, path, extra, context);
605    let output = std::process::Command::new("git")
606        .args(&args)
607        .current_dir(workdir)
608        .output();
609    match output {
610        Ok(o) if o.status.success() => {
611            let text = String::from_utf8(o.stdout).unwrap_or_default();
612            if text.trim().is_empty() {
613                match scope {
614                    DiffScope::Head => "No changes against HEAD.\n".to_string(),
615                    DiffScope::Staged => "No staged changes.\n".to_string(),
616                    DiffScope::Unstaged => "No unstaged changes.\n".to_string(),
617                    DiffScope::MergePreview(b) => {
618                        format!("Merging {b} would bring in no changes.\n")
619                    }
620                }
621            } else {
622                text
623            }
624        }
625        _ => "Not a git repository, or no commits yet.\n".to_string(),
626    }
627}
628
629/// MG.18c: which tree this buffer's hunks can be moved between.
630///
631/// Split from [`MagitView::diff_source`] so the mapping is testable
632/// without a live buffer and a spawned document actor.
633fn source_for_scope(scope: &DiffScope) -> Option<DiffSource> {
634    match scope {
635        DiffScope::Staged => Some(DiffSource::Staged),
636        DiffScope::Unstaged => Some(DiffSource::Unstaged),
637        // MG.43e: a merge preview describes a merge that has NOT
638        // happened, so there is no tree to stage a hunk into. Applying
639        // one would write changes the branch has not been merged for —
640        // `s` / `u` / `x` correctly decline here.
641        DiffScope::Head | DiffScope::MergePreview(_) => None,
642    }
643}
644
645/// `gr` for this view — `magit-core-mode` owns the chord and the one
646/// boot-registered handler; see [`MagitView`].
647struct DiffView(Arc<Mutex<DiffState>>);
648
649impl MagitView for DiffView {
650    /// This buffer's content is a unified diff, so "a file" is a
651    /// `diff --git` header — not the generic indented-row scan, which
652    /// here matches every indented CONTEXT line and would walk `]f`
653    /// through arbitrary code.
654    fn file_lines(
655        &self,
656        store: &lattice_mode::BufferStoreHandle,
657        buffer: lattice_core::BufferId,
658    ) -> Option<Vec<u32>> {
659        Some(crate::magit_core_mode::diff_file_lines(store, buffer))
660    }
661
662    /// MG.22: the scope this buffer was opened at decides which
663    /// version `<CR>` opens — the index blob for a staged diff, the
664    /// live file otherwise. The `Head` scope combines both sides, so
665    /// the working-tree copy is the only version that is definitely
666    /// what the user is looking at.
667    fn diff_target(
668        &self,
669        path: &std::path::Path,
670        _cursor: lattice_protocol::position::Position,
671    ) -> Option<Effect> {
672        let g = self.0.lock().ok()?;
673        let label = crate::repo_scope::label_of_buffer(&g.store, g.buffer_id);
674        match g.scope {
675            DiffScope::Staged => Some(Effect::OpenSyntheticBuffer {
676                name: crate::magit_file_revision_mode::blob_buffer_name(&label, "staged", path),
677                mode_id: "magit-file-revision-mode".to_string(),
678                content: None,
679                cursor: None,
680                activate_minor: None,
681            }),
682            DiffScope::Head | DiffScope::Unstaged | DiffScope::MergePreview(_) => {
683                let full = g.workdir.join(path);
684                full.exists().then_some(Effect::OpenBuffer {
685                    path: Some(full),
686                    force: false,
687                })
688            }
689        }
690    }
691
692    fn refresh(&self) -> Option<Effect> {
693        refresh(self.0.clone())
694    }
695
696    /// MG.23k: `D`'s rows for a diff buffer — magit's own diff
697    /// arguments, minus the ones that would change what this buffer
698    /// *is* rather than how it renders.
699    fn argument_flags(&self) -> &'static [crate::magit_global_mode::RemoteFlag] {
700        DIFF_ARGS
701    }
702
703    fn refresh_with_args(&self, extra: Vec<String>) -> Option<Effect> {
704        if let Ok(mut g) = self.0.lock() {
705            g.extra_args = extra;
706        }
707        refresh(self.0.clone())
708    }
709
710    /// MG.18c: the buffer's scope answers this for its whole content —
711    /// every line came from one `git diff` invocation.
712    ///
713    /// `DiffScope::Head` deliberately yields `None`. `git diff HEAD`
714    /// combines staged and unstaged changes into single hunks, so a
715    /// hunk from it is not a patch against either tree, and staging it
716    /// would be guesswork. `d s` / `d u` from magit-status open the
717    /// scoped views where the question has an answer.
718    fn diff_source(&self, _cursor: Position) -> Option<DiffSource> {
719        source_for_scope(&self.0.lock().ok()?.scope)
720    }
721
722    /// MG.18d: a diff buffer's landmark is the `diff --git` header —
723    /// it has no entry rows, and its staged/unstaged identity belongs
724    /// to the whole buffer rather than to a section within it.
725    fn refresh_restoring(&self, site: crate::cursor_restore::HunkSite) -> Option<Effect> {
726        refresh_with(self.0.clone(), Some(site.as_diff_header()))
727    }
728
729    fn workdir(&self) -> Option<PathBuf> {
730        Some(self.0.lock().ok()?.workdir.clone())
731    }
732
733    /// `s` — file-level: finds the nearest `diff --git a/X b/X` header
734    /// above the cursor.
735    fn stage(&self, cursor: Position) -> Option<Effect> {
736        let s = self.0.clone();
737        let (path, workdir) = {
738            let g = s.lock().ok()?;
739            (file_at_cursor(&g, cursor.line)?, g.workdir.clone())
740        };
741        spawn_mutation_and_refresh(s, format!("stage {}", path.display()), move || {
742            let repo =
743                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
744            Index::stage_path(&repo, &path)
745                .map(|_| String::new())
746                .map_err(|e| e.to_string())
747        })
748    }
749
750    fn unstage(&self, cursor: Position) -> Option<Effect> {
751        let s = self.0.clone();
752        let (path, workdir) = {
753            let g = s.lock().ok()?;
754            (file_at_cursor(&g, cursor.line)?, g.workdir.clone())
755        };
756        spawn_mutation_and_refresh(s, format!("unstage {}", path.display()), move || {
757            let repo =
758                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
759            Index::unstage_path(&repo, &path)
760                .map(|_| String::new())
761                .map_err(|e| e.to_string())
762        })
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    /// MG.22b: the option is the DEFAULT, so `D`'s `--unified` wins.
771    /// Emitting both and letting git take the last would work, but it
772    /// puts two contradictory `-U`s in the argv and the next reader
773    /// cannot tell which one applies.
774    #[test]
775    fn the_context_option_yields_to_the_menus_override() {
776        let with_default = run_diff_argv(&DiffScope::Unstaged, None, &[], 7);
777        assert!(
778            with_default.contains(&"--unified=7".to_string()),
779            "the option supplies the context when the menu did not: {with_default:?}"
780        );
781
782        let overridden = run_diff_argv(&DiffScope::Unstaged, None, &["--unified=1".to_string()], 7);
783        assert!(
784            overridden.contains(&"--unified=1".to_string()),
785            "the menu's value must be there: {overridden:?}"
786        );
787        assert_eq!(
788            overridden
789                .iter()
790                .filter(|a| a.starts_with("--unified"))
791                .count(),
792            1,
793            "exactly one `--unified` reaches git: {overridden:?}"
794        );
795    }
796
797    /// The arguments must land before `--`, or git reads them as
798    /// pathspecs and the diff silently comes back empty.
799    #[test]
800    fn every_argument_precedes_the_path_separator() {
801        let argv = run_diff_argv(
802            &DiffScope::Staged,
803            Some(std::path::Path::new("src/main.rs")),
804            &["-w".to_string()],
805            3,
806        );
807        let sep = argv.iter().position(|a| a == "--").expect("a separator");
808        for flag in ["--cached", "-w", "--unified=3"] {
809            let at = argv
810                .iter()
811                .position(|a| a == flag)
812                .unwrap_or_else(|| panic!("`{flag}` missing from {argv:?}"));
813            assert!(at < sep, "`{flag}` must precede `--`: {argv:?}");
814        }
815    }
816
817    /// MG.18c — `*magit:diff*` compares against HEAD, so one hunk can
818    /// contain both staged and unstaged lines. It is not a patch
819    /// against either tree, and `git apply` would either refuse it or
820    /// (worse) accept a partially-correct one. `d s` / `d u` from
821    /// magit-status open the scoped views where the question has an
822    /// answer; here staging stays file-level.
823    #[test]
824    fn only_the_scoped_views_can_stage_a_hunk() {
825        assert_eq!(
826            source_for_scope(&DiffScope::Staged),
827            Some(DiffSource::Staged)
828        );
829        assert_eq!(
830            source_for_scope(&DiffScope::Unstaged),
831            Some(DiffSource::Unstaged)
832        );
833        assert_eq!(
834            source_for_scope(&DiffScope::Head),
835            None,
836            "a HEAD diff mixes both sides in one hunk"
837        );
838    }
839
840    /// The buffer name is the only carrier of scope, so a parse that
841    /// drifted would silently reclassify every hunk in the view.
842    ///
843    /// MR.3b: asserted through the producer rather than against literal
844    /// names — the repository sits between the view word and the scope
845    /// now, and a literal is exactly what drifts when that moves.
846    #[test]
847    fn the_scope_a_buffer_name_encodes_survives_the_round_trip() {
848        let path = std::path::Path::new("src/a.rs");
849        for (scope, path) in [
850            (DiffScope::Head, None),
851            (DiffScope::Head, Some(path)),
852            (DiffScope::Staged, Some(path)),
853            (DiffScope::Unstaged, Some(path)),
854            (DiffScope::Staged, None),
855            (DiffScope::MergePreview("feature/x".into()), None),
856        ] {
857            let name = crate::workdir::magit_buffer_name_with(
858                "diff",
859                "lattice",
860                &diff_view_rest(&scope, path),
861            );
862            let (parsed_scope, parsed_path) = parse_buffer_name(&name);
863            assert_eq!(parsed_scope, scope, "{name}");
864            assert_eq!(parsed_path.as_deref(), path, "{name}");
865        }
866    }
867
868    /// A file whose name STARTS with a scope word is a path, not a
869    /// scope. `staged-fixtures.rs` is an ordinary filename, and reading
870    /// it as an index-scoped diff of nothing would show an empty buffer
871    /// with no way to tell why.
872    #[test]
873    fn a_path_that_starts_with_a_scope_word_is_still_a_path() {
874        let path = std::path::Path::new("staged-fixtures.rs");
875        let name = crate::workdir::magit_buffer_name_with(
876            "diff",
877            "lattice",
878            &diff_view_rest(&DiffScope::Head, Some(path)),
879        );
880        assert_eq!(
881            parse_buffer_name(&name),
882            (DiffScope::Head, Some(path.to_path_buf())),
883            "{name}"
884        );
885    }
886}