Skip to main content

lattice_magit/
magit_stash_mode.rs

1//! MG.9: magit-stash major mode.
2//!
3//! Lists stash entries with apply/pop/drop/create operations.
4
5use std::sync::{Arc, Mutex, OnceLock};
6
7use lattice_config;
8use lattice_grammar::Effect;
9use lattice_mode::{
10    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
11    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
12    keymap_entry,
13};
14use lattice_protocol::position::Position;
15use lattice_vcs::{Repository, Stash};
16
17use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
18use crate::headerline::{self, Field, MagitHeaderlineHandle};
19
20pub struct MagitStashMode;
21
22impl MagitStashMode {
23    pub fn mode_id() -> ModeId {
24        ModeId::new("magit-stash-mode")
25    }
26}
27
28fn magit_stash_keymap_entries() -> &'static [KeymapEntry] {
29    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
30    ENTRIES.get_or_init(|| {
31        vec![
32            keymap_entry! { mode: Normal, chord: "a", doc: "Apply stash", cmd: "action:magit-stash-apply" },
33            keymap_entry! { mode: Normal, chord: "p", doc: "Pop stash", cmd: "action:magit-stash-pop" },
34            keymap_entry! { mode: Normal, chord: "d", doc: "Drop stash", cmd: "action:magit-stash-drop" },
35            keymap_entry! { mode: Normal, chord: "z", doc: "Create stash", cmd: "action:magit-stash-create" },
36            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show this stash's patch", cmd: "action:magit-stash-show" },
37        ]
38    })
39}
40
41pub struct StashState {
42    buffer_id: lattice_core::BufferId,
43    store: Arc<BufferStoreHandle>,
44    workdir: std::path::PathBuf,
45    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
46    /// MG.14: the buffer's headerline — the stash count, re-set from
47    /// the same `build_stash_list` call that produced the list.
48    headerline: Option<MagitHeaderlineHandle>,
49}
50
51/// MG.13: service alias for this mode's per-buffer state — register
52/// and look up through this exact type
53/// (`feedback_servicesregistry_arc_typeid`).
54pub type StashStatesHandle = Arc<BufferStates<StashState>>;
55
56/// Resolve this mode's state for the buffer an action fired in.
57/// `None` means no magit-stash buffer is live there.
58fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<StashState>>> {
59    crate::buffer_state::state_for::<StashState>(ctx)
60}
61
62/// The stash a chord acts on, or the picker to ask with.
63///
64/// Resolution order is the whole fix. Every magit buffer that shows
65/// stashes answers `stash_at_cursor` for its own rows, so the cursor
66/// wins wherever it lands one — the stash list AND magit-status's
67/// Stashes section. With nothing under the cursor the row *asks*
68/// rather than dying silently, which is where MG.23j landed `A` / `_`
69/// / `O` for exactly the same reason: the dispatch menu can be opened
70/// from a buffer with no stash in it at all.
71enum StashTarget {
72    At(usize),
73    Ask(Effect),
74}
75
76fn stash_target(ctx: &ActionContext<'_>, ex_command: &str) -> StashTarget {
77    match crate::buffer_state::view_for(ctx).and_then(|v| v.stash_at_cursor(ctx.cursor)) {
78        Some(idx) => StashTarget::At(idx),
79        None => StashTarget::Ask(Effect::OpenPicker {
80            source: crate::picker_sources::STASH_PICK_SOURCE.to_string(),
81            args: vec![ex_command.to_string()],
82            root: None,
83            fill_action: None,
84            query: None,
85        }),
86    }
87}
88
89/// A `lattice_vcs::Stash` mutation, as data — so the three that share
90/// [`run_on_stash`]'s body differ by one function pointer rather than
91/// by a copy of it.
92type StashOp = fn(&Repository, usize) -> lattice_vcs::Result<()>;
93
94/// Run `op` on `stash@{idx}`, refreshing in place when we own the
95/// buffer.
96///
97/// Two paths, deliberately: inside the stash list there is a
98/// `StashState` to rebuild, so the list updates itself. From anywhere
99/// else — magit-status, or a dispatch menu over an ordinary file —
100/// there is nothing of ours to rebuild, so the operation reports by
101/// notification and `gr` refreshes, which is exactly what
102/// `spawn_commit_op` does for the commit ops fired from any buffer.
103fn run_on_stash(
104    ctx: &ActionContext<'_>,
105    idx: usize,
106    verb: &'static str,
107    op: StashOp,
108) -> Option<Effect> {
109    if let Some(s) = state(ctx) {
110        let workdir = { s.lock().ok()?.workdir.clone() };
111        return spawn_mutation_and_refresh(s, format!("{verb} stash@{{{idx}}}"), move || {
112            let repo =
113                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
114            op(&repo, idx)
115                .map(|_| String::new())
116                .map_err(|e| e.to_string())
117        });
118    }
119    Some(crate::magit_global_mode::spawn_git(
120        crate::repo_scope::action_workdir(ctx),
121        vec![
122            "stash".to_string(),
123            verb.to_string(),
124            format!("stash@{{{idx}}}"),
125        ],
126        &format!("{verb} stash@{{{idx}}}"),
127    ))
128}
129
130/// `gr` for a stash buffer — see [`MagitView`].
131struct StashView(Arc<Mutex<StashState>>);
132
133impl MagitView for StashView {
134    fn refresh(&self) -> Option<Effect> {
135        refresh(self.0.clone())
136    }
137
138    /// Every row in this buffer is a stash, so the cursor line is the
139    /// whole answer.
140    fn stash_at_cursor(&self, cursor: Position) -> Option<usize> {
141        let g = self.0.lock().ok()?;
142        stash_index_at_cursor(&g, cursor)
143    }
144}
145
146impl Mode for MagitStashMode {
147    type Guard = BufferStateGuard<StashState>;
148
149    fn id(&self) -> ModeId {
150        Self::mode_id()
151    }
152    fn kind(&self) -> ModeKind {
153        ModeKind::Major
154    }
155    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
156        None
157    }
158
159    fn options(&self) -> OptionOverrideSet {
160        lattice_config::overrides! {
161            lattice_config::ReadOnly = true,
162            lattice_config::NoFile = true,
163        }
164    }
165
166    /// MG.RO: `read-only-mode` is where the gate actually is.
167    ///
168    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
169    /// `read_only_edit_rejected`, which guards the insert-mode char path;
170    /// operators never reach it, because a `Document`'s grammar dispatch
171    /// applies its own edits and hands the host an already-applied
172    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
173    /// the buffer reported itself read-only — worse than not gating at all,
174    /// because it looks protected.
175    ///
176    /// `read-only-mode` carries the option AND the `invocation_runner`
177    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
178    /// letting motions, `:` and `/` through.
179    ///
180    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
181    /// mode is followed from the mode being ACTIVATED, and the majors are what
182    /// the host activates. Putting it on the shared minor looked right and was
183    /// verified not to fire.
184    fn implies(&self) -> &[lattice_mode::ModeId] {
185        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
186        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
187    }
188
189    fn required_capabilities(&self) -> CapabilitySet {
190        CapabilitySet::empty()
191    }
192    fn keymap(&self) -> Keymap {
193        Keymap::from_entries(magit_stash_keymap_entries())
194    }
195
196    /// MG.13: registered once at boot, not per activation — see
197    /// `buffer_state`'s module docs for why.
198    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
199        vec![
200            // apply (a)
201            ActionHandlerContribution {
202                action_name: "action:magit-stash-apply",
203                handler: Arc::new(|ctx: &ActionContext<'_>| {
204                    match stash_target(ctx, "magit-stash-apply") {
205                        StashTarget::At(idx) => run_on_stash(ctx, idx, "apply", Stash::apply),
206                        StashTarget::Ask(effect) => Some(effect),
207                    }
208                }),
209            },
210            // pop (p)
211            ActionHandlerContribution {
212                action_name: "action:magit-stash-pop",
213                handler: Arc::new(|ctx: &ActionContext<'_>| {
214                    match stash_target(ctx, "magit-stash-pop") {
215                        StashTarget::At(idx) => run_on_stash(ctx, idx, "pop", Stash::pop),
216                        StashTarget::Ask(effect) => Some(effect),
217                    }
218                }),
219            },
220            // drop (d) — MG.12: a dropped stash is gone; `apply` and
221            // `pop` above put their content somewhere the user can
222            // still see it, so only this one asks. No git call in this
223            // half: answering `n` never reaches the execute half.
224            ActionHandlerContribution {
225                action_name: "action:magit-stash-drop",
226                handler: Arc::new(|ctx: &ActionContext<'_>| {
227                    match stash_target(ctx, "magit-stash-drop") {
228                        StashTarget::At(idx) => Some(drop_stash_confirm(idx)),
229                        StashTarget::Ask(effect) => Some(effect),
230                    }
231                }),
232            },
233            // drop, after confirmation — re-reads the stash at the
234            // cursor, which the confirm transient could not have moved
235            // (see the matching note in `magit_branch_mode`).
236            ActionHandlerContribution {
237                action_name: "action:magit-stash-drop-execute",
238                handler: Arc::new(|ctx: &ActionContext<'_>| {
239                    // IX.2: drop the stash the prompt named. Stash
240                    // indices RENUMBER — dropping or creating one
241                    // shifts every later index — so re-reading the row
242                    // after a refresh is how you drop the wrong stash.
243                    //
244                    // The carried target is read BEFORE any view
245                    // lookup, and that ordering is load-bearing here in
246                    // a way it was not before: the picker path fires
247                    // this half from a buffer with no stash under the
248                    // cursor at all, so a cursor re-read would find
249                    // nothing and silently drop none.
250                    let idx = match crate::confirm::carried_target(ctx)
251                        .and_then(|t| t.parse::<usize>().ok())
252                    {
253                        Some(carried) => carried,
254                        None => match stash_target(ctx, "magit-stash-drop") {
255                            StashTarget::At(idx) => idx,
256                            StashTarget::Ask(effect) => return Some(effect),
257                        },
258                    };
259                    run_on_stash(ctx, idx, "drop", Stash::drop)
260                }),
261            },
262            // MG.15: <CR> — open this stash's patch in its own buffer.
263            // The one list view that had no `<CR>`, which made it the
264            // last exception to MG.11's uniformity rule. Read-only and
265            // non-mutating, so no confirm.
266            ActionHandlerContribution {
267                action_name: "action:magit-stash-show",
268                handler: Arc::new(|ctx: &ActionContext<'_>| {
269                    let idx = match stash_target(ctx, "magit-stash-show") {
270                        StashTarget::At(idx) => idx,
271                        StashTarget::Ask(effect) => return Some(effect),
272                    };
273                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
274                        ctx,
275                        "stash",
276                        "magit-stash-show-mode",
277                        Some(&crate::magit_stash_show_mode::stash_view_rest(idx)),
278                    ))
279                }),
280            },
281            // create (z)
282            ActionHandlerContribution {
283                action_name: "action:magit-stash-create",
284                handler: Arc::new(|ctx: &ActionContext<'_>| {
285                    let s = state(ctx)?;
286                    let workdir = { s.lock().ok()?.workdir.clone() };
287                    spawn_mutation_and_refresh(s, "stash changes".to_string(), move || {
288                        let repo = Repository::discover(&workdir)
289                            .map_err(|e| format!("not a git repository: {e}"))?;
290                        Stash::create(&repo, None, false)
291                            .map(|_| String::new())
292                            .map_err(|e| e.to_string())
293                    })
294                }),
295            },
296        ]
297    }
298
299    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
300        Box::pin(async move {
301            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
302            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
303            let Some(store) = ctx.service::<BufferStoreHandle>() else {
304                return Ok(orphan());
305            };
306            let Some(handle) = store.handle_for(buffer_id) else {
307                return Ok(orphan());
308            };
309            // MR.3: the repository the trigger resolved for THIS
310            // buffer, not the one the editor was started in.
311            let workdir =
312                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
313            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
314
315            // MG.14: install the headerline in the same synchronous
316            // prefix as the state publish.
317            let (hl, hl_registration) =
318                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
319                    Some((h, reg)) => (Some(h), Some(reg)),
320                    None => (None, None),
321                };
322
323            // MG.13: publish BEFORE the first `.await` — see the note
324            // in `magit_branch_mode::on_activate`.
325            let Some(states) = ctx.service::<StashStatesHandle>() else {
326                return Ok(orphan());
327            };
328            let state = states.publish(
329                buffer_id,
330                StashState {
331                    buffer_id,
332                    store: store.clone(),
333                    workdir: workdir.clone(),
334                    pending_highlights: pending_highlights.clone(),
335                    headerline: hl.clone(),
336                },
337            );
338            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
339                .with_headerline(hl_registration);
340            if let Some(views) = ctx.service::<MagitViewsHandle>() {
341                views.publish(buffer_id, Arc::new(StashView(state.clone())));
342                guard = guard.with_views((*views).clone());
343            }
344
345            // Populate stash list: blocking I/O on spawn_blocking, then
346            // apply edit on the current task (no Runtime::new()).
347            let wd = workdir.clone();
348            let (text, header) = tokio::task::spawn_blocking(move || build_stash_list(&wd))
349                .await
350                .unwrap();
351            headerline::publish(&hl, header);
352            let spans = crate::highlight::stash_styled_spans(&text);
353            crate::buffer_io::replace_buffer_text(&handle, text).await;
354            if let Some(ref ph) = pending_highlights {
355                ph.store_and_wake(buffer_id, spans);
356            }
357
358            Ok(guard)
359        })
360    }
361}
362
363/// `gr` — re-list stashes without a prior mutation.
364fn refresh(s: Arc<Mutex<StashState>>) -> Option<Effect> {
365    let (handle, wd, pending, buffer_id, hl) = {
366        let g = s.lock().ok()?;
367        (
368            g.store.handle_for(g.buffer_id)?,
369            g.workdir.clone(),
370            g.pending_highlights.clone(),
371            g.buffer_id,
372            g.headerline.clone(),
373        )
374    };
375    // MG.27: the row says "refreshing" from here until the
376    // guard drops — including on every early exit inside the
377    // task, which is why it is a guard and not a matching pair.
378    let busy = headerline::busy(&hl);
379    tokio::task::spawn(async move {
380        let _busy = busy;
381        let (text, header) = tokio::task::spawn_blocking(move || build_stash_list(&wd))
382            .await
383            .unwrap_or_default();
384        headerline::publish(&hl, header);
385        let spans = crate::highlight::stash_styled_spans(&text);
386        crate::buffer_io::replace_buffer_text(&handle, text).await;
387        if let Some(ph) = pending {
388            ph.store_and_wake(buffer_id, spans);
389        }
390    });
391    None
392}
393
394/// Run `mutate` (a blocking git call) on `spawn_blocking`, off the
395/// actor thread, then re-list stashes — the shape every mutating
396/// handler above uses instead of calling git synchronously inline.
397/// Run a repository mutation off-thread, report it, then refresh.
398///
399/// MG.54: `mutate` returns a `Result` so the outcome can be published.
400/// It used to be `impl FnOnce()`, which meant every caller discarded
401/// its git result — the operation finished in silence, and a FAILED
402/// one finished in the same silence with the buffer refreshing as
403/// though it had worked.
404fn spawn_mutation_and_refresh(
405    s: Arc<Mutex<StashState>>,
406    label: String,
407    mutate: impl FnOnce() -> Result<String, String> + Send + 'static,
408) -> Option<Effect> {
409    let (handle, wd, pending, buffer_id, hl) = {
410        let g = s.lock().ok()?;
411        (
412            g.store.handle_for(g.buffer_id)?,
413            g.workdir.clone(),
414            g.pending_highlights.clone(),
415            g.buffer_id,
416            g.headerline.clone(),
417        )
418    };
419    // MG.27: the row says "refreshing" from here until the
420    // guard drops — including on every early exit inside the
421    // task, which is why it is a guard and not a matching pair.
422    let busy = headerline::busy(&hl);
423    tokio::task::spawn(async move {
424        let _busy = busy;
425        let result = tokio::task::spawn_blocking(mutate)
426            .await
427            .unwrap_or_else(|e| Err(e.to_string()));
428        crate::magit_global_mode::finish_task(&wd, &label, result);
429        let (text, header) = tokio::task::spawn_blocking(move || build_stash_list(&wd))
430            .await
431            .unwrap_or_default();
432        headerline::publish(&hl, header);
433        let spans = crate::highlight::stash_styled_spans(&text);
434        crate::buffer_io::replace_buffer_text(&handle, text).await;
435        if let Some(ph) = pending {
436            ph.store_and_wake(buffer_id, spans);
437        }
438    });
439    None
440}
441
442/// MG.12: the ask half of `d`. Names the stash by the same
443/// `stash@{N}` ref the list row shows, so the prompt and the row it
444/// came from read identically.
445fn drop_stash_confirm(index: usize) -> Effect {
446    crate::confirm::ask_target(
447        format!("Drop stash@{{{index}}}?"),
448        "action:magit-stash-drop-execute",
449        index.to_string(),
450    )
451}
452
453/// One row of the stash list. The single writer of this format —
454/// [`stash_index_at_cursor`] is its only reader, and
455/// `highlight::stash_styled_spans` colours it by the same offsets.
456pub fn list_row(index: usize, message: &str) -> String {
457    format!("  stash@{{{index}}} {message}")
458}
459
460fn stash_index_at_cursor(state: &StashState, cursor: Position) -> Option<usize> {
461    let handle = state.store.handle_for(state.buffer_id)?;
462    let snap = handle.snapshot();
463    let line = snap.buffer.line(cursor.line)?;
464    parse_index(&line)
465}
466
467/// `"  stash@{N} message"` → `N`. The reader half of [`list_row`],
468/// split out as a free function so the round-trip between the two is
469/// testable without a live buffer — the seam where they drifted apart
470/// is what left every chord in this buffer dead.
471pub fn parse_index(line: &str) -> Option<usize> {
472    line.trim()
473        .strip_prefix("stash@{")
474        .and_then(|s| s.split('}').next())
475        .and_then(|idx| idx.parse().ok())
476}
477
478/// Build the stash list AND its MG.14 header fields — the count comes
479/// from the same `Stash::list` the body is formatted from.
480fn build_stash_list(workdir: &std::path::Path) -> (String, Vec<Field>) {
481    let repo = match Repository::discover(workdir) {
482        Ok(r) => r,
483        Err(_) => return ("Not a git repository.\n".to_string(), Vec::new()),
484    };
485    let stashes = Stash::list(&repo).unwrap_or_default();
486    let header = headerline::stash_fields(stashes.len());
487    if stashes.is_empty() {
488        return ("No stashes.\n".to_string(), header);
489    }
490    // MG.15: rows carry the `stash@{N}` label, matching the stash
491    // entries magit-status already renders (`sections.rs`). This is a
492    // BUG FIX, not cosmetics: `stash_index_at_cursor` has always
493    // parsed `stash@{N}` out of the row, so while the list rendered a
494    // bare message EVERY chord in this buffer — `a`, `p`, `d` — read
495    // `None` and silently did nothing. Same failure class as MG.6's
496    // dead `<CR>`: the reader and the writer of a line format drifted
497    // apart with no test spanning them. `list_row` is now the one
498    // writer and `stash_index_at_cursor` reads it back; a round-trip
499    // test spans the pair.
500    let mut out = format!("Stashes ({})\n", stashes.len());
501    for s in &stashes {
502        out.push_str(&list_row(s.index, &s.message));
503        out.push('\n');
504    }
505    out.push('\n');
506    (out, header)
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    /// MG.15 — the regression guard for a bug that made EVERY chord in
514    /// this buffer dead.
515    ///
516    /// `stash_index_at_cursor` has always parsed `stash@{N}` out of the
517    /// row under the cursor, but `build_stash_list` rendered a bare
518    /// `  <message>`. So `a` (apply), `p` (pop) and `d` (drop) all
519    /// resolved `None` and silently did nothing — no error, no effect,
520    /// indistinguishable from an unbound key. Nothing caught it because
521    /// the writer and the reader were only ever tested apart.
522    ///
523    /// This spans them: whatever `list_row` writes, `parse_index` must
524    /// read back.
525    #[test]
526    fn every_row_the_list_writes_parses_back_to_its_own_index() {
527        for (index, message) in [
528            (0usize, "WIP on main: 1234abc a message"),
529            (7, "On feature/x: another"),
530            (12, ""),
531        ] {
532            let row = list_row(index, message);
533            assert_eq!(
534                parse_index(&row),
535                Some(index),
536                "the chord handlers read the row the list writes; a \
537                 format the parser cannot read leaves a/p/d/<CR> dead: {row:?}"
538            );
539        }
540    }
541
542    /// The inverse, so the guard above cannot pass vacuously: the
543    /// pre-MG.15 format (bare message, no label) is exactly what the
544    /// parser cannot read.
545    #[test]
546    fn the_old_unlabelled_row_format_is_unparseable() {
547        assert_eq!(parse_index("  WIP on main: 1234abc a message"), None);
548    }
549
550    /// magit-status renders stash rows too, and now *resolves* them —
551    /// so the round-trip above has to hold for the status buffer's
552    /// writer as well.
553    ///
554    /// It nearly did not: `sections.rs` had its own inline copy of the
555    /// format rather than calling [`list_row`]. That is the identical
556    /// writer/reader split MG.15 was, one buffer over, and it would
557    /// have surfaced the identical way — `p` on a Stashes row doing
558    /// nothing, indistinguishable from an unbound key. This asserts
559    /// they are the same string rather than trusting that they are.
560    #[test]
561    fn the_status_buffer_writes_the_same_stash_row_the_list_does() {
562        use crate::sections::{Section, SectionEntry, SectionIndex, SectionKind};
563
564        let entries: Vec<SectionEntry> = [(0usize, "WIP on main: abc123 x"), (3, "On main: y")]
565            .into_iter()
566            .map(|(index, message)| SectionEntry::Stash {
567                index,
568                message: message.to_string(),
569            })
570            .collect();
571        let index = SectionIndex {
572            sections: vec![Section {
573                kind: SectionKind::Stashes,
574                header_line: 0,
575                body_start: 1,
576                body_end: 1 + entries.len(),
577                entries,
578            }],
579            branch: "main".to_string(),
580            ahead: 0,
581            behind: 0,
582            bisect: None,
583            in_flight: None,
584            upstream: None,
585        };
586
587        // Render the real status buffer and read its stash rows back
588        // through the parser the chords use.
589        let rendered = index.format_buffer();
590        let found: Vec<usize> = rendered.lines().filter_map(parse_index).collect();
591        assert_eq!(
592            found,
593            vec![0, 3],
594            "magit-status's stash rows must parse back to their indices — \
595             the chords resolve the stash under the cursor this way, and \
596             a row the parser cannot read is a dead key.\n{rendered}"
597        );
598    }
599
600    /// The list header and blank separator are not stash rows — a
601    /// chord fired on either must decline rather than act on stash 0.
602    #[test]
603    fn non_row_lines_carry_no_index() {
604        assert_eq!(parse_index("Stashes (3)"), None);
605        assert_eq!(parse_index(""), None);
606        assert_eq!(parse_index("No stashes."), None);
607    }
608
609    /// MG.15: `<CR>` targets the detail buffer for the stash at the
610    /// cursor. Asserted through the same name builder the mode's own
611    /// parser round-trips (see `magit_stash_show_mode`), so the two
612    /// halves cannot drift the way the list format did.
613    #[test]
614    fn enter_opens_the_detail_buffer_for_the_row_under_the_cursor() {
615        let row = list_row(3, "WIP on main: deadbee something");
616        let index = parse_index(&row).expect("a list row carries its index");
617        assert_eq!(
618            crate::workdir::magit_buffer_name_with(
619                "stash",
620                "lattice",
621                &crate::magit_stash_show_mode::stash_view_rest(index),
622            ),
623            "*magit:stash:lattice:3*"
624        );
625    }
626
627    /// MG.12: `d` used to call `Stash::drop` straight from the chord,
628    /// while magit-status's `x` on the same class of act asked first.
629    #[test]
630    fn drop_asks_before_dropping_and_names_the_stash() {
631        match drop_stash_confirm(2) {
632            Effect::Confirm {
633                prompt,
634                yes_action,
635                args: _,
636            } => {
637                assert_eq!(prompt, "Drop stash@{2}?");
638                assert_eq!(yes_action, "action:magit-stash-drop-execute");
639            }
640            other => panic!("expected a confirm before dropping a stash, got {other:?}"),
641        }
642    }
643
644    /// The prompt names the stash with the same `stash@{N}` ref the
645    /// list row shows, so the question matches what is on screen
646    /// behind the transient.
647    ///
648    /// MG.15 note: this test used to build `row` as a hand-written
649    /// string literal — which is precisely how the bug below survived.
650    /// It asserted against the format the author *believed* the list
651    /// rendered, and the list rendered something else. It now calls
652    /// [`list_row`], the real writer.
653    #[test]
654    fn drop_prompt_uses_the_same_ref_form_the_list_row_shows() {
655        let index = 0;
656        let row = list_row(index, "WIP on main: 1234abc msg");
657        match drop_stash_confirm(index) {
658            Effect::Confirm { prompt, .. } => {
659                let stash_ref = format!("stash@{{{index}}}");
660                assert!(row.contains(&stash_ref) && prompt.contains(&stash_ref));
661            }
662            other => panic!("expected Confirm, got {other:?}"),
663        }
664    }
665}