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(§ion_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}