Skip to main content

lattice_magit/
actions.rs

1//! MG.3: magit-status action handlers.
2//!
3//! Each handler captures shared state from the mode's Guard so it
4//! can read the cursor line, resolve the repo, and invoke git
5//! operations. Async operations (diff expansion, refresh) use the
6//! stored tokio handle — no `Runtime::new()`, no `block_on`.
7
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{Arc, Mutex};
11
12use lattice_core::BufferId;
13use lattice_grammar::Effect;
14use lattice_mode::{
15    ActionContext, ActionHandlerContribution, BufferStoreHandle, PendingSyntheticHighlights,
16};
17use lattice_protocol::edit::Edit;
18use lattice_protocol::position::Position;
19use lattice_vcs::{Index, Repository};
20
21use crate::buffer_state::DiffSource;
22use crate::refresh;
23
24pub struct StatusBufferState {
25    pub buffer_id: BufferId,
26    pub store: Arc<BufferStoreHandle>,
27    pub workdir: PathBuf,
28    pub runtime: tokio::runtime::Handle,
29    /// MG.2: optional handle to store styled spans after async edit
30    /// lands, so highlights appear without a keystroke.
31    pub pending_highlights: Option<std::sync::Arc<PendingSyntheticHighlights>>,
32    /// Entries currently inline-expanded (file diff / stash show /
33    /// commit show), keyed by [`entry_key`], value = number of buffer
34    /// lines the expansion occupies.
35    ///
36    /// MG.18d: a refresh no longer clears this. The rebuild *carries*
37    /// the open entries' diffs (`refresh::build_and_format`), so the
38    /// map is replaced with counts recomputed from the text that was
39    /// actually written — staging a hunk makes a diff shorter, and a
40    /// carried-over count would then collapse the wrong rows.
41    pub expanded: HashMap<String, usize>,
42    /// MG.14: the buffer's headerline — branch, ahead/behind, repo
43    /// name, dirty counts. Re-set by every refresh from the same
44    /// `SectionIndex` the body is built from.
45    pub headerline: Option<crate::headerline::MagitHeaderlineHandle>,
46    /// MG.22b: the config, not the value — read per refresh so a
47    /// `:set magit.hunk.context-lines` takes effect on the next `gr`
48    /// rather than only on reopen.
49    pub config: Option<Arc<lattice_config::ConfigRegistry>>,
50    /// MG.18d: where the cursor should land once the next refresh's
51    /// text exists. Set by a mutation, consumed by the refresh it was
52    /// queued for — a later `gr` must not re-apply a stale jump.
53    pub pending_cursor: Option<crate::cursor_restore::HunkRestore>,
54    /// MG.18d: the wake-baked bus the resolved position goes back on.
55    pub cursor_bus: Option<crate::cursor_restore::CursorBusHandle>,
56    /// DS.3: the grammar registry, for syntax-highlighting the code
57    /// inside an inline-expanded diff.
58    ///
59    /// `None` in a harness without the service — the diff then renders
60    /// exactly as it did before syntax layering existed, which is the
61    /// degradation this feature is designed around rather than an
62    /// error path.
63    pub lang_registry: Option<Arc<lattice_syntax::LangRegistry>>,
64}
65
66// ── line classification ─────────────────────────────────
67
68/// What kind of entry occupies a status-buffer line. Derived directly
69/// from the rendered line's fixed layout (see
70/// `SectionIndex::format_buffer_styled`), not by guessing at word
71/// boundaries — this is what lets `classify_line` tell a "new file"
72/// (two-word label) entry apart from every other one-word label.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub(crate) enum StatusLine {
75    File {
76        path: PathBuf,
77        staged: bool,
78        /// Git has no record of this path.
79        ///
80        /// Load-bearing for discard and nothing else so far: `git
81        /// checkout -- <path>` restores a tracked file from the index,
82        /// and on an untracked one it fails with "pathspec … did not
83        /// match any file(s) known to git". Discarding an untracked
84        /// file means *deleting* it, which is a different command and
85        /// a different question to ask the user.
86        untracked: bool,
87        /// Where a renamed / copied path came from, parsed back out of
88        /// the row's `old -> new` rendering.
89        ///
90        /// Load-bearing for UNSTAGE: `git reset HEAD -- <new>` alone
91        /// leaves the old path staged-DELETED, so "unstage this
92        /// rename" would record a deletion the user never asked for.
93        /// Both paths have to be reset together.
94        original_path: Option<PathBuf>,
95    },
96    Stash {
97        index: usize,
98    },
99    Commit {
100        sha: String,
101    },
102}
103
104/// Status labels `SectionIndex::format_buffer_styled` renders via
105/// `format!("  {:<12} {}", label, path)`. Checked as whole-word
106/// prefixes (label followed by whitespace) so diff content inserted
107/// by a toggled-open entry — which can start with an arbitrary
108/// number of leading spaces when the underlying source line is
109/// itself indented — never collides with these.
110///
111/// Must stay in sync with `sections::status_label`'s outputs — the
112/// test `status_label_is_a_subset_of_actions_file_labels` enforces it
113/// mechanically rather than by inspection.
114///
115/// `"clean"` is gone with `PathStatus::Clean`: a clean path is
116/// `PathChange::CLEAN` (nothing on either axis) and never reaches a
117/// rendered row. `"renamed"` / `"copied"` / `"typechange"` arrived
118/// with the variants that were previously collapsed into `Added` (or,
119/// for a type change, dropped entirely).
120pub(crate) const FILE_LABELS: [&str; 16] = [
121    "modified",
122    "new file",
123    "deleted",
124    "renamed",
125    "copied",
126    "typechange",
127    "untracked",
128    "ignored",
129    // The seven unmerged combinations, in git's own wording, plus the
130    // generic fallback for a `U` pairing git documents no name for.
131    "both deleted",
132    "added by us",
133    "deleted by them",
134    "added by them",
135    "deleted by us",
136    "both added",
137    "both modified",
138    "unmerged",
139];
140
141/// Classify the entry at `line`, or `None` if it isn't a
142/// stage/unstage/visit-able entry line (a header, blank line, or
143/// content inside an inline-expanded diff).
144pub(crate) fn classify_line(state: &StatusBufferState, line: u32) -> Option<StatusLine> {
145    let handle = state.store.handle_for(state.buffer_id)?;
146    let snap = handle.snapshot();
147    let text = snap.buffer.line(line)?;
148    // `section_header_above` needs the live buffer, so only call it
149    // when `classify_line_text` actually needs to disambiguate
150    // (File → staged?, or the Recent-commits fallback) — see there.
151    classify_line_text(&text, || section_header_above(state, line))
152}
153
154/// The pure classification core of [`classify_line`], split out so it's
155/// testable without a live buffer/store. `header_above` is called lazily
156/// (only when a candidate match needs to know its enclosing section) so
157/// callers with a real buffer don't pay for an unnecessary backward scan.
158pub(crate) fn classify_line_text(
159    text: &str,
160    header_above: impl FnOnce() -> Option<String>,
161) -> Option<StatusLine> {
162    if !text.starts_with("  ") {
163        return None;
164    }
165    let trimmed = &text[2..];
166    if let Some(rest) = trimmed.strip_prefix("stash@{") {
167        let idx_str = rest.split('}').next()?;
168        return Some(StatusLine::Stash {
169            index: idx_str.parse().ok()?,
170        });
171    }
172    // LONGEST FIRST, and that ordering is load-bearing rather than
173    // tidy: labels are matched as PREFIXES, and `"deleted"` is a
174    // prefix of `"deleted by us"`. Iterating in declaration order, a
175    // `deleted by us   path` row matches `"deleted"`, leaves
176    // `" by us   path"` (which does start with whitespace), and parses
177    // the path as `"by us   path"` — a file that does not exist. Same
178    // trap for `"deleted by them"`.
179    let mut labels = FILE_LABELS;
180    labels.sort_by_key(|l| std::cmp::Reverse(l.len()));
181    for label in labels {
182        if let Some(rest) = trimmed.strip_prefix(label)
183            && rest.starts_with(char::is_whitespace)
184        {
185            let field = rest.trim_start();
186            // A rename / copy row renders `old -> new`. Split from the
187            // RIGHT: ` -> ` is legal inside a filename, and the new
188            // path — the one every action targets — is what follows
189            // the last separator. A ` -> ` inside the NEW name is not
190            // recoverable from the rendered text, which is inherent to
191            // the display form git and magit both use.
192            let (original_path, path) = match field.rsplit_once(" -> ") {
193                Some((from, to)) if label == "renamed" || label == "copied" => {
194                    (Some(PathBuf::from(from)), PathBuf::from(to))
195                }
196                _ => (None, PathBuf::from(field)),
197            };
198            let staged = header_above()
199                .map(|h| h.starts_with("Staged"))
200                .unwrap_or(false);
201            // The label is the fact, and it is already parsed: the
202            // Untracked section renders `PathStatus::Untracked` as
203            // `"untracked"` (`sections::status_label`, held to
204            // `FILE_LABELS` by `status_label_is_a_subset_of_actions_file_labels`).
205            return Some(StatusLine::File {
206                path,
207                staged,
208                untracked: label == "untracked",
209                original_path,
210            });
211        }
212    }
213    // Only commit entries fall through to here: "<sha> <subject>".
214    // Both commit sections render identical rows, so both must classify
215    // — otherwise `<CR>` would work under one heading and silently do
216    // nothing under the other.
217    let header = header_above()?;
218    if header.starts_with("Recent commits") || header.starts_with("Unmerged into") {
219        let sha = trimmed.split_whitespace().next()?;
220        if !sha.is_empty() && sha.chars().all(|c| c.is_ascii_hexdigit()) {
221            return Some(StatusLine::Commit {
222                sha: sha.to_string(),
223            });
224        }
225    }
226    None
227}
228
229/// Stable identity for a `StatusLine`, used as the [`StatusBufferState::expanded`]
230/// key. Includes `staged` for `File` — a `Conflicted` path appears in
231/// BOTH the Staged and Unstaged sections simultaneously (see
232/// `refresh::build_section_index`), as two distinct buffer rows that
233/// can be independently expanded; collapsing that distinction would
234/// let expanding one row's diff make `toggle_expand` treat the
235/// *other* row as already-expanded too, and collapse the wrong line
236/// range.
237pub(crate) fn entry_key(sl: &StatusLine) -> String {
238    match sl {
239        StatusLine::File { path, staged, .. } => format!("f:{staged}:{}", path.display()),
240        StatusLine::Stash { index } => format!("s:{index}"),
241        StatusLine::Commit { sha } => format!("c:{sha}"),
242    }
243}
244
245fn section_header_above(state: &StatusBufferState, line: u32) -> Option<String> {
246    let handle = state.store.handle_for(state.buffer_id)?;
247    let snap = handle.snapshot();
248    for l in (0..=line).rev() {
249        let text = snap.buffer.line(l)?;
250        let t = text.trim();
251        if crate::sections::is_section_header(t) {
252            return Some(t.to_string());
253        }
254    }
255    None
256}
257
258/// MG.18c: map a status section header to the tree its entries' diffs
259/// were produced against.
260///
261/// Untracked files count as Unstaged: a whole-file `s` there is
262/// `git add`, and an untracked file has no diff to expand, so the
263/// hunk path never reaches this with one — the row is here so the
264/// mapping is total rather than silently defaulting.
265///
266/// Split from [`StatusView::diff_source`] so the classification is
267/// testable without a live buffer, the same split `classify_line` /
268/// `classify_line_text` already uses.
269pub(crate) fn diff_source_for_header(header: &str) -> Option<DiffSource> {
270    if header.starts_with("Staged") {
271        Some(DiffSource::Staged)
272    } else if header.starts_with("Unstaged") || header.starts_with("Untracked") {
273        Some(DiffSource::Unstaged)
274    } else {
275        // "Recent commits", "Stashes", "Merge conflicts", …
276        None
277    }
278}
279
280/// Run the git command that shows `sl`'s content: a file's diff
281/// (staged-aware), a stash's patch, or a commit's patch.
282pub(crate) fn run_show(workdir: &Path, sl: &StatusLine, context: i64) -> Option<String> {
283    let mut cmd = std::process::Command::new("git");
284    cmd.current_dir(workdir);
285    // MG.22b: `magit.hunk.context-lines` applies to every patch magit
286    // generates, not only the dedicated diff view — a value honoured in
287    // `:magit-diff` but ignored by magit-status's inline `=` would be
288    // the more confusing half of a half-migration.
289    let unified = format!("--unified={context}");
290    match sl {
291        StatusLine::File { path, staged, .. } => {
292            cmd.arg("diff");
293            if *staged {
294                cmd.arg("--cached");
295            }
296            cmd.arg(&unified).arg("--").arg(path);
297        }
298        StatusLine::Stash { index } => {
299            cmd.args([
300                "stash",
301                "show",
302                "-p",
303                &unified,
304                &format!("stash@{{{index}}}"),
305            ]);
306        }
307        StatusLine::Commit { sha } => {
308            cmd.args(["show", &unified, sha]);
309        }
310    }
311    let output = cmd.output().ok()?;
312    if output.status.success() {
313        String::from_utf8(output.stdout).ok()
314    } else {
315        None
316    }
317}
318
319/// Toggle the inline expansion of `sl` at `cursor_line`: collapse it
320/// if already expanded (removing exactly the number of lines recorded
321/// in `StatusBufferState::expanded` — not a re-scanned guess), or
322/// insert its `git show`/`git diff` output and record the inserted
323/// line count if collapsed. Shared by `=` (files) and `<CR>`
324/// (stashes/commits).
325/// MG.44: what a press on an already-classified file line should do.
326///
327/// Pulled out as a pure decision because it is the whole behavioural
328/// change: before, an expanded entry was DELETED from the buffer and
329/// the next press re-ran `git diff`. Now it folds, so the fetched rows
330/// survive. Keeping the decision separate from the effect is what
331/// makes that assertable without a `BufferStoreHandle`.
332#[derive(Debug, PartialEq, Eq)]
333enum DiffToggle {
334    /// Nothing fetched yet — run `git diff` and insert it.
335    Fetch,
336    /// Rows are present — hide/show them, keeping the text.
337    Fold,
338    /// Recorded as expanded but occupying no rows, so there is
339    /// nothing to fold; forget it instead.
340    Drop,
341}
342
343impl DiffToggle {
344    fn for_state(existing_count: Option<usize>) -> Self {
345        match existing_count {
346            None => Self::Fetch,
347            Some(0) => Self::Drop,
348            Some(_) => Self::Fold,
349        }
350    }
351}
352
353/// MG.44: the body BOTH `=` and `<Tab>` run on a status file line.
354///
355/// One operation with three states — not fetched, shown, hidden:
356///
357/// - not fetched -> run `git diff` and insert it
358/// - shown       -> fold it shut (the rows stay)
359/// - hidden      -> unfold
360///
361/// `=` and `<Tab>` were previously different operations on the same
362/// line: one spliced text in and out, the other folded whatever was
363/// already there. Sharing the body is what makes them agree, and it
364/// has to be shared rather than duplicated because `<Tab>` is owned by
365/// `magit-core-mode` (a MINOR mode, which outranks the status major in
366/// the layer order) while `=` is the status major's own chord — two
367/// copies would drift and only one of them would ever be reachable.
368///
369/// Off a file line there is nothing magit-specific to do, so the
370/// generic fold toggle stands: `<Tab>` keeps working on section
371/// headers and hunks exactly as before.
372pub(crate) fn toggle_diff_or_fold(ctx: &ActionContext<'_>) -> Option<Effect> {
373    let fold = || {
374        Some(Effect::AppAction(
375            lattice_grammar::AppEffect::ToggleFoldAtCursor,
376        ))
377    };
378    let Some(s) = status_state(ctx) else {
379        return fold();
380    };
381    let sl = {
382        let Ok(g) = s.lock() else { return fold() };
383        classify_line(&g, ctx.cursor.line)
384    };
385    let Some(sl @ StatusLine::File { .. }) = sl else {
386        return fold();
387    };
388    toggle_expand(&s, sl, ctx.cursor.line)
389}
390
391fn toggle_expand(
392    s: &Arc<Mutex<StatusBufferState>>,
393    sl: StatusLine,
394    cursor_line: u32,
395) -> Option<Effect> {
396    let key = entry_key(&sl);
397    let (handle, wd, rt, existing_count, pending, bid, context, hl, registry) = {
398        let g = s.lock().ok()?;
399        let h = g.store.handle_for(g.buffer_id)?;
400        let context = context_lines(&g.config);
401        (
402            h,
403            g.workdir.clone(),
404            g.runtime.clone(),
405            g.expanded.get(&key).copied(),
406            g.pending_highlights.clone(),
407            g.buffer_id,
408            context,
409            g.headerline.clone(),
410            crate::hunk_syntax::syntax_registry(g.lang_registry.clone(), g.config.as_ref()),
411        )
412    };
413
414    match DiffToggle::for_state(existing_count) {
415        DiffToggle::Fold => {
416            // MG.44: **hide it, do not delete it.**
417            //
418            // This branch used to splice the diff out of the buffer, so
419            // re-showing it re-ran `git diff` — throwing away work
420            // already done and paying I/O for a keystroke that shows
421            // text the buffer had a moment ago. A fold hides the rows
422            // and keeps them, which is what emacs magit does and what
423            // the buffer model already provides.
424            //
425            // Nothing is removed from `expanded` either: the entry IS
426            // still expanded, it is merely folded shut. Clearing it
427            // would make the next press re-fetch, which is the very
428            // thing this removed — and would desync the fold ranges
429            // `MagitStatusFoldSource` derives from that map.
430            return Some(Effect::AppAction(
431                lattice_grammar::AppEffect::ToggleFoldAtCursor,
432            ));
433        }
434        DiffToggle::Drop => {
435            // A zero-line expansion has no rows to fold, so there is
436            // nothing to hide and the entry is dropped as before.
437            if let Ok(mut g) = s.lock() {
438                g.expanded.remove(&key);
439            }
440        }
441        DiffToggle::Fetch => {
442            let pos = Position::new(cursor_line + 1, 0);
443            let start_line = cursor_line + 1;
444            let s = s.clone();
445            let path = match &sl {
446                StatusLine::File { path, .. } => path.display().to_string(),
447                _ => String::new(),
448            };
449            rt.spawn(async move {
450                // MG.31: the git call happens HERE, inside the spawned
451                // task, not above on the actor thread. See
452                // [`expand_payload`].
453                let (text, line_count, spans, refine) =
454                    match expand_payload(wd, sl, context, registry).await {
455                        Ok(payload) => payload,
456                        // MG.56: say something. Returning quietly here is
457                        // what made `=` look like an unbound key on a row
458                        // whose changes had been committed elsewhere — the
459                        // press did fire, git did answer, and the answer
460                        // was an empty patch.
461                        Err(miss) => {
462                            crate::headerline::publish_notice(
463                                &hl,
464                                Some(match miss {
465                                    ExpandMiss::NoChanges => {
466                                        format!("no changes in {path} — press gr to refresh")
467                                    }
468                                    ExpandMiss::Failed(e) => {
469                                        format!("could not diff {path}: {e}")
470                                    }
471                                }),
472                            );
473                            return;
474                        }
475                    };
476                let _ = handle
477                    .apply_edit_batch(vec![Edit::insert(pos, format!("{}\n", text))])
478                    .await;
479                // Recorded only after the insert lands — see the
480                // collapse-branch comment above. Recording it
481                // beforehand let a rapid second `=`/`<CR>` press see
482                // "already expanded" and race the collapse branch
483                // against rows the insert hadn't populated yet.
484                if let Ok(mut g) = s.lock() {
485                    g.expanded.insert(key, line_count);
486                }
487                if let Some(ref ph) = pending {
488                    ph.insert_at_refined_and_wake(bid, start_line, spans, refine);
489                }
490            });
491        }
492    }
493    None
494}
495
496/// MG.31: the blocking half of an inline expansion — the `git` call and
497/// the styling of its output — on the blocking pool.
498///
499/// **Why this is a function and not three lines in `toggle_expand`.**
500/// `toggle_expand` is an action handler, so its body runs on the editor
501/// actor's `current_thread` runtime (`editor_actor.rs`: one task,
502/// `run_actor` processes commands one at a time). [`run_show`] ends in
503/// `Command::output()` — a fork/exec plus wait — so calling it from the
504/// handler stalled the loop that services keystrokes for the whole
505/// duration of a `git diff`, which alone exceeds paramount-goal-1's
506/// one-frame ceiling. Every other magit view already ran its git call
507/// inside `spawn_blocking`, including [`run_show`]'s two other callers;
508/// this path was the one that did not.
509///
510/// The styling moves with it deliberately: it is `O(lines)` over the
511/// diff and belongs on the same side of the boundary as the call that
512/// produced it.
513///
514/// `None` when the entry has no diff to show (git failed, or the output
515/// was blank) — the caller then inserts nothing, exactly as before.
516/// Why an expansion produced nothing.
517///
518/// The two used to collapse into one `None`, and the caller returned
519/// silently on either — so pressing `=` on a file whose changes had
520/// since been committed elsewhere did *nothing*, repeatedly, and looked
521/// exactly like an unbound key. They are different problems with
522/// different fixes and have to be told apart to say anything useful.
523#[derive(Debug)]
524pub(crate) enum ExpandMiss {
525    /// git answered, and the answer was an empty patch. Almost always
526    /// a stale buffer: the row is still listed because the status scan
527    /// that produced it has been overtaken by a commit, a stage, or an
528    /// edit made outside this view.
529    NoChanges,
530    /// The git call itself failed.
531    Failed(String),
532}
533
534async fn expand_payload(
535    workdir: PathBuf,
536    sl: StatusLine,
537    context: i64,
538    lang_registry: Option<Arc<lattice_syntax::LangRegistry>>,
539) -> Result<
540    (
541        String,
542        usize,
543        Vec<Vec<lattice_cells::style::StyledSpan>>,
544        Vec<Vec<lattice_cells::RefineSpan>>,
545    ),
546    ExpandMiss,
547> {
548    tokio::task::spawn_blocking(move || {
549        let raw = run_show(&workdir, &sl, context)
550            .ok_or_else(|| ExpandMiss::Failed("git could not read the diff".to_string()))?;
551        if raw.trim().is_empty() {
552            return Err(ExpandMiss::NoChanges);
553        }
554        // MG.46: only the trailing newline goes. A patch is not free
555        // text — each hunk's `@@` header declares how many body lines
556        // follow, and `hunk_fold_source` bounds the fold by that count.
557        // `.trim()` also ate a trailing blank context line (git emits
558        // one as a lone space), leaving the text one line shorter than
559        // its own header claimed, and the fold then ran past the end of
560        // the diff into the status rows below.
561        let text = raw.trim_end_matches('\n').to_string();
562        let line_count = text.lines().count();
563        // DR.3 fix: the `=` toggle publishes refinement too. It
564        // previously took spans alone, so an expansion opened by `=`
565        // showed no intra-line highlight while the same expansion
566        // rebuilt by `gr` did — the same route asymmetry DS-fix
567        // removed for syntax.
568        let styled = crate::hunk_syntax::styled_diff(&text, lang_registry.as_ref());
569        Ok((text, line_count, styled.spans, styled.refine))
570    })
571    .await
572    .unwrap_or_else(|e| Err(ExpandMiss::Failed(e.to_string())))
573}
574
575// ── registration ────────────────────────────────────────
576
577/// MG.13: service alias for magit-status's per-buffer state
578/// (`feedback_servicesregistry_arc_typeid`).
579pub type StatusStatesHandle = Arc<crate::buffer_state::BufferStates<StatusBufferState>>;
580
581/// Resolve the status buffer's state for the buffer an action fired
582/// in. `None` means this is not a live magit-status buffer, so the
583/// handler declines — the same outcome as before, minus the race.
584pub(crate) fn status_state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<StatusBufferState>>> {
585    crate::buffer_state::state_for::<StatusBufferState>(ctx)
586}
587
588/// MG.13: magit-status's action handlers, registered once at boot by
589/// `MagitStatusMode::action_handlers()`.
590///
591/// Each body opens with `let s = status_state(ctx)?;` — resolving this
592/// buffer's state from the `BufferStates<StatusBufferState>` service
593/// rather than closing over it at activation. That removes the window
594/// in which `x` / `=` / `<CR>` resolved but had no handler yet. `s`,
595/// `u` and `gr` are NOT here: they are shared with `magit-diff-mode`,
596/// so `magit-core-mode` owns their single handler and reaches this
597/// buffer through [`StatusView`] (see `buffer_state::MagitView`).
598pub fn status_action_handlers() -> Vec<ActionHandlerContribution> {
599    let mut contributions: Vec<ActionHandlerContribution> = Vec::new();
600
601    macro_rules! handler {
602        ($name:expr, $body:expr) => {
603            contributions.push(ActionHandlerContribution {
604                action_name: $name,
605                handler: Arc::new($body),
606            });
607        };
608    }
609
610    // Run `mutate` (a blocking git call) on `spawn_blocking`, off the
611    // actor thread entirely, then refresh — the shape every mutating
612    // handler below uses instead of calling git synchronously inline.
613    // Handlers read whatever cursor/path state they need up front
614    // (fast, in-memory) and hand this a self-contained closure; the
615    // handler itself returns `None` immediately, before the git call
616    // has even started.
617
618    // ── stage (s) ──────────────────────────────────────
619    // MG.13: registered once at boot by `magit-core-mode` (a shared
620    // action — `magit-diff-mode` binds `s` too) and dispatched
621    // through `StatusView`'s `MagitView` impl below.
622
623    // ── unstage (u) ───────────────────────────────────
624    // MG.13: registered once at boot by `magit-core-mode` (a shared
625    // action — `magit-diff-mode` binds `u` too) and dispatched
626    // through `StatusView`'s `MagitView` impl below.
627
628    // ── discard (x) ───────────────────────────────────
629    // PU.6: prompt for confirmation before destructive discard.
630    //
631    // MG.18c: hunk-at-cursor first, exactly as `s` / `u` resolve —
632    // but through the ask/execute pair, because §12.13 requires a
633    // destructive action's chord to perform no git call at all. The
634    // prompt names the hunk's file position so the question is
635    // answerable without dismissing it.
636    {
637        handler!("action:magit-discard", move |ctx: &ActionContext<'_>| {
638            match crate::magit_core_mode::resolve_hunk(ctx, crate::magit_core_mode::HunkOp::Discard)
639            {
640                crate::magit_core_mode::HunkResolution::Ready {
641                    patch,
642                    region_lines,
643                    workdir,
644                    ..
645                } => {
646                    // MG.18e: the prompt names what will actually go.
647                    // "Discard hunk" over a 2-line selection would be a
648                    // question about something the user did not ask for
649                    // — and §12.13 requires the question to be
650                    // answerable without dismissing it.
651                    let target = match region_lines {
652                        Some(1) => format!("1 line of {}", patch.display_location()),
653                        Some(n) => format!("{n} lines of {}", patch.display_location()),
654                        None => format!("hunk at {}", patch.display_location()),
655                    };
656                    // IX.2: carry the PATCH, not the rows it came
657                    // from. A row span is a coordinate a rebuild
658                    // invalidates — a refresh landing while the dialog
659                    // is open would make the same span mean different
660                    // lines. The patch is content, so it still means
661                    // what it meant; and if the tree moved under it,
662                    // `git apply`'s context check refuses it loudly
663                    // rather than discarding somewhere plausible.
664                    Some(crate::confirm::ask_with(
665                        format!("Discard {target}?"),
666                        "action:magit-discard-execute",
667                        lattice_grammar::Args::List(vec![
668                            lattice_grammar::ArgValue::String(String::new()),
669                            lattice_grammar::ArgValue::String(patch.to_patch()),
670                            lattice_grammar::ArgValue::String(
671                                workdir.to_string_lossy().into_owned(),
672                            ),
673                        ]),
674                    ))
675                }
676                crate::magit_core_mode::HunkResolution::Refused(effect) => Some(effect),
677                crate::magit_core_mode::HunkResolution::FileLevel => {
678                    let s = status_state(ctx)?;
679                    // A Visual selection over ENTRY rows means "these files",
680                    // not "this file" — the same rule `s` and `u` already
681                    // follow in `stage_or_unstage`. `x` was the one that did
682                    // not: it read `ctx.cursor.line` alone, so selecting three
683                    // untracked files and pressing `x` discarded exactly one.
684                    if let Some(region) = ctx.selection {
685                        let lo = region.start.line.min(region.end.line);
686                        let hi = region.start.line.max(region.end.line);
687                        if hi > lo
688                            && let Some((files, _)) = discardable_files_in_rows(&s, lo..=hi)
689                            && files.len() > 1
690                        {
691                            return Some(batch_discard_confirm(&files));
692                        }
693                    }
694                    let g = s.lock().ok()?;
695                    let StatusLine::File {
696                        path, untracked, ..
697                    } = classify_line(&g, ctx.cursor.line)?
698                    else {
699                        return None;
700                    };
701                    drop(g);
702                    Some(file_discard_confirm(&path, untracked))
703                }
704            }
705        });
706    }
707    // PU.6: actual discard, dispatched by Confirm's yes-action.
708    //
709    // IX.2: acts on what the prompt named. The ask half carries either
710    // the synthesized patch (hunk / region) or the path (file), and
711    // this half prefers that over anything it could re-derive — a
712    // refresh landing while the dialog is open rebuilds the buffer and
713    // moves the cursor, so re-derivation is how you discard a file you
714    // never confirmed.
715    //
716    // A patch is content, not coordinates, so it still means what it
717    // meant; and if the working tree moved under it, `git apply`'s
718    // exact-context check refuses it loudly instead of applying it at a
719    // plausible-looking offset.
720    {
721        handler!(
722            "action:magit-discard-execute",
723            // The collapse lands on the EXECUTE half, not the ask: `x` over a
724            // selection returns `Effect::Confirm` and has not acted yet, so
725            // dropping the selection there would lose it for a question the
726            // user may still answer `no` to.
727            crate::magit_core_mode::consuming_selection(move |ctx: &ActionContext<'_>| {
728                // Slot 1 is the carried patch, slot 2 its workdir.
729                if let (Some(patch), Some(workdir)) = (ctx.arg_str(1), ctx.arg_str(2))
730                    && !patch.is_empty()
731                {
732                    return Some(crate::magit_core_mode::spawn_patch_discard(
733                        std::path::PathBuf::from(workdir),
734                        patch.to_string(),
735                        crate::buffer_state::view_for(ctx),
736                    ));
737                }
738                if let Some(path) = crate::confirm::carried_target(ctx)
739                    && !path.is_empty()
740                {
741                    let s = status_state(ctx)?;
742                    let workdir = s.lock().ok()?.workdir.clone();
743                    return spawn_mutation_and_refresh(
744                        s.clone(),
745                        format!("discard {path}"),
746                        move || {
747                            let repo = Repository::discover(&workdir)
748                                .map_err(|e| format!("not a git repository: {e}"))?;
749                            repo.run_git(["checkout", "--", &path])
750                                .map(|out| String::from_utf8_lossy(&out).into_owned())
751                                .map_err(|e| e.to_string())
752                        },
753                    );
754                }
755                match crate::magit_core_mode::resolve_hunk(
756                    ctx,
757                    crate::magit_core_mode::HunkOp::Discard,
758                ) {
759                    crate::magit_core_mode::HunkResolution::Ready {
760                        view,
761                        workdir,
762                        patch,
763                        site,
764                        region_lines,
765                    } => Some(crate::magit_core_mode::spawn_hunk_apply(
766                        view,
767                        workdir,
768                        patch,
769                        crate::magit_core_mode::HunkOp::Discard,
770                        site,
771                        region_lines,
772                    )),
773                    crate::magit_core_mode::HunkResolution::Refused(effect) => Some(effect),
774                    crate::magit_core_mode::HunkResolution::FileLevel => {
775                        let s = status_state(ctx)?;
776                        let (path, untracked, workdir) = {
777                            let g = s.lock().ok()?;
778                            let StatusLine::File {
779                                path, untracked, ..
780                            } = classify_line(&g, ctx.cursor.line)?
781                            else {
782                                return None;
783                            };
784                            (path, untracked, g.workdir.clone())
785                        };
786                        // Same split as the ask half: `git checkout`
787                        // cannot restore a path git has no record of.
788                        if untracked {
789                            return spawn_untracked_delete(s.clone(), workdir, path);
790                        }
791                        spawn_mutation_and_refresh(
792                            s.clone(),
793                            format!("discard {}", path.display()),
794                            move || {
795                                let repo = Repository::discover(&workdir)
796                                    .map_err(|e| format!("not a git repository: {e}"))?;
797                                repo.run_git(["checkout", "--", &path.to_string_lossy()])
798                                    .map(|out| String::from_utf8_lossy(&out).into_owned())
799                                    .map_err(|e| e.to_string())
800                            },
801                        )
802                    }
803                }
804            })
805        );
806    }
807
808    // Discarding an UNTRACKED file, after confirmation.
809    //
810    // A separate execute half rather than a branch inside the one
811    // above, for the reason `magit-global-file-discard` / `-delete` /
812    // `-checkout` are already three pairs: one action, one act. The
813    // ask half chooses which to name, so the question the user
814    // answered and the command that runs cannot drift apart.
815    {
816        handler!(
817            "action:magit-discard-untracked-execute",
818            // The collapse lands on the EXECUTE half, not the ask: `x` over a
819            // selection returns `Effect::Confirm` and has not acted yet, so
820            // dropping the selection there would lose it for a question the
821            // user may still answer `no` to.
822            crate::magit_core_mode::consuming_selection(move |ctx: &ActionContext<'_>| {
823                let s = status_state(ctx)?;
824                let workdir = s.lock().ok()?.workdir.clone();
825                // IX.2: act on what the prompt named. Re-derivation is
826                // the fallback for a path that carried nothing.
827                let path = match crate::confirm::carried_target(ctx) {
828                    Some(carried) if !carried.is_empty() => PathBuf::from(carried),
829                    _ => {
830                        let g = s.lock().ok()?;
831                        let StatusLine::File { path, .. } = classify_line(&g, ctx.cursor.line)?
832                        else {
833                            return None;
834                        };
835                        path
836                    }
837                };
838                spawn_untracked_delete(s.clone(), workdir, path)
839            })
840        );
841    }
842
843    // Discarding a MULTI-FILE selection, after confirmation.
844    //
845    // One handler for both kinds, unlike the single-file pair above, and the
846    // split there is the reason: those two exist so the question the user
847    // answered and the command that runs cannot drift apart, which works when
848    // the ask names one file. A selection can hold both kinds at once, and
849    // asking two questions for one keypress is worse than asking one — so the
850    // ask names both counts (see `batch_discard_confirm`) and this half does
851    // each path the right way.
852    //
853    // ONE task, ONE refresh, and one `git` invocation per kind rather than per
854    // file — `stage_rows`' reasoning: N spawns meant N `.git/index.lock`
855    // cycles and a partial batch nobody could describe.
856    {
857        handler!(
858            "action:magit-discard-batch-execute",
859            // The collapse lands on the EXECUTE half, not the ask: `x` over a
860            // selection returns `Effect::Confirm` and has not acted yet, so
861            // dropping the selection there would lose it for a question the
862            // user may still answer `no` to.
863            crate::magit_core_mode::consuming_selection(move |ctx: &ActionContext<'_>| {
864                let files = carried_batch(&ctx.args);
865                if files.is_empty() {
866                    return None;
867                }
868                let s = status_state(ctx)?;
869                let workdir = s.lock().ok()?.workdir.clone();
870                let tracked: Vec<String> = files
871                    .iter()
872                    .filter(|(_, u)| !*u)
873                    .map(|(p, _)| p.to_string_lossy().into_owned())
874                    .collect();
875                let untracked: Vec<String> = files
876                    .iter()
877                    .filter(|(_, u)| *u)
878                    .map(|(p, _)| p.to_string_lossy().into_owned())
879                    .collect();
880                let label = format!("discard {} files", files.len());
881                spawn_mutation_and_refresh(s.clone(), label, move || {
882                    let repo = Repository::discover(&workdir)
883                        .map_err(|e| format!("not a git repository: {e}"))?;
884                    let mut out = String::new();
885                    if !tracked.is_empty() {
886                        let mut args: Vec<&str> = vec!["checkout", "--"];
887                        args.extend(tracked.iter().map(String::as_str));
888                        out.push_str(
889                            &repo
890                                .run_git(args)
891                                .map(|o| String::from_utf8_lossy(&o).into_owned())
892                                .map_err(|e| e.to_string())?,
893                        );
894                    }
895                    if !untracked.is_empty() {
896                        // `clean -f -d`, for `spawn_untracked_delete`'s
897                        // reason: checkout addresses paths git knows, and
898                        // these by definition are not.
899                        let mut args: Vec<&str> = vec!["clean", "-f", "-d", "--"];
900                        args.extend(untracked.iter().map(String::as_str));
901                        out.push_str(
902                            &repo
903                                .run_git(args)
904                                .map(|o| String::from_utf8_lossy(&o).into_owned())
905                                .map_err(|e| e.to_string())?,
906                        );
907                    }
908                    Ok(out)
909                })
910            })
911        );
912    }
913
914    // ── visit (<CR>) ───────────────────────────────────
915    // File entries open the file — the INDEX blob for a Staged
916    // entry (`*magit:file:staged:<path>*`, read-only: this section
917    // describes what's staged, which may already differ from a
918    // since-edited working copy), the live editable working-tree
919    // file for Unstaged (uniform with magit-diff-mode's own
920    // Staged-vs-Unstaged `<CR>` split — see magit.md §6.3). Stash
921    // entries toggle their inline patch, same mechanism `=` uses
922    // for files (there's no dedicated "stash detail" buffer to open
923    // instead).
924    {
925        handler!("action:magit-visit", move |ctx: &ActionContext<'_>| {
926            let s = status_state(ctx)?;
927            visit_status_line(&s, ctx.cursor.line)
928        });
929    }
930
931    status_action_handlers_rest(&mut contributions);
932    contributions
933}
934
935/// MG.22: magit-status's `<CR>` body, lifted out of the handler so the
936/// `MagitView` can answer with it too.
937///
938/// `magit-hunk-mode` owns the chord; this stays the status buffer's
939/// answer for rows that are not diff content, reached through
940/// `MagitView::visit_at_cursor`. The `action:magit-visit` id is kept
941/// so an ex-command or a user keymap can still reach it directly.
942fn visit_status_line(s: &Arc<Mutex<StatusBufferState>>, line: u32) -> Option<Effect> {
943    let (sl, label) = {
944        let g = s.lock().ok()?;
945        (
946            classify_line(&g, line)?,
947            crate::repo_scope::label_of_buffer(&g.store, g.buffer_id),
948        )
949    };
950    match sl {
951        StatusLine::File {
952            path, staged: true, ..
953        } => Some(Effect::OpenSyntheticBuffer {
954            name: crate::magit_file_revision_mode::blob_buffer_name(&label, "staged", &path),
955            mode_id: "magit-file-revision-mode".to_string(),
956            content: None,
957            cursor: None,
958            activate_minor: None,
959        }),
960        StatusLine::File {
961            path,
962            staged: false,
963            ..
964        } => {
965            let g = s.lock().ok()?;
966            let full = g.workdir.join(&path);
967            full.exists().then_some(Effect::OpenBuffer {
968                path: Some(full),
969                force: false,
970            })
971        }
972        StatusLine::Stash { .. } => toggle_expand(s, sl, line),
973        // Bug fix: `<CR>` on a commit SHA used to toggle the inline
974        // diff (same as `=`) — but every other magit view that shows a
975        // SHA (log, blame, rebase) treats `<CR>` as "open the dedicated
976        // commit buffer", so status was the one inconsistent surface.
977        // `=` still does the inline toggle for a quick look without
978        // leaving the status buffer.
979        // MR.3b: `show`, not `commit` — the compose buffer owns that
980        // view word, and with the repository in segment 2 the two would
981        // be the same name in a checkout called like a sha. The label
982        // comes from THIS buffer's name, which is the status buffer of
983        // the repository whose commit is being shown.
984        StatusLine::Commit { sha } => Some(Effect::OpenSyntheticBuffer {
985            name: crate::workdir::magit_buffer_name_with(
986                crate::magit_revision_mode::SHOW_VIEW,
987                &label,
988                &sha,
989            ),
990            mode_id: "magit-revision-mode".to_string(),
991            content: None,
992            cursor: None,
993            activate_minor: None,
994        }),
995    }
996}
997
998/// The remaining status handlers, split from
999/// [`status_action_handlers`] only because `visit_status_line` had to
1000/// be lifted to module scope between them.
1001fn status_action_handlers_rest(contributions: &mut Vec<ActionHandlerContribution>) {
1002    macro_rules! handler {
1003        ($name:expr, $body:expr) => {
1004            contributions.push(ActionHandlerContribution {
1005                action_name: $name,
1006                handler: Arc::new($body),
1007            });
1008        };
1009    }
1010
1011    // ── commit (cc) ───────────────────────────────────
1012    {
1013        handler!("action:magit-commit", move |ctx: &ActionContext<'_>| {
1014            let _ = status_state(ctx)?;
1015            Some(Effect::OpenSyntheticBuffer {
1016                name: "*magit:commit*".to_string(),
1017                mode_id: "magit-commit-mode".to_string(),
1018                content: None,
1019                cursor: None,
1020                activate_minor: None,
1021            })
1022        });
1023    }
1024
1025    // ── commit amend (ca) ─────────────────────────────
1026    {
1027        handler!("action:magit-commit-amend", move |ctx: &ActionContext<
1028            '_,
1029        >| {
1030            let _ = status_state(ctx)?;
1031            Some(Effect::OpenSyntheticBuffer {
1032                name: "*magit:amend*".to_string(),
1033                mode_id: "magit-commit-mode".to_string(),
1034                content: None,
1035                cursor: None,
1036                activate_minor: None,
1037            })
1038        });
1039    }
1040
1041    // ── stage patch (p) ───────────────────────────────
1042    // `git add -p` is genuinely interactive — it reads its own
1043    // prompts from stdin, which the TUI's raw-mode input loop already
1044    // owns. Running it via `Command::output()` (as this handler used
1045    // to) blocks the single-threaded actor waiting for a child that's
1046    // also waiting on stdin neither process routes to the other —
1047    // an indefinite hang, not just a slow blocking call. Until there's
1048    // a terminal-suspend mechanism (`:!`-style handoff) to route through,
1049    // fail loudly instead of hanging: stage via `s` (file-level) or
1050    // expand the diff with `=` and review before staging.
1051    {
1052        handler!("action:magit-stage-patch", move |ctx: &ActionContext<
1053            '_,
1054        >| {
1055            let _ = status_state(ctx)?;
1056            Some(Effect::Echo {
1057                level: lattice_grammar::EchoLevel::Error,
1058                text: "magit: interactive `git add -p` isn't supported yet — stage the whole \
1059                       file with `s`, or expand the diff with `=` to review first"
1060                    .to_string(),
1061            })
1062        });
1063    }
1064
1065    // ── refresh (gr) ──────────────────────────────────
1066    // MG.13: registered once at boot by `magit-core-mode` and
1067    // dispatched through the status buffer's `MagitView`; see
1068    // `buffer_state::MagitView` for why it cannot be per-mode.
1069
1070    // ── toggle diff (=) ───────────────────────────────
1071    {
1072        // MG.44: `=` and `<Tab>` are the same operation now — see
1073        // `toggle_diff_or_fold`.
1074        handler!("action:magit-toggle-diff", move |ctx: &ActionContext<
1075            '_,
1076        >| {
1077            toggle_diff_or_fold(ctx)
1078        });
1079    }
1080
1081    // ── diff-file (d) — open a dedicated diff buffer scoped to
1082    // the file at cursor AND its section's baseline (index for
1083    // Staged, working-tree-vs-index for Unstaged), instead of
1084    // expanding inline like `=`. See `magit_diff_mode`'s `DiffScope`.
1085    {
1086        handler!("action:magit-diff-file", move |ctx: &ActionContext<'_>| {
1087            let s = status_state(ctx)?;
1088            let g = s.lock().ok()?;
1089            let StatusLine::File { path, staged, .. } = classify_line(&g, ctx.cursor.line)? else {
1090                return None;
1091            };
1092            // MR.3b: the scope and the path are this view's `rest`;
1093            // the repository in front of them comes from the buffer this
1094            // fired in, which is the status buffer of the repo being
1095            // diffed.
1096            let scope = if staged {
1097                crate::magit_diff_mode::DiffScope::Staged
1098            } else {
1099                crate::magit_diff_mode::DiffScope::Unstaged
1100            };
1101            Some(crate::magit_global_mode::open_repo_view_from_action_with(
1102                ctx,
1103                "diff",
1104                "magit-diff-mode",
1105                Some(&crate::magit_diff_mode::diff_view_rest(&scope, Some(&path))),
1106            ))
1107        });
1108    }
1109
1110    // ── close (q) ─────────────────────────────────────
1111    // MG.13: removed from here. `action:magit-close` was registered by
1112    // BOTH this mode (`Effect::BufferDelete`) and `magit-core-mode`
1113    // (`Effect::DismissPopup`). Same action id ⇒ last registrant won,
1114    // decided by cascade ordering, so `q` in the status buffer was
1115    // nondeterministic between "delete the buffer" and "bury it".
1116    //
1117    // This is not a behaviour *choice* — `DismissPopup` is the already
1118    // documented and already tested intent. `magit-core-mode`'s handler
1119    // records the live-reported bug it fixed (`q` quitting the whole
1120    // editor), and
1121    // `lattice-ui-tui`'s `q_on_magit_status_buries_it_and_never_quits_the_editor`
1122    // asserts that `q` restores the buffer that was active before
1123    // magit-status opened. The registration here contradicted that
1124    // test; whenever it won the race the guarantee was simply not in
1125    // force. Removing it makes the tested behaviour deterministic.
1126
1127    // ── MG.23h: jump to a section (the `s` row's submenu) ──
1128    //
1129    // Fired from the dispatch menu, which by then owns the keystrokes —
1130    // so the handler reads the buffer that was active when it opened
1131    // (`ActionContext::buffer_id`), the same seam every other menu row
1132    // resolves through.
1133    //
1134    // The section is found by scanning for its header text rather than
1135    // by consulting the `SectionIndex`: `]]` / `[[` already locate
1136    // sections that way, and two mechanisms for "where does this
1137    // section start" is one more than can stay in agreement. The
1138    // prefixes come from `sections::SECTION_HEADER_PREFIXES`, which is
1139    // also what renders them.
1140    for (action_name, prefix) in [
1141        ("action:magit-jump-staged", "Staged changes"),
1142        ("action:magit-jump-unstaged", "Unstaged changes"),
1143        ("action:magit-jump-untracked", "Untracked files"),
1144        ("action:magit-jump-stashes", "Stashes"),
1145        ("action:magit-jump-unmerged", "Unmerged into"),
1146        ("action:magit-jump-commits", "Recent commits"),
1147    ] {
1148        contributions.push(ActionHandlerContribution {
1149            action_name,
1150            handler: Arc::new(move |ctx: &ActionContext<'_>| Some(jump_to_section(ctx, prefix))),
1151        });
1152    }
1153}
1154
1155/// MG.23h: move the cursor to the section whose header starts with
1156/// `prefix`, or say it isn't there.
1157///
1158/// A section with no entries is not rendered at all, so "jump to
1159/// Stashes" in a repo with no stashes has nothing to land on. Echoing
1160/// beats leaving the cursor where it was with no explanation — from
1161/// inside a menu, a row that appears to do nothing reads as broken.
1162fn jump_to_section(ctx: &ActionContext<'_>, prefix: &str) -> Effect {
1163    let found = ctx
1164        .services
1165        .get::<lattice_mode::BufferStoreHandle>()
1166        .and_then(|store| store.handle_for(lattice_core::BufferId(ctx.buffer_id.0 as u32)))
1167        .and_then(|handle| {
1168            let snap = handle.snapshot();
1169            (0..snap.buffer.content_line_count()).find(|l| {
1170                snap.buffer
1171                    .line(*l)
1172                    .is_some_and(|t| t.trim_start().starts_with(prefix))
1173            })
1174        });
1175    match found {
1176        Some(row) => Effect::CursorMove(lattice_protocol::position::Position::new(row, 0)),
1177        None => Effect::Echo {
1178            level: lattice_grammar::EchoLevel::Info,
1179            text: format!("magit: no {prefix} section here"),
1180        },
1181    }
1182}
1183
1184/// The distinct files the buffer rows `rows` cover, plus the workdir.
1185///
1186/// `None` when the selection holds no file entry at all — a range over
1187/// section headers or commit rows, where staging means nothing. The
1188/// caller then falls through to the cursor's own entry, which declines
1189/// the same way it always did.
1190fn files_in_rows(
1191    s: &Arc<Mutex<StatusBufferState>>,
1192    rows: std::ops::RangeInclusive<u32>,
1193) -> Option<(Vec<PathBuf>, PathBuf)> {
1194    let g = s.lock().ok()?;
1195    let paths = distinct_files(rows.map(|line| classify_line(&g, line)));
1196    if paths.is_empty() {
1197        return None;
1198    }
1199    Some((paths, g.workdir.clone()))
1200}
1201
1202/// The distinct file paths in a run of classified rows, in buffer
1203/// order.
1204///
1205/// Pure, because the decisions are here rather than in the lookup
1206/// around it. **Distinct** matters twice: a file entry and its expanded
1207/// inline diff are separate rows of the same file, so a selection
1208/// covering both must not stage it twice; and the same path can appear
1209/// in the staged *and* unstaged sections at once. **Buffer order**
1210/// matters because a batch reported in a different order than it is
1211/// shown is harder to check.
1212pub(crate) fn distinct_files(lines: impl Iterator<Item = Option<StatusLine>>) -> Vec<PathBuf> {
1213    let mut paths: Vec<PathBuf> = Vec::new();
1214    for line in lines.flatten() {
1215        if let StatusLine::File { path, .. } = line
1216            && !paths.contains(&path)
1217        {
1218            paths.push(path);
1219        }
1220    }
1221    paths
1222}
1223
1224/// Run a repository mutation off-thread, report it, then refresh.
1225///
1226/// **`mutate` returns a `Result` and that is not incidental.** It used
1227/// to be `impl FnOnce()`, so every caller wrote
1228/// `let _ = repo.run_git(...)` and threw the outcome away. Staging,
1229/// unstaging and discarding therefore finished in total silence — and
1230/// worse, a *failed* one did too: the buffer refreshed as though it had
1231/// worked, so the only symptom was a file that stayed where it was.
1232///
1233/// Making the closure return `Result<String, String>` moves that from a
1234/// discipline nobody kept to something the compiler asks for, and
1235/// [`finish_task`] then logs and publishes in one call. `label` names
1236/// the operation in the notification, so it is what the user reads —
1237/// "stage src/main.rs", not an argv.
1238/// The confirm `x` raises on a file entry.
1239///
1240/// **An untracked file is a different act behind the same key**, and
1241/// the prompt has to say so. "Discard changes to X?" presumes a
1242/// committed version to go back to; for an untracked file there is
1243/// none, so the only thing `x` can mean is *delete it*, and git keeps
1244/// no copy to recover it from. Answering that question wrongly costs
1245/// the file.
1246///
1247/// Pure and separate from the handler for the same reason
1248/// `picker_sources::branch_checkout_outcome` is: the choice is worth
1249/// testing directly, and the handler's context fixture is not part of
1250/// the decision.
1251/// Every distinct file the selected rows cover, each with whether git
1252/// tracks it — the discard peer of [`files_in_rows`], which needs only
1253/// paths because staging treats both kinds alike.
1254///
1255/// Discard does not: a tracked file is restored with `git checkout` and an
1256/// untracked one is *deleted*, and `checkout` fails outright on a path git
1257/// has no record of. So the flag has to travel with the path.
1258fn discardable_files_in_rows(
1259    s: &Arc<Mutex<StatusBufferState>>,
1260    rows: std::ops::RangeInclusive<u32>,
1261) -> Option<(Vec<(PathBuf, bool)>, PathBuf)> {
1262    let g = s.lock().ok()?;
1263    let mut out: Vec<(PathBuf, bool)> = Vec::new();
1264    for line in rows.filter_map(|line| classify_line(&g, line)) {
1265        if let StatusLine::File {
1266            path, untracked, ..
1267        } = line
1268            // Distinct, for `distinct_files`' reasons: a file entry and its
1269            // expanded inline diff are separate rows of one file, and the
1270            // same path can sit in the staged and unstaged sections at once.
1271            && !out.iter().any(|(p, _)| *p == path)
1272        {
1273            out.push((path, untracked));
1274        }
1275    }
1276    if out.is_empty() {
1277        return None;
1278    }
1279    Some((out, g.workdir.clone()))
1280}
1281
1282/// The question for a multi-file discard, and the list it carries.
1283///
1284/// **One question, even for a mixed selection.** The counts are named
1285/// separately because the two halves are not equally severe — a tracked
1286/// file comes back from the index, an untracked one does not come back at
1287/// all — and a prompt that said only "Discard 5 files?" would hide the
1288/// irreversible half behind the recoverable one.
1289///
1290/// Routes to a single batch execute rather than the two single-file halves.
1291/// Those stay as they are: one action, one act, for a selection of one.
1292fn batch_discard_confirm(files: &[(PathBuf, bool)]) -> Effect {
1293    let untracked = files.iter().filter(|(_, u)| *u).count();
1294    let tracked = files.len() - untracked;
1295    let prompt = match (tracked, untracked) {
1296        (0, n) => format!("Delete {n} untracked files? git has no copy to restore."),
1297        (n, 0) => format!("Discard changes to {n} files?"),
1298        (t, u) => format!(
1299            "Discard changes to {t} file(s) and DELETE {u} untracked file(s)? \
1300             The untracked ones cannot be restored."
1301        ),
1302    };
1303    // IX.2: carry the payload. Each entry is `<flag><path>` — one leading
1304    // byte for trackedness, so the flag travels with its path. That flag
1305    // decides `git checkout` versus `git clean`, and losing it would either
1306    // fail on an untracked path or DELETE a tracked one.
1307    //
1308    // **ONE slot holding every entry, not one slot per entry**, and that is
1309    // the fix for the bug this batch was written to solve reappearing one
1310    // seam later. `Effect::Confirm` seeds the dialog's transient state by
1311    // ZIPPING the yes-action's declared schema with the carried list
1312    // (`seed_transient_state`), so a list longer than the schema is silently
1313    // truncated. This action declares one slot, `files`, because
1314    // `TransientValue` is `Bool | String` and a transient slot cannot hold a
1315    // list at all — so emitting N values meant N-1 of them were dropped
1316    // between the ask and the act, and selecting three files discarded one.
1317    //
1318    // Joined on NUL: the only byte that cannot occur in a POSIX path, so the
1319    // split is exact for every path git can hand us — including the ones with
1320    // newlines and spaces that made a per-entry separator unusable in the
1321    // first place. A Rust `String` holds it fine; it is only paths that cannot.
1322    let args = lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
1323        files
1324            .iter()
1325            .map(|(path, untracked)| {
1326                format!(
1327                    "{}{}",
1328                    if *untracked { 'u' } else { 't' },
1329                    path.to_string_lossy()
1330                )
1331            })
1332            .collect::<Vec<_>>()
1333            .join(BATCH_SEPARATOR),
1334    )]);
1335    crate::confirm::ask_with(prompt, "action:magit-discard-batch-execute", args)
1336}
1337
1338/// Decode what [`batch_discard_confirm`] carried.
1339///
1340/// Takes the ARGS rather than the context so it is a pure function over the
1341/// payload — the encode/decode pair is the part worth testing, and a test
1342/// that had to stand up an `ActionContext` would be testing the harness.
1343/// Separates the entries packed into the batch discard's single carried slot.
1344///
1345/// NUL, because it is the one byte a POSIX path cannot contain — every other
1346/// candidate (newline, tab, any punctuation) is legal in a filename, and git
1347/// will hand us paths that use them.
1348const BATCH_SEPARATOR: &str = "\0";
1349
1350fn carried_batch(args: &lattice_grammar::Args) -> Vec<(PathBuf, bool)> {
1351    let mut out = Vec::new();
1352    let entries = match args.as_list() {
1353        Some(list) => list,
1354        None => return out,
1355    };
1356    // Flattens NUL-joined slots. Written as split-then-flatten rather than
1357    // "read slot 0 and split it" so the decode is agnostic to how many slots
1358    // the value arrived in — the ask half packs everything into one because
1359    // the confirm round trip truncates to the schema's arity, and a decoder
1360    // that hard-coded that packing would break silently the day the transient
1361    // state learns to hold a list.
1362    for entry in entries
1363        .iter()
1364        .filter_map(|value| match value {
1365            lattice_grammar::ArgValue::String(v) | lattice_grammar::ArgValue::Raw(v) => {
1366                Some(v.as_str())
1367            }
1368            _ => None,
1369        })
1370        .flat_map(|slot| slot.split(BATCH_SEPARATOR))
1371    {
1372        let mut chars = entry.chars();
1373        match chars.next() {
1374            Some('u') => out.push((PathBuf::from(chars.as_str()), true)),
1375            Some('t') => out.push((PathBuf::from(chars.as_str()), false)),
1376            // Neither flag: not ours. Dropped rather than guessed — a
1377            // mis-decoded entry here would delete a path nobody named.
1378            _ => {}
1379        }
1380    }
1381    out
1382}
1383
1384fn file_discard_confirm(path: &std::path::Path, untracked: bool) -> Effect {
1385    let target = path.to_string_lossy().into_owned();
1386    if untracked {
1387        return crate::confirm::ask_target(
1388            format!(
1389                "Delete untracked file {}? git has no copy to restore.",
1390                path.display()
1391            ),
1392            "action:magit-discard-untracked-execute",
1393            target,
1394        );
1395    }
1396    crate::confirm::ask_target(
1397        format!("Discard changes to {}?", path.display()),
1398        "action:magit-discard-execute",
1399        target,
1400    )
1401}
1402
1403/// Delete an untracked path, then refresh.
1404///
1405/// `git clean -f -d -- <path>` rather than `checkout` or `rm`: those
1406/// two both address paths git already knows, and this one by
1407/// definition is not. `-d` is what makes an untracked *directory* row
1408/// work — `git status` reports one as a single entry when it contains
1409/// nothing tracked, so the Untracked section shows a directory exactly
1410/// where it shows a file, and `clean` without `-d` would silently skip
1411/// it and report success.
1412///
1413/// The pathspec is `--`-separated for the usual reason: a path that
1414/// looks like an option or a ref must not be read as one.
1415fn spawn_untracked_delete(
1416    s: Arc<Mutex<StatusBufferState>>,
1417    workdir: std::path::PathBuf,
1418    path: PathBuf,
1419) -> Option<Effect> {
1420    let shown = path.display().to_string();
1421    spawn_mutation_and_refresh(s, format!("delete untracked {shown}"), move || {
1422        let repo =
1423            Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
1424        repo.run_git(["clean", "-f", "-d", "--", &path.to_string_lossy()])
1425            .map(|out| String::from_utf8_lossy(&out).into_owned())
1426            .map_err(|e| e.to_string())
1427    })
1428}
1429
1430fn spawn_mutation_and_refresh(
1431    s: Arc<Mutex<StatusBufferState>>,
1432    label: String,
1433    mutate: impl FnOnce() -> Result<String, String> + Send + 'static,
1434) -> Option<Effect> {
1435    let ctx = refresh_context(&s)?;
1436    tokio::task::spawn(async move {
1437        let result = tokio::task::spawn_blocking(mutate)
1438            .await
1439            .unwrap_or_else(|e| Err(e.to_string()));
1440        crate::magit_global_mode::finish_task(&ctx.wd, &label, result);
1441        // `finish_task` published `BackgroundTaskFinished`, and every
1442        // live magit-status buffer is subscribed to it — including
1443        // this one. Refreshing here as well would run `git status`
1444        // twice per action.
1445        //
1446        // The fallback is not defensive padding: a harness that never
1447        // installed an event bus has no subscriber, and without this
1448        // the mutation would land with nothing redrawing it.
1449        if crate::magit_global_mode::event_bus().is_none() {
1450            run_refresh(ctx).await;
1451        }
1452    });
1453    None
1454}
1455
1456/// Everything a refresh needs, read out of the state in one lock.
1457///
1458/// MG.18d: gathered into a struct because the list stopped fitting a
1459/// tuple once the refresh had to carry the open entries, the cursor
1460/// restore and the bus to answer on.
1461struct RefreshContext {
1462    handle: Arc<dyn lattice_runtime::Document>,
1463    wd: PathBuf,
1464    pending: Option<Arc<PendingSyntheticHighlights>>,
1465    bid: BufferId,
1466    headerline: Option<crate::headerline::MagitHeaderlineHandle>,
1467    /// Entry keys whose diffs must come back expanded.
1468    open: std::collections::HashSet<String>,
1469    restore: Option<crate::cursor_restore::HunkRestore>,
1470    cursor_bus: Option<crate::cursor_restore::CursorBusHandle>,
1471    /// MG.22b: `magit.hunk.context-lines`, snapshotted with the rest of
1472    /// the refresh inputs.
1473    context: i64,
1474    /// DS-fix (2026-08-12): the grammar registry the reopened
1475    /// expansions highlight through, snapshotted with the rest of the
1476    /// refresh inputs and resolved through the SAME
1477    /// `hunk_syntax::syntax_registry` gate the `=` toggle uses — so the
1478    /// `magit.hunk.syntax-highlight` option means one thing on both
1479    /// routes.
1480    lang_registry: Option<Arc<lattice_syntax::LangRegistry>>,
1481    state: Arc<Mutex<StatusBufferState>>,
1482}
1483
1484/// Snapshot the refresh inputs. Takes `pending_cursor` — a restore is
1485/// consumed by the refresh it was queued for, so a later `gr` does not
1486/// re-apply a stale jump.
1487/// `magit.hunk.context-lines`, or git's own default when there is no
1488/// config registry (a stripped harness).
1489pub(crate) fn context_lines(config: &Option<Arc<lattice_config::ConfigRegistry>>) -> i64 {
1490    config
1491        .as_ref()
1492        .and_then(|c| c.get_typed::<crate::options::MagitHunkContextLines>())
1493        .map(|v| *v)
1494        .unwrap_or(3)
1495}
1496
1497fn refresh_context(s: &Arc<Mutex<StatusBufferState>>) -> Option<RefreshContext> {
1498    let mut g = s.lock().ok()?;
1499    let handle = g.store.handle_for(g.buffer_id)?;
1500    let restore = g.pending_cursor.take();
1501    Some(RefreshContext {
1502        handle,
1503        wd: g.workdir.clone(),
1504        pending: g.pending_highlights.clone(),
1505        bid: g.buffer_id,
1506        headerline: g.headerline.clone(),
1507        // MG.18d: the keys survive the rebuild — `build_and_format`
1508        // re-runs their diffs and inlines them, rather than the buffer
1509        // coming back collapsed and the map being cleared to match.
1510        open: g.expanded.keys().cloned().collect(),
1511        restore,
1512        cursor_bus: g.cursor_bus.clone(),
1513        context: context_lines(&g.config),
1514        lang_registry: crate::hunk_syntax::syntax_registry(
1515            g.lang_registry.clone(),
1516            g.config.as_ref(),
1517        ),
1518        state: Arc::clone(s),
1519    })
1520}
1521
1522async fn run_refresh(ctx: RefreshContext) {
1523    do_refresh(
1524        ctx.handle,
1525        ctx.wd,
1526        ctx.pending,
1527        ctx.bid,
1528        ctx.headerline,
1529        ctx.open,
1530        ctx.restore,
1531        ctx.cursor_bus,
1532        ctx.context,
1533        ctx.lang_registry,
1534        ctx.state,
1535    )
1536    .await;
1537}
1538
1539/// Refresh the status buffer: blocking `git status`/`stash
1540/// list`/`log` on `spawn_blocking`, then apply the formatted text +
1541/// highlights on the current task.
1542///
1543/// MG.13: lifted to module scope (was nested in
1544/// `register_action_handlers`) so [`trigger_refresh`] can reach it
1545/// from the boot-registered `gr` path.
1546///
1547/// MG.18d: `open` names the entries whose diffs must come back
1548/// expanded, and the rebuilt text carries them — a refresh no longer
1549/// throws away what you had open. `restore` (set only by a mutation)
1550/// then resolves the cursor against that same text, so the entry and
1551/// the position agree by construction rather than by two lookups
1552/// against a buffer in motion.
1553#[allow(clippy::too_many_arguments)]
1554async fn do_refresh(
1555    handle: Arc<dyn lattice_runtime::Document>,
1556    wd: PathBuf,
1557    pending: Option<Arc<PendingSyntheticHighlights>>,
1558    bid: BufferId,
1559    headerline: Option<crate::headerline::MagitHeaderlineHandle>,
1560    open: std::collections::HashSet<String>,
1561    restore: Option<crate::cursor_restore::HunkRestore>,
1562    cursor_bus: Option<crate::cursor_restore::CursorBusHandle>,
1563    context: i64,
1564    lang_registry: Option<Arc<lattice_syntax::LangRegistry>>,
1565    state: Arc<Mutex<StatusBufferState>>,
1566) {
1567    // MG.27: the row says "refreshing" for the whole of this function,
1568    // cleared by the guard's drop — including if the `spawn_blocking`
1569    // below panics or the task is cancelled when the buffer closes.
1570    let _busy = crate::headerline::busy(&headerline);
1571    let (text, spans, header, reopened, refine) = tokio::task::spawn_blocking(move || {
1572        refresh::build_and_format(&wd, &open, context, lang_registry.as_ref())
1573    })
1574    .await
1575    .expect("spawn_blocking");
1576    // MG.14: publish before the edit — the header describes the state
1577    // the body is about to show, and `set` is a comparison plus (at
1578    // most) one atomic, nowhere near the edit's cost.
1579    crate::headerline::publish(&headerline, header);
1580    // The expansion bookkeeping describes the text about to be written,
1581    // so it is replaced (not merged): an entry that vanished from the
1582    // status output has no rows to collapse later.
1583    if let Ok(mut g) = state.lock() {
1584        g.expanded = reopened;
1585    }
1586    // Resolved against the text rather than the buffer — the buffer is
1587    // about to become this text, and reading it back would race the
1588    // very edit being applied.
1589    let position = restore.and_then(|r| crate::cursor_restore::restore_position(&text, &r));
1590    refresh::apply_and_highlight_refined(handle, text, spans, refine, pending, bid).await;
1591    // Sent AFTER the replace lands: a cursor delivered first would be
1592    // clamped against the outgoing content. The send wakes the editor,
1593    // so the cursor arrives without the user touching a key
1594    // (`boot-composition.md` §3).
1595    if let Some(position) = position {
1596        crate::cursor_restore::send_cursor(&cursor_bus, bid, position);
1597    }
1598}
1599
1600/// `gr` — bare refresh of the status buffer, no prior mutation.
1601///
1602/// MG.13: a free function (not a closure inside
1603/// `register_action_handlers`) because the `gr` handler is now
1604/// registered once at boot by `magit-core-mode` and reaches this
1605/// through [`StatusView`]; see `buffer_state::MagitView`.
1606pub fn trigger_refresh(s: Arc<Mutex<StatusBufferState>>) -> Option<Effect> {
1607    let ctx = refresh_context(&s)?;
1608    tokio::task::spawn(run_refresh(ctx));
1609    None::<Effect>
1610}
1611
1612/// The status buffer's `MagitView` — supplies `gr`'s body for buffers
1613/// `magit-status-mode` owns.
1614pub struct StatusView(pub Arc<Mutex<StatusBufferState>>);
1615
1616impl crate::buffer_state::MagitView for StatusView {
1617    /// MG.22: magit-status's `<CR>` — the reason `visit_at_cursor`
1618    /// exists on the trait at all.
1619    ///
1620    /// `magit-hunk-mode` owns the chord now, but here it must keep
1621    /// resolving rows that are not diff content: a staged file opens
1622    /// its index blob, an unstaged one the live file, a stash toggles
1623    /// its inline patch, a commit opens its buffer. Returning `None`
1624    /// for anything `classify_line` does not recognise is what lets
1625    /// the caller fall through to diff-path resolution — and what
1626    /// keeps it from resolving a row against a *previous* entry's
1627    /// expanded diff.
1628    fn visit_at_cursor(&self, cursor: lattice_protocol::position::Position) -> Option<Effect> {
1629        visit_status_line(&self.0, cursor.line)
1630    }
1631
1632    /// MG.20: the commit on the Recent-commits row under the cursor.
1633    /// File and stash rows correctly yield `None`, so `V` on a staged
1634    /// file does nothing rather than reverting an unrelated commit.
1635    fn commit_at_cursor(&self, cursor: lattice_protocol::position::Position) -> Option<String> {
1636        let g = self.0.lock().ok()?;
1637        let handle = g.store.handle_for(g.buffer_id)?;
1638        let snap = handle.snapshot();
1639        let line = snap.buffer.line(cursor.line)?;
1640        // A Recent-commits row is `"  <sha> <subject>"`; every other
1641        // row kind (file entries carry a status label, stashes carry
1642        // `stash@{`) fails the hex test.
1643        let tok = line.split_whitespace().next()?;
1644        (tok.len() >= 4 && tok.chars().all(|c| c.is_ascii_hexdigit())).then(|| tok.to_string())
1645    }
1646
1647    /// The stash on the Stashes row under the cursor.
1648    ///
1649    /// magit-status renders these with the SAME `"  stash@{N} msg"`
1650    /// row `magit-stash-mode`'s list uses (`sections.rs` and
1651    /// `magit_stash_mode::list_row`), so both views share one parser
1652    /// rather than growing a second idea of the format. File and
1653    /// commit rows fail the `stash@{` prefix and correctly yield
1654    /// `None`, so `p` on a staged file pops nothing.
1655    fn stash_at_cursor(&self, cursor: lattice_protocol::position::Position) -> Option<usize> {
1656        let g = self.0.lock().ok()?;
1657        let handle = g.store.handle_for(g.buffer_id)?;
1658        let snap = handle.snapshot();
1659        let line = snap.buffer.line(cursor.line)?;
1660        crate::magit_stash_mode::parse_index(&line)
1661    }
1662
1663    fn workdir(&self) -> Option<std::path::PathBuf> {
1664        Some(self.0.lock().ok()?.workdir.clone())
1665    }
1666
1667    /// MG.18c: the section an inline diff was expanded under says
1668    /// which tree it was diffed against — `run_show` passes
1669    /// `--cached` for a Staged entry and nothing for an Unstaged one,
1670    /// so the header the diff sits below is the same fact, already on
1671    /// screen.
1672    ///
1673    /// Stashes and commits expand patches too, and those belong to
1674    /// neither the index nor the worktree; `None` refuses hunk staging
1675    /// there rather than applying a commit's diff to the index.
1676    /// MG.50: `<CR>` inside an inline diff.
1677    ///
1678    /// This was the one view with no answer — `<CR>` in a magit-status
1679    /// hunk fell to the trait default and did nothing at all, while the
1680    /// same key in magit-diff or a revision opened the file.
1681    ///
1682    /// Which version to open is the SECTION's question, not the
1683    /// buffer's: a status buffer holds staged and unstaged diffs at
1684    /// once, and they describe different content. `diff_source` already
1685    /// answers it from the header above the cursor — the same seam
1686    /// `s` / `u` / `x` use to decide which tree a hunk applies to, so
1687    /// the version `<CR>` shows and the tree a hunk stages to can never
1688    /// disagree.
1689    fn diff_target(&self, path: &std::path::Path, cursor: Position) -> Option<Effect> {
1690        // MR.3b: the repository this buffer is showing, from its own
1691        // name — the blob buffer must open the file in THAT checkout.
1692        let label = {
1693            let g = self.0.lock().ok()?;
1694            crate::repo_scope::label_of_buffer(&g.store, g.buffer_id)
1695        };
1696        match self.diff_source(cursor)? {
1697            // Staged: the index blob, which is what the diff describes.
1698            // The working-tree file may have moved on since.
1699            DiffSource::Staged => Some(Effect::OpenSyntheticBuffer {
1700                name: crate::magit_file_revision_mode::blob_buffer_name(&label, "staged", path),
1701                mode_id: "magit-file-revision-mode".to_string(),
1702                content: None,
1703                cursor: None,
1704                activate_minor: None,
1705            }),
1706            // Unstaged (and untracked): the diff IS against the working
1707            // tree, so that file is the thing being described.
1708            DiffSource::Unstaged => {
1709                let full = self.0.lock().ok()?.workdir.join(path);
1710                full.exists().then_some(Effect::OpenBuffer {
1711                    path: Some(full),
1712                    force: false,
1713                })
1714            }
1715            // A commit's patch, expanded inline under its Recent-commits
1716            // row. THAT row names the revision, and it is the only place
1717            // the sha exists — the patch text below it does not repeat
1718            // it. So walk up to the entry this content was expanded
1719            // under, exactly as the fold source does to find an
1720            // expansion's extent.
1721            //
1722            // A stash's patch resolves to no sha and declines rather
1723            // than guessing a revision.
1724            DiffSource::Committed => {
1725                let g = self.0.lock().ok()?;
1726                let sha = (0..=cursor.line).rev().find_map(|l| {
1727                    match classify_line(&g, l) {
1728                        Some(StatusLine::Commit { sha }) => Some(sha),
1729                        // Any other classified entry means the walk left
1730                        // this patch without finding a commit.
1731                        Some(_) => None,
1732                        None => None,
1733                    }
1734                })?;
1735                Some(Effect::OpenSyntheticBuffer {
1736                    name: crate::magit_file_revision_mode::blob_buffer_name(&label, &sha, path),
1737                    mode_id: "magit-file-revision-mode".to_string(),
1738                    content: None,
1739                    cursor: None,
1740                    activate_minor: None,
1741                })
1742            }
1743        }
1744    }
1745
1746    fn diff_source(&self, cursor: Position) -> Option<DiffSource> {
1747        let g = self.0.lock().ok()?;
1748        diff_source_for_header(&section_header_above(&g, cursor.line)?)
1749    }
1750
1751    fn refresh(&self) -> Option<Effect> {
1752        trigger_refresh(self.0.clone())
1753    }
1754
1755    /// MG.18d: queue the restore, then refresh. The refresh consumes it
1756    /// once it holds the rebuilt text — see [`refresh_context`].
1757    fn refresh_restoring(&self, site: crate::cursor_restore::HunkSite) -> Option<Effect> {
1758        if let Ok(mut g) = self.0.lock() {
1759            // A status buffer's landmark is the entry row; its section
1760            // is what `staged` selects between, since one path can be
1761            // listed under both.
1762            g.pending_cursor = Some(site.as_status_entry());
1763        }
1764        trigger_refresh(self.0.clone())
1765    }
1766
1767    /// `s` — stage the file on the status entry line at `cursor`.
1768    fn stage(&self, cursor: Position) -> Option<Effect> {
1769        let s = self.0.clone();
1770        let (path, workdir) = {
1771            let g = s.lock().ok()?;
1772            let StatusLine::File { path, .. } = classify_line(&g, cursor.line)? else {
1773                return None;
1774            };
1775            (path, g.workdir.clone())
1776        };
1777        spawn_mutation_and_refresh(s, format!("stage {}", path.display()), move || {
1778            let repo =
1779                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
1780            Index::stage_path(&repo, &path)
1781                .map(|()| String::new())
1782                .map_err(|e| e.to_string())
1783        })
1784    }
1785
1786    /// Every distinct file the selected rows cover, staged in ONE task
1787    /// with ONE refresh.
1788    ///
1789    /// Distinct because a file entry and its expanded inline diff are
1790    /// separate rows of the same file — a selection over both must not
1791    /// stage it twice — and because the same path can appear in both
1792    /// the staged and unstaged sections.
1793    fn stage_rows(&self, rows: std::ops::RangeInclusive<u32>) -> Option<Effect> {
1794        let s = self.0.clone();
1795        let (paths, workdir) = files_in_rows(&s, rows)?;
1796        spawn_mutation_and_refresh(s, format!("stage {} files", paths.len()), move || {
1797            let repo =
1798                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
1799            // ONE `git add` with every path, not one per file. N commands
1800            // meant N process spawns and N `.git/index.lock` cycles — and
1801            // a partial batch, which is why this used to report "3 of 5
1802            // staged". One command is atomic: it stages all of them or
1803            // none, and there is no half-outcome left to describe.
1804            Index::stage_paths(&repo, paths.iter())
1805                .map(|()| String::new())
1806                .map_err(|e| e.to_string())
1807        })
1808        // `Some` because the work was HANDLED, even though there is no
1809        // synchronous effect to return.
1810        //
1811        // `spawn_mutation_and_refresh` always returns `None` — it spawns
1812        // and has nothing to hand back — and the caller reads `None` as
1813        // "this did not apply, try the fallback":
1814        //
1815        //     rows.and_then(|r| view.stage_rows(r))
1816        //         .or_else(|| view.stage(ctx.cursor))
1817        //
1818        // so a visual-mode stage span the batch AND a second `git add`
1819        // for the cursor's file, concurrently. They raced on
1820        // `.git/index.lock` and the single one lost, which is how a
1821        // selection that staged correctly still reported
1822        // "stage <file> failed: Unable to create index.lock"
1823        // (2026-08-16).
1824        //
1825        // The `?` on `files_in_rows` above keeps `None` meaning the one
1826        // thing the fallback should react to: the selection covers no
1827        // files.
1828        .or(Some(Effect::None))
1829    }
1830
1831    fn unstage_rows(&self, rows: std::ops::RangeInclusive<u32>) -> Option<Effect> {
1832        let s = self.0.clone();
1833        let (paths, workdir) = files_in_rows(&s, rows)?;
1834        spawn_mutation_and_refresh(s, format!("unstage {} files", paths.len()), move || {
1835            let repo =
1836                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
1837            // One `git reset` with every path — see `stage_rows`.
1838            Index::unstage_paths(&repo, paths.iter())
1839                .map(|()| String::new())
1840                .map_err(|e| e.to_string())
1841        })
1842        // Handled — see `stage_rows` for why this is `Some`.
1843        .or(Some(Effect::None))
1844    }
1845
1846    fn unstage(&self, cursor: Position) -> Option<Effect> {
1847        let s = self.0.clone();
1848        let (path, original_path, workdir) = {
1849            let g = s.lock().ok()?;
1850            let StatusLine::File {
1851                path,
1852                original_path,
1853                ..
1854            } = classify_line(&g, cursor.line)?
1855            else {
1856                return None;
1857            };
1858            (path, original_path, g.workdir.clone())
1859        };
1860        spawn_mutation_and_refresh(s, format!("unstage {}", path.display()), move || {
1861            let repo =
1862                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
1863            // A rename is two index entries; both have to be reset or
1864            // the old path stays staged-deleted. See `unstage_paths`.
1865            let mut targets = vec![path.clone()];
1866            targets.extend(original_path.clone());
1867            Index::unstage_paths(&repo, &targets)
1868                .map(|()| String::new())
1869                .map_err(|e| e.to_string())
1870        })
1871    }
1872}
1873
1874#[cfg(test)]
1875mod diff_toggle_tests {
1876    use super::DiffToggle;
1877
1878    /// **An entry that has rows folds; it never re-fetches.**
1879    ///
1880    /// This is the regression the slice exists to prevent. The old
1881    /// behaviour spliced the diff out of the buffer, so pressing `=`
1882    /// twice meant two `git diff` runs and threw away text the buffer
1883    /// already had. Any future change that maps a present expansion
1884    /// back to `Fetch` reintroduces exactly that.
1885    #[test]
1886    fn an_expanded_entry_folds_rather_than_refetching() {
1887        assert_eq!(DiffToggle::for_state(Some(12)), DiffToggle::Fold);
1888        assert_eq!(DiffToggle::for_state(Some(1)), DiffToggle::Fold);
1889    }
1890
1891    /// Nothing fetched yet is the only state that runs git.
1892    #[test]
1893    fn only_an_unfetched_entry_runs_git() {
1894        assert_eq!(DiffToggle::for_state(None), DiffToggle::Fetch);
1895    }
1896
1897    /// A zero-row expansion has nothing to hide, so folding it would
1898    /// be a no-op the user reads as a dead key. It is forgotten
1899    /// instead, which lets the next press fetch again.
1900    #[test]
1901    fn a_zero_row_expansion_is_dropped_not_folded() {
1902        assert_eq!(DiffToggle::for_state(Some(0)), DiffToggle::Drop);
1903    }
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908    use super::*;
1909
1910    fn header(s: &str) -> impl FnOnce() -> Option<String> + '_ {
1911        move || Some(s.to_string())
1912    }
1913
1914    fn file(path: &str, staged: bool) -> Option<StatusLine> {
1915        Some(StatusLine::File {
1916            path: PathBuf::from(path),
1917            staged,
1918            untracked: false,
1919            original_path: None,
1920        })
1921    }
1922
1923    /// A Visual selection over several entries stages all of them, in
1924    /// the order the buffer shows.
1925    #[test]
1926    fn a_selection_collects_every_file_it_covers_in_buffer_order() {
1927        let rows = [
1928            file("src/a.rs", false),
1929            file("src/b.rs", false),
1930            file("src/c.rs", false),
1931        ];
1932        assert_eq!(
1933            distinct_files(rows.into_iter()),
1934            vec![
1935                PathBuf::from("src/a.rs"),
1936                PathBuf::from("src/b.rs"),
1937                PathBuf::from("src/c.rs")
1938            ]
1939        );
1940    }
1941
1942    /// A file entry and its expanded inline diff are separate rows of
1943    /// the SAME file. A selection over both must stage it once — twice
1944    /// is not harmless when the second call runs against a tree the
1945    /// first already changed.
1946    #[test]
1947    fn an_expanded_entry_is_not_staged_twice() {
1948        let rows = [
1949            file("src/a.rs", false),
1950            file("src/a.rs", false),
1951            file("src/b.rs", false),
1952        ];
1953        assert_eq!(
1954            distinct_files(rows.into_iter()),
1955            vec![PathBuf::from("src/a.rs"), PathBuf::from("src/b.rs")]
1956        );
1957    }
1958
1959    /// The same path can sit in the staged AND unstaged sections at
1960    /// once — a partially-staged file. A selection spanning both is
1961    /// still one path.
1962    #[test]
1963    fn a_partially_staged_file_appearing_twice_is_still_one_path() {
1964        let rows = [file("src/a.rs", true), file("src/a.rs", false)];
1965        assert_eq!(
1966            distinct_files(rows.into_iter()),
1967            vec![PathBuf::from("src/a.rs")]
1968        );
1969    }
1970
1971    /// Rows that are not files — section headers, commit rows, blanks —
1972    /// contribute nothing, so a selection over them declines and the
1973    /// caller falls back to the cursor's own entry.
1974    #[test]
1975    fn non_file_rows_contribute_nothing() {
1976        let rows = [
1977            None,
1978            Some(StatusLine::Commit {
1979                sha: "a1b2c3d".into(),
1980            }),
1981            Some(StatusLine::Stash { index: 0 }),
1982        ];
1983        assert!(distinct_files(rows.into_iter()).is_empty());
1984    }
1985
1986    /// MG.18c — the header a hunk sits under decides which tree its
1987    /// patch applies to. Getting this backwards would send an
1988    /// unstaged hunk through `u` (git refuses, harmless) or a staged
1989    /// one through `x` (reverses it out of the worktree while leaving
1990    /// it staged — the half-state the gate exists to prevent).
1991    #[test]
1992    fn section_headers_map_to_the_tree_their_diffs_came_from() {
1993        assert_eq!(
1994            diff_source_for_header("Staged changes (2)"),
1995            Some(DiffSource::Staged)
1996        );
1997        assert_eq!(
1998            diff_source_for_header("Unstaged changes (3)"),
1999            Some(DiffSource::Unstaged)
2000        );
2001        assert_eq!(
2002            diff_source_for_header("Untracked files (1)"),
2003            Some(DiffSource::Unstaged),
2004            "`s` on an untracked file is `git add` — the worktree side"
2005        );
2006    }
2007
2008    /// A commit's or stash's inline patch belongs to neither the index
2009    /// nor the worktree. `None` refuses hunk staging there rather than
2010    /// applying a commit's diff to the index.
2011    #[test]
2012    fn commit_and_stash_sections_have_no_stageable_source() {
2013        assert_eq!(diff_source_for_header("Recent commits"), None);
2014        assert_eq!(diff_source_for_header("Stashes (2)"), None);
2015    }
2016
2017    fn no_header() -> impl FnOnce() -> Option<String> {
2018        || None
2019    }
2020
2021    // ── audit fix: collapse deleted the following entry's text ──
2022    // ── audit fix: the "new file" (two-word) label bug ──────────
2023
2024    #[test]
2025    fn staged_new_file_entry_classifies_with_full_path() {
2026        // Root cause of the u / =-on-staged bugs: the old
2027        // `parse_file_path` split on the first space and got the
2028        // "file" half of the "new file" label instead of the path.
2029        let line = format!("  {:<12} {}", "new file", "src/lib.rs");
2030        let sl = classify_line_text(&line, header("Staged changes (1)"));
2031        assert_eq!(
2032            sl,
2033            Some(StatusLine::File {
2034                path: PathBuf::from("src/lib.rs"),
2035                staged: true,
2036                untracked: false,
2037                original_path: None,
2038            })
2039        );
2040    }
2041
2042    #[test]
2043    fn unstaged_modified_entry_classifies_as_not_staged() {
2044        let line = format!("  {:<12} {}", "modified", "src/main.rs");
2045        let sl = classify_line_text(&line, header("Unstaged changes (1)"));
2046        assert_eq!(
2047            sl,
2048            Some(StatusLine::File {
2049                path: PathBuf::from("src/main.rs"),
2050                staged: false,
2051                untracked: false,
2052                original_path: None,
2053            })
2054        );
2055    }
2056
2057    /// `x` on an untracked entry must ask a different question and
2058    /// route to a different command.
2059    ///
2060    /// The bug: both paths ran `git checkout -- <path>`, which fails on
2061    /// a path git has no record of — "pathspec 'test' did not match any
2062    /// file(s) known to git" — so `x` on an untracked file reported a
2063    /// git error and deleted nothing. The prompt was wrong too: it
2064    /// offered to discard *changes* to a file that has no committed
2065    /// version, when the only available act is deleting it outright.
2066    #[test]
2067    fn discarding_an_untracked_file_asks_to_delete_it_not_to_revert_it() {
2068        let tracked = file_discard_confirm(&PathBuf::from("src/main.rs"), false);
2069        let untracked = file_discard_confirm(&PathBuf::from("test"), true);
2070
2071        let (t_prompt, t_yes) = match tracked {
2072            Effect::Confirm {
2073                prompt, yes_action, ..
2074            } => (prompt, yes_action),
2075            other => panic!("expected Confirm, got {other:?}"),
2076        };
2077        let (u_prompt, u_yes) = match untracked {
2078            Effect::Confirm {
2079                prompt, yes_action, ..
2080            } => (prompt, yes_action),
2081            other => panic!("expected Confirm, got {other:?}"),
2082        };
2083
2084        assert_eq!(t_prompt, "Discard changes to src/main.rs?");
2085        assert_eq!(t_yes, "action:magit-discard-execute");
2086
2087        assert!(
2088            u_prompt.starts_with("Delete untracked file test?"),
2089            "the prompt must name deletion — there are no changes to \
2090             discard on a file git has never seen: {u_prompt:?}"
2091        );
2092        assert!(
2093            u_prompt.contains("no copy"),
2094            "and must say the deletion is unrecoverable: {u_prompt:?}"
2095        );
2096        assert_ne!(
2097            u_yes, t_yes,
2098            "the untracked path must not reach `git checkout --`, which \
2099             fails outright on a path git has no record of"
2100        );
2101        assert_eq!(u_yes, "action:magit-discard-untracked-execute");
2102    }
2103
2104    #[test]
2105    fn untracked_file_entry_classifies_as_untracked_and_not_staged() {
2106        let line = format!("  {:<12} {}", "untracked", "notes.txt");
2107        let sl = classify_line_text(&line, header("Untracked files (1)"));
2108        assert_eq!(
2109            sl,
2110            Some(StatusLine::File {
2111                path: PathBuf::from("notes.txt"),
2112                staged: false,
2113                untracked: true,
2114                original_path: None,
2115            })
2116        );
2117    }
2118
2119    #[test]
2120    fn deleted_entry_classifies_correctly() {
2121        let line = format!("  {:<12} {}", "deleted", "old.rs");
2122        let sl = classify_line_text(&line, header("Unstaged changes (1)"));
2123        assert_eq!(
2124            sl,
2125            Some(StatusLine::File {
2126                path: PathBuf::from("old.rs"),
2127                staged: false,
2128                untracked: false,
2129                original_path: None,
2130            })
2131        );
2132    }
2133
2134    /// Every label a status row can carry must round-trip back to its
2135    /// path — including the multi-word unmerged ones.
2136    ///
2137    /// The trap this pins: labels are matched as PREFIXES, and
2138    /// `"deleted"` is a prefix of `"deleted by us"`. In declaration
2139    /// order, a `deleted by us` row matched `"deleted"`, left
2140    /// `" by us   path"` (whitespace-led, so the guard passed) and
2141    /// parsed the path as `"by us   path"` — a file that does not
2142    /// exist, so staging or visiting it would silently miss. Matching
2143    /// longest-first is what makes this correct, which is why it is
2144    /// asserted over the whole label set rather than one example.
2145    #[test]
2146    fn every_label_round_trips_to_its_path() {
2147        for label in FILE_LABELS {
2148            let line = format!(
2149                "  {label:<width$} {path}",
2150                width = crate::sections::LABEL_WIDTH,
2151                path = "src/deep/path.rs"
2152            );
2153            let sl = classify_line_text(&line, header("Unstaged changes (1)"));
2154            assert_eq!(
2155                sl,
2156                Some(StatusLine::File {
2157                    path: PathBuf::from("src/deep/path.rs"),
2158                    staged: false,
2159                    untracked: label == "untracked",
2160                    original_path: None,
2161                }),
2162                "label {label:?} must yield the path, not a fragment of its own text"
2163            );
2164        }
2165    }
2166
2167    /// A rename row renders `old -> new`, and classification must
2168    /// return the NEW path (what every action targets) while keeping
2169    /// the origin — unstaging needs both, or the old path stays
2170    /// staged-deleted.
2171    #[test]
2172    fn a_rename_row_yields_the_new_path_and_keeps_its_origin() {
2173        let line = format!(
2174            "  {label:<width$} {path}",
2175            label = "renamed",
2176            width = crate::sections::LABEL_WIDTH,
2177            path = "docs/old name.md -> docs/new name.md"
2178        );
2179        let sl = classify_line_text(&line, header("Staged changes (1)"));
2180        assert_eq!(
2181            sl,
2182            Some(StatusLine::File {
2183                path: PathBuf::from("docs/new name.md"),
2184                staged: true,
2185                untracked: false,
2186                original_path: Some(PathBuf::from("docs/old name.md")),
2187            })
2188        );
2189    }
2190
2191    /// ` -> ` is legal in a filename, so the split is from the RIGHT:
2192    /// the new path is whatever follows the LAST separator.
2193    #[test]
2194    fn a_rename_splits_from_the_right() {
2195        let line = format!(
2196            "  {label:<width$} {path}",
2197            label = "renamed",
2198            width = crate::sections::LABEL_WIDTH,
2199            path = "a -> b.txt -> c.txt"
2200        );
2201        match classify_line_text(&line, header("Staged changes (1)")) {
2202            Some(StatusLine::File {
2203                path,
2204                original_path,
2205                ..
2206            }) => {
2207                assert_eq!(path, PathBuf::from("c.txt"));
2208                assert_eq!(original_path, Some(PathBuf::from("a -> b.txt")));
2209            }
2210            other => panic!("expected a File row, got {other:?}"),
2211        }
2212    }
2213
2214    /// Only rename / copy rows carry the arrow form — a MODIFIED file
2215    /// whose name happens to contain ` -> ` keeps its whole name.
2216    #[test]
2217    fn only_rename_rows_split_on_the_arrow() {
2218        let line = format!(
2219            "  {label:<width$} {path}",
2220            label = "modified",
2221            width = crate::sections::LABEL_WIDTH,
2222            path = "weird -> name.txt"
2223        );
2224        match classify_line_text(&line, header("Unstaged changes (1)")) {
2225            Some(StatusLine::File {
2226                path,
2227                original_path,
2228                ..
2229            }) => {
2230                assert_eq!(path, PathBuf::from("weird -> name.txt"));
2231                assert_eq!(original_path, None);
2232            }
2233            other => panic!("expected a File row, got {other:?}"),
2234        }
2235    }
2236
2237    /// A path containing a label word must not be mistaken for one.
2238    #[test]
2239    fn a_path_that_looks_like_a_label_is_still_a_path() {
2240        let line = format!(
2241            "  {label:<width$} {path}",
2242            label = "modified",
2243            width = crate::sections::LABEL_WIDTH,
2244            path = "deleted by us.txt"
2245        );
2246        let sl = classify_line_text(&line, header("Unstaged changes (1)"));
2247        assert_eq!(
2248            sl,
2249            Some(StatusLine::File {
2250                path: PathBuf::from("deleted by us.txt"),
2251                staged: false,
2252                untracked: false,
2253                original_path: None,
2254            })
2255        );
2256    }
2257
2258    // ── stash / commit entries — <CR> previously no-op'd on both ──
2259
2260    #[test]
2261    fn stash_entry_classifies_by_index() {
2262        let sl = classify_line_text("  stash@{2} WIP on main: 1234abc msg", no_header());
2263        assert_eq!(sl, Some(StatusLine::Stash { index: 2 }));
2264    }
2265
2266    #[test]
2267    fn commit_entry_classifies_sha_under_recent_commits_header() {
2268        let sl = classify_line_text("  a1b2c3d Fix the thing", header("Recent commits (20)"));
2269        assert_eq!(
2270            sl,
2271            Some(StatusLine::Commit {
2272                sha: "a1b2c3d".to_string(),
2273            })
2274        );
2275    }
2276
2277    /// The unmerged section renders identical commit rows, so `<CR>`
2278    /// must work there too. Without this the same row would be live
2279    /// under one heading and inert under the other — a difference the
2280    /// user cannot see and would read as a bug.
2281    #[test]
2282    fn commit_entry_classifies_sha_under_the_unmerged_header() {
2283        let sl = classify_line_text(
2284            "  a1b2c3d Fix the thing",
2285            header("Unmerged into origin/main (3)"),
2286        );
2287        assert_eq!(
2288            sl,
2289            Some(StatusLine::Commit {
2290                sha: "a1b2c3d".to_string(),
2291            })
2292        );
2293    }
2294
2295    /// The unmerged section shows commits, which have no file diff to
2296    /// expand — same as recent commits and stashes.
2297    #[test]
2298    fn the_unmerged_header_has_no_diff_source() {
2299        assert_eq!(
2300            diff_source_for_header("Unmerged into origin/main (3)"),
2301            None
2302        );
2303    }
2304
2305    #[test]
2306    fn commit_like_line_outside_recent_commits_header_is_not_a_commit() {
2307        // Guards against misclassifying arbitrary indented text as a
2308        // commit entry when it isn't actually under that section.
2309        let sl = classify_line_text("  a1b2c3d Fix the thing", header("Stashes (1)"));
2310        assert_eq!(sl, None);
2311    }
2312
2313    // ── non-entry lines ──────────────────────────────────────────
2314
2315    #[test]
2316    fn section_header_line_is_not_an_entry() {
2317        assert_eq!(classify_line_text("Staged changes (2)", no_header()), None);
2318    }
2319
2320    #[test]
2321    fn blank_line_is_not_an_entry() {
2322        assert_eq!(classify_line_text("", no_header()), None);
2323    }
2324
2325    #[test]
2326    fn no_changes_message_is_not_an_entry() {
2327        assert_eq!(
2328            classify_line_text("No changes (working tree clean)", no_header()),
2329            None
2330        );
2331    }
2332
2333    // ── entry_key: Conflicted-file staged/unstaged collision fix ──
2334
2335    #[test]
2336    fn entry_key_distinguishes_staged_and_unstaged_rows_for_the_same_path() {
2337        // A Conflicted file appears in BOTH sections at once
2338        // (refresh::build_section_index); the two rows must map to
2339        // distinct expansion-tracking keys or expanding one would
2340        // make `toggle_expand` treat the other as already-expanded.
2341        let staged = StatusLine::File {
2342            path: PathBuf::from("conflict.rs"),
2343            staged: true,
2344            untracked: false,
2345            original_path: None,
2346        };
2347        let unstaged = StatusLine::File {
2348            path: PathBuf::from("conflict.rs"),
2349            staged: false,
2350            untracked: false,
2351            original_path: None,
2352        };
2353        assert_ne!(entry_key(&staged), entry_key(&unstaged));
2354    }
2355
2356    #[test]
2357    fn entry_key_stable_for_same_status_line() {
2358        let a = StatusLine::Stash { index: 3 };
2359        let b = StatusLine::Stash { index: 3 };
2360        assert_eq!(entry_key(&a), entry_key(&b));
2361    }
2362}
2363
2364/// MG.31: the inline `=` expansion's git call belongs on the blocking
2365/// pool, not the actor thread.
2366#[cfg(test)]
2367mod expand_payload_tests {
2368    use super::*;
2369    use lattice_cells::style::Style;
2370    use std::process::Command;
2371    use std::time::{Duration, Instant};
2372
2373    fn git_ok(dir: &Path, args: &[&str]) {
2374        let st = Command::new("git")
2375            .args(args)
2376            .current_dir(dir)
2377            .status()
2378            .expect("git");
2379        assert!(st.success(), "git {args:?} failed");
2380    }
2381
2382    /// A repo with one tracked file whose working tree differs from
2383    /// HEAD in `changed` lines. `changed` drives how long `git diff`
2384    /// takes, which is what the responsiveness probe below needs.
2385    fn repo_with_modified_file(lines: usize) -> tempfile::TempDir {
2386        let dir = tempfile::tempdir().expect("tempdir");
2387        let p = dir.path();
2388        git_ok(p, &["init"]);
2389        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
2390        git_ok(p, &["config", "user.name", "lattice-test"]);
2391        let base: String = (1..=lines).map(|i| format!("line {i}\n")).collect();
2392        std::fs::write(p.join("a.txt"), &base).expect("write base");
2393        git_ok(p, &["add", "a.txt"]);
2394        git_ok(p, &["commit", "-m", "base"]);
2395        // Every line differs, so the diff is proportional to `lines`.
2396        let modified: String = (1..=lines).map(|i| format!("line {i} CHANGED\n")).collect();
2397        std::fs::write(p.join("a.txt"), &modified).expect("write modified");
2398        dir
2399    }
2400
2401    fn unstaged(path: &str) -> StatusLine {
2402        StatusLine::File {
2403            path: PathBuf::from(path),
2404            staged: false,
2405            untracked: false,
2406            original_path: None,
2407        }
2408    }
2409
2410    /// DS.3 end-to-end: with a grammar registry, the code inside an
2411    /// inline-expanded diff carries syntax spans UNDER the diff layer.
2412    ///
2413    /// Goes through `expand_payload` — the real path `=` takes — rather
2414    /// than calling the span builder directly, because the thing worth
2415    /// pinning is that the registry actually reaches it.
2416    #[test]
2417    fn an_expanded_diff_carries_syntax_under_the_diff_layer() {
2418        use lattice_cells::style::Style;
2419
2420        let dir = tempfile::tempdir().expect("tempdir");
2421        let p = dir.path();
2422        git_ok(p, &["init"]);
2423        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
2424        git_ok(p, &["config", "user.name", "lattice-test"]);
2425        std::fs::write(p.join("a.rs"), "fn main() {}\n").expect("write base");
2426        git_ok(p, &["add", "a.rs"]);
2427        git_ok(p, &["commit", "-m", "base"]);
2428        std::fs::write(p.join("a.rs"), "fn main() {\n    let x = 1;\n}\n").expect("write");
2429
2430        let registry = lattice_syntax::LangRegistry::standard().expect("registry");
2431        let rt = tokio::runtime::Builder::new_current_thread()
2432            .enable_all()
2433            .build()
2434            .expect("runtime");
2435        let (text, _, spans, _) = rt
2436            .block_on(expand_payload(
2437                p.to_path_buf(),
2438                unstaged("a.rs"),
2439                3,
2440                Some(registry),
2441            ))
2442            .expect("a modified tracked file has a diff");
2443
2444        let added = text
2445            .lines()
2446            .position(|l| l.starts_with("+    let x"))
2447            .expect("the added line is in the diff");
2448        let style_at = |byte: usize| {
2449            spans[added]
2450                .iter()
2451                .find(|s| byte >= s.start && byte < s.end)
2452                .map(|s| s.style)
2453        };
2454        assert_eq!(
2455            style_at(0),
2456            Some(Style::DiffAdd),
2457            "the `+` column stays diff-coloured, which is also what the \
2458             sign map reads to tint the row"
2459        );
2460        let code = style_at(5);
2461        assert!(
2462            code.is_some() && code != Some(Style::DiffAdd),
2463            "the code past the marker must resolve to a syntax style, got {code:?}"
2464        );
2465    }
2466
2467    /// The relocation must not change what the caller receives: the
2468    /// trimmed diff text, its line count, and one span row per line.
2469    #[test]
2470    fn returns_the_diff_its_line_count_and_a_span_row_per_line() {
2471        let dir = repo_with_modified_file(20);
2472        let rt = tokio::runtime::Builder::new_current_thread()
2473            .enable_all()
2474            .build()
2475            .expect("runtime");
2476        let (text, line_count, spans, _) = rt
2477            .block_on(expand_payload(
2478                dir.path().to_path_buf(),
2479                unstaged("a.txt"),
2480                3,
2481                None,
2482            ))
2483            .expect("a modified tracked file has a diff");
2484
2485        assert!(text.starts_with("diff --git"), "got: {text:?}");
2486        assert_eq!(line_count, text.lines().count());
2487        assert_eq!(
2488            spans.len(),
2489            line_count,
2490            "one span row per line, or the highlight splice misaligns"
2491        );
2492        assert!(
2493            spans
2494                .iter()
2495                .any(|row| row.iter().any(|s| s.style == Style::DiffAdd)),
2496            "a changed file's diff must carry added lines"
2497        );
2498    }
2499
2500    /// MG.46: **the patch must be inlined verbatim**, because a hunk's
2501    /// `@@` header declares how many body lines it has and the fold
2502    /// source bounds the hunk by that count.
2503    ///
2504    /// A blank trailing context line is a single space, and `.trim()`
2505    /// on the whole patch removed it — leaving the text one line
2506    /// shorter than its own header claimed. The hunk fold then ran past
2507    /// the end of the diff into the status rows below it, which is the
2508    /// same symptom `hunk_fold_source` was fixed for and the reason
2509    /// only trailing newlines may be stripped.
2510    #[test]
2511    fn a_trailing_blank_context_line_survives_into_the_expansion() {
2512        let dir = tempfile::tempdir().expect("tempdir");
2513        let p = dir.path();
2514        git_ok(p, &["init"]);
2515        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
2516        git_ok(p, &["config", "user.name", "lattice-test"]);
2517        // The file ends with a blank line, so the diff's last context
2518        // line is a lone space.
2519        std::fs::write(p.join("a.txt"), "one\ntwo\n\n").expect("write base");
2520        git_ok(p, &["add", "a.txt"]);
2521        git_ok(p, &["commit", "-m", "base"]);
2522        std::fs::write(p.join("a.txt"), "one\ntwo CHANGED\n\n").expect("write modified");
2523
2524        let rt = tokio::runtime::Builder::new_current_thread()
2525            .enable_all()
2526            .build()
2527            .expect("runtime");
2528        let (text, line_count, spans, _) = rt
2529            .block_on(expand_payload(p.to_path_buf(), unstaged("a.txt"), 3, None))
2530            .expect("a modified tracked file has a diff");
2531
2532        let body: Vec<&str> = text.lines().collect();
2533        let at = body
2534            .iter()
2535            .position(|l| l.starts_with("@@"))
2536            .expect("the patch has a hunk header");
2537        // Every row after the header is hunk body, including the blank
2538        // context line git emits as a lone space.
2539        let declared = body[at]
2540            .split_whitespace()
2541            .find_map(|t| {
2542                t.strip_prefix('+')?
2543                    .split_once(',')
2544                    .map(|(_, c)| c.to_string())
2545            })
2546            .and_then(|c| c.parse::<usize>().ok())
2547            .expect("the header declares a new-side count");
2548        let present = body[at + 1..]
2549            .iter()
2550            .filter(|l| l.is_empty() || l.starts_with([' ', '+']))
2551            .count();
2552        assert_eq!(
2553            present, declared,
2554            "the inlined body must supply every line its header declares; \
2555             got {body:?}",
2556        );
2557        assert_eq!(line_count, text.lines().count());
2558        assert_eq!(spans.len(), line_count, "one span row per line");
2559    }
2560
2561    /// An entry with nothing to show declines rather than inserting a
2562    /// blank expansion — the behaviour the old `!diff.trim().is_empty()`
2563    /// guard had.
2564    #[test]
2565    fn declines_when_there_is_no_diff() {
2566        let dir = repo_with_modified_file(5);
2567        let rt = tokio::runtime::Builder::new_current_thread()
2568            .enable_all()
2569            .build()
2570            .expect("runtime");
2571        // `b.txt` is not in the repo at all, so `git diff -- b.txt` is
2572        // empty.
2573        let out = rt.block_on(expand_payload(
2574            dir.path().to_path_buf(),
2575            unstaged("b.txt"),
2576            3,
2577            None,
2578        ));
2579        // MG.56: not merely "nothing was inserted" — WHY. This used to
2580        // assert only the silence, which is exactly the behaviour that
2581        // made `=` look like an unbound key: a file whose changes had
2582        // been committed elsewhere produced an empty patch and no word
2583        // about it. The distinction between "git had nothing to show"
2584        // and "git failed" is what lets the caller say something
2585        // useful, so the test pins it rather than the emptiness.
2586        assert!(
2587            matches!(out, Err(ExpandMiss::NoChanges)),
2588            "an empty diff must report NoChanges, not a bare failure — \
2589             the row is stale, and `gr` is the fix worth naming"
2590        );
2591    }
2592
2593    /// **The MG.31 regression guard.** Mirrors
2594    /// `lattice-multibuffer/tests/ui_responsive_during_scan.rs`: run on
2595    /// a `current_thread` runtime (the editor actor's configuration,
2596    /// `editor_actor.rs:562`) and assert a concurrent probe keeps its
2597    /// sleep budget while the expansion runs.
2598    ///
2599    /// **Verified non-vacuous**, not assumed: dropping the
2600    /// `spawn_blocking` from `expand_payload` (the pre-MG.31 shape) puts
2601    /// the git call and the styling on this runtime and the measured gap
2602    /// goes to **263 ms** against the 50 ms threshold — a 5× margin, so
2603    /// neither CI jitter nor a fast machine can flip the verdict. The
2604    /// file is sized (200k lines, every one changed) to buy exactly that
2605    /// margin; at 40k it was only 74 ms, which was too close to call.
2606    #[test]
2607    fn the_expansion_does_not_starve_the_actor_runtime() {
2608        let dir = repo_with_modified_file(200_000);
2609        let rt = tokio::runtime::Builder::new_current_thread()
2610            .enable_all()
2611            .build()
2612            .expect("runtime");
2613
2614        let (max_gap, ticks, produced) = rt.block_on(async {
2615            let task = tokio::task::spawn(expand_payload(
2616                dir.path().to_path_buf(),
2617                unstaged("a.txt"),
2618                3,
2619                None,
2620            ));
2621
2622            let mut max_gap = Duration::ZERO;
2623            let mut ticks = 0usize;
2624            let mut last = Instant::now();
2625            for _ in 0..50 {
2626                tokio::time::sleep(Duration::from_millis(5)).await;
2627                let now = Instant::now();
2628                max_gap = max_gap.max(now.duration_since(last));
2629                ticks += 1;
2630                last = now;
2631            }
2632            let produced = task.await.expect("join").is_ok();
2633            (max_gap, ticks, produced)
2634        });
2635
2636        assert_eq!(ticks, 50, "all probe iterations ran");
2637        assert!(produced, "the expansion still produced its diff");
2638        assert!(
2639            max_gap < Duration::from_millis(50),
2640            "max probe gap was {max_gap:?}; expected < 50 ms — the actor's \
2641             current_thread runtime is being starved by the `=` expansion \
2642             (paramount-goal-1 regression, MG.31). The git call and the \
2643             styling belong inside `spawn_blocking`."
2644        );
2645    }
2646}
2647
2648/// MG — `x` over a multi-file Visual selection.
2649///
2650/// Reported 2026-09-11: selecting several untracked files and pressing `x`
2651/// untracked only the first. `s` and `u` had honoured a selection since
2652/// MG.23g (`stage_or_unstage`'s FileLevel branch reads `ctx.selection`);
2653/// `x` alone still read `ctx.cursor.line`.
2654#[cfg(test)]
2655mod batch_discard_tests {
2656    use super::*;
2657
2658    fn f(path: &str, untracked: bool) -> (PathBuf, bool) {
2659        (PathBuf::from(path), untracked)
2660    }
2661
2662    /// The reported case: all untracked. The prompt says DELETE and says how
2663    /// many, because there is no copy to restore and a count of one would be
2664    /// a lie about what the key is about to do.
2665    #[test]
2666    fn an_all_untracked_selection_asks_to_delete_all_of_them() {
2667        let effect = batch_discard_confirm(&[f("a.txt", true), f("b.txt", true)]);
2668        let lattice_grammar::Effect::Confirm {
2669            prompt, yes_action, ..
2670        } = effect
2671        else {
2672            panic!("expected a Confirm, got {effect:?}");
2673        };
2674        assert!(prompt.contains('2'), "the count is named: {prompt}");
2675        assert!(prompt.contains("Delete"), "and that it deletes: {prompt}");
2676        assert_eq!(yes_action, "action:magit-discard-batch-execute");
2677    }
2678
2679    #[test]
2680    fn an_all_tracked_selection_asks_to_discard_changes() {
2681        let effect = batch_discard_confirm(&[f("a.rs", false), f("b.rs", false)]);
2682        let lattice_grammar::Effect::Confirm { prompt, .. } = effect else {
2683            panic!("expected a Confirm");
2684        };
2685        assert!(prompt.contains("Discard changes"), "{prompt}");
2686        assert!(!prompt.contains("Delete"), "nothing is deleted: {prompt}");
2687    }
2688
2689    /// **A mixed selection names BOTH counts.** The two halves are not
2690    /// equally severe — a tracked file comes back from the index, an
2691    /// untracked one does not come back at all — so a prompt saying only
2692    /// "Discard 3 files?" would hide the irreversible half behind the
2693    /// recoverable one.
2694    #[test]
2695    fn a_mixed_selection_names_the_irreversible_half_separately() {
2696        let effect = batch_discard_confirm(&[
2697            f("tracked.rs", false),
2698            f("new_a.txt", true),
2699            f("new_b.txt", true),
2700        ]);
2701        let lattice_grammar::Effect::Confirm { prompt, .. } = effect else {
2702            panic!("expected a Confirm");
2703        };
2704        assert!(prompt.contains("DELETE"), "{prompt}");
2705        assert!(prompt.contains("cannot be restored"), "{prompt}");
2706    }
2707
2708    /// **The ask emits exactly as many slots as its action DECLARES**, and
2709    /// this is the assertion the whole batch turned on.
2710    ///
2711    /// `Effect::Confirm` seeds the dialog's transient state by ZIPPING the
2712    /// yes-action's schema with the carried values, and `TransientValue` is
2713    /// `Bool | String` — a slot cannot hold a list. So a producer emitting one
2714    /// slot per file against a one-slot schema loses every file but the first
2715    /// BETWEEN the ask and the act: the prompt says "Discard 3 files?", you
2716    /// confirm, and one is discarded. That is the bug `b0772901` set out to
2717    /// fix, reappearing one seam later because it shipped with no test.
2718    ///
2719    /// Asserted against the schema in `lib.rs` rather than against the literal
2720    /// `1`, so the two cannot drift apart in either direction.
2721    #[test]
2722    fn the_ask_emits_one_slot_per_declared_schema_slot() {
2723        let declared = crate::confirm_target_slots("action:magit-discard-batch-execute")
2724            .expect("the batch execute declares its slots");
2725        let lattice_grammar::Effect::Confirm { args, .. } =
2726            batch_discard_confirm(&[f("a.rs", false), f("b.txt", true), f("c.txt", true)])
2727        else {
2728            panic!("expected a Confirm");
2729        };
2730        assert_eq!(
2731            args.as_list().map(<[_]>::len),
2732            Some(declared),
2733            "the ask carries a different number of slots than the action \
2734             declares — anything past the declared count is dropped by the \
2735             confirm round trip, silently, and the act runs on a truncated list"
2736        );
2737    }
2738
2739    /// The carried payload round-trips, flag included — that flag is what
2740    /// decides `git checkout` versus `git clean`, so losing it would either
2741    /// fail on an untracked path or DELETE a tracked one.
2742    #[test]
2743    fn the_carried_list_round_trips_with_its_flags() {
2744        let files = vec![f("a.rs", false), f("b.txt", true)];
2745        let lattice_grammar::Effect::Confirm { args, .. } = batch_discard_confirm(&files) else {
2746            panic!("expected a Confirm");
2747        };
2748        assert_eq!(carried_batch(&args), files);
2749    }
2750
2751    /// A path with a space, a quote, a newline. The flag is ONE leading byte
2752    /// rather than a separator precisely so every path survives — splitting
2753    /// on one would lose exactly the paths that most need care.
2754    #[test]
2755    fn a_hostile_path_survives_the_round_trip() {
2756        let files = vec![f("dir with space/a'b\nc.txt", true)];
2757        let lattice_grammar::Effect::Confirm { args, .. } = batch_discard_confirm(&files) else {
2758            panic!("expected a Confirm");
2759        };
2760        assert_eq!(carried_batch(&args), files);
2761    }
2762
2763    /// An entry carrying neither flag is DROPPED, not guessed. A
2764    /// mis-decoded entry here would delete a path nobody named.
2765    #[test]
2766    fn an_unflagged_entry_is_dropped_rather_than_guessed() {
2767        let args = lattice_grammar::Args::List(vec![
2768            lattice_grammar::ArgValue::String("ta.rs".to_string()),
2769            lattice_grammar::ArgValue::String("/etc/passwd".to_string()),
2770        ]);
2771        assert_eq!(carried_batch(&args), vec![f("a.rs", false)]);
2772    }
2773}