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}