Skip to main content

lattice_magit/
magit_stash_show_mode.rs

1//! MG.15: `*magit:stash:<n>*` — one stash's patch.
2//!
3//! magit-stash listed stashes but had no `<CR>`: the only way to see
4//! what a stash actually contained was magit-status, where `<CR>`
5//! toggles the patch inline among the other sections. That was the
6//! last hole in MG.11's `<CR>` uniformity rule — every other list view
7//! navigates to a detail buffer, and "apply this to my working tree?"
8//! is exactly the question you want answered before pressing `a`.
9//!
10//! magit-status's inline toggle is deliberately left alone: there a
11//! stash is one row among many and expanding in place keeps the
12//! surrounding context. Here the stash IS the subject.
13//!
14//! Fixed-content view, like `magit-revision-mode`: `gr` is a no-op
15//! because `stash@{n}`'s patch does not change under a fixed index.
16//! (Dropping or popping the stash renumbers the *other* entries —
17//! which is why the buffer name carries the index it was opened at,
18//! and why the stash list is the place that refreshes, not this.)
19
20use std::sync::{Arc, Mutex, OnceLock};
21
22use lattice_config;
23use lattice_grammar::Effect;
24use lattice_mode::{
25    BufferStoreHandle, CapabilitySet, Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext,
26    ModeId, ModeKind, OptionOverrideSet,
27};
28use lattice_protocol::position::Position;
29
30use crate::buffer_state::{BufferStateGuard, BufferStates};
31use crate::headerline;
32
33pub struct MagitStashShowMode;
34
35impl MagitStashShowMode {
36    pub fn mode_id() -> ModeId {
37        ModeId::new("magit-stash-show-mode")
38    }
39}
40
41fn magit_stash_show_keymap_entries() -> &'static [KeymapEntry] {
42    // No mode-specific chords — `q`/`gr`/nav come from magit-core
43    // (this mode is in its `ActivationPolicy::Majors` list). Same
44    // shape as `magit-file-revision-mode`.
45    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
46    ENTRIES.get_or_init(Vec::new)
47}
48
49/// MG.23g: what `a` / `-` need to know about this buffer — where the
50/// repository is. The stash index is already in the buffer name; the
51/// working directory is not, and a `git apply` needs one.
52pub struct StashShowState {
53    workdir: std::path::PathBuf,
54    /// MR.3b: the repository label this buffer's own name carries —
55    /// held, not re-derived, for the qualified-label reason in
56    /// `RevisionState`.
57    repo: String,
58    /// MG.22: which stash, so `<CR>` can name the ref it opens the
59    /// file from. The buffer name carries it too, but a view that had
60    /// to re-parse the name would make that format load-bearing in a
61    /// second place — the drift that left every stash chord dead until
62    /// MG.15.
63    index: Option<usize>,
64}
65
66/// Service alias for this mode's per-buffer state
67/// (`feedback_servicesregistry_arc_typeid`).
68pub type StashShowStatesHandle = Arc<BufferStates<StashShowState>>;
69
70/// MG.23g: this buffer's [`MagitView`] — the stash peer of
71/// `magit-revision-mode`'s, and identical for the same reason: a
72/// stash's patch describes a change that is not sitting between two of
73/// this checkout's trees, so `s` / `u` cannot move it and `a` / `-`
74/// can put it into the working tree or take it back out.
75///
76/// This is hunk-level, which is what makes it different from the stash
77/// list's own `a` (apply the *whole* stash): one hunk of a stash,
78/// where the list's key takes all of it.
79struct StashShowView(Arc<Mutex<StashShowState>>);
80
81impl crate::buffer_state::MagitView for StashShowView {
82    /// This buffer's content is a unified diff, so "a file" is a
83    /// `diff --git` header — not the generic indented-row scan, which
84    /// here matches every indented CONTEXT line and would walk `]f`
85    /// through arbitrary code.
86    fn file_lines(
87        &self,
88        store: &lattice_mode::BufferStoreHandle,
89        buffer: lattice_core::BufferId,
90    ) -> Option<Vec<u32>> {
91        Some(crate::magit_core_mode::diff_file_lines(store, buffer))
92    }
93
94    /// `stash@{n}`'s patch does not change under a fixed index, so
95    /// there is nothing for `gr` to rebuild — see this module's header
96    /// for why the stash *list* is the thing that refreshes.
97    fn refresh(&self) -> Option<Effect> {
98        None
99    }
100
101    fn diff_source(&self, _cursor: Position) -> Option<crate::buffer_state::DiffSource> {
102        Some(crate::buffer_state::DiffSource::Committed)
103    }
104
105    /// MG.22: the file as this stash left it. `git show
106    /// stash@{n}:<path>` is a real revspec, so the existing
107    /// `magit-file-revision-mode` opens it with no new machinery — the
108    /// ref simply is not a sha.
109    fn diff_target(
110        &self,
111        path: &std::path::Path,
112        _cursor: lattice_protocol::position::Position,
113    ) -> Option<Effect> {
114        let (idx, label) = {
115            let g = self.0.lock().ok()?;
116            (g.index?, g.repo.clone())
117        };
118        Some(Effect::OpenSyntheticBuffer {
119            name: crate::magit_file_revision_mode::blob_buffer_name(
120                &label,
121                &format!("stash@{{{idx}}}"),
122                path,
123            ),
124            mode_id: "magit-file-revision-mode".to_string(),
125            content: None,
126            cursor: None,
127            activate_minor: None,
128        })
129    }
130
131    fn workdir(&self) -> Option<std::path::PathBuf> {
132        self.0.lock().ok().map(|g| g.workdir.clone())
133    }
134}
135
136impl Mode for MagitStashShowMode {
137    /// MG.23g: was `Option<HeaderlineRegistration>` — the per-buffer
138    /// state and the view registration join it now that `a` / `-` need
139    /// somewhere to read this buffer's workdir from.
140    type Guard = BufferStateGuard<StashShowState>;
141
142    fn id(&self) -> ModeId {
143        Self::mode_id()
144    }
145    fn kind(&self) -> ModeKind {
146        ModeKind::Major
147    }
148    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
149        None
150    }
151
152    fn options(&self) -> OptionOverrideSet {
153        lattice_config::overrides! {
154            lattice_config::ReadOnly = true,
155            lattice_config::NoFile = true,
156            lattice_config::Number = false,
157        }
158    }
159
160    /// MG.RO: `read-only-mode` is where the gate actually is.
161    ///
162    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
163    /// `read_only_edit_rejected`, which guards the insert-mode char path;
164    /// operators never reach it, because a `Document`'s grammar dispatch
165    /// applies its own edits and hands the host an already-applied
166    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
167    /// the buffer reported itself read-only — worse than not gating at all,
168    /// because it looks protected.
169    ///
170    /// `read-only-mode` carries the option AND the `invocation_runner`
171    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
172    /// letting motions, `:` and `/` through.
173    ///
174    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
175    /// mode is followed from the mode being ACTIVATED, and the majors are what
176    /// the host activates. Putting it on the shared minor looked right and was
177    /// verified not to fire.
178    fn implies(&self) -> &[lattice_mode::ModeId] {
179        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
180        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
181    }
182
183    fn required_capabilities(&self) -> CapabilitySet {
184        CapabilitySet::empty()
185    }
186    fn keymap(&self) -> Keymap {
187        Keymap::from_entries(magit_stash_show_keymap_entries())
188    }
189
190    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
191        Box::pin(async move {
192            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
193            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
194            let Some(store) = ctx.service::<BufferStoreHandle>() else {
195                return Ok(orphan());
196            };
197            let Some(handle) = store.handle_for(buffer_id) else {
198                return Ok(orphan());
199            };
200            // MR.3: the repository the trigger resolved for THIS
201            // buffer, not the one the editor was started in.
202            let workdir =
203                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
204
205            let index = store
206                .name_for(buffer_id)
207                .and_then(|name| parse_buffer_name(&name));
208
209            // MG.14: the index is in the buffer name, so the header is
210            // complete before `git stash show` runs. The message needs
211            // the git call and is filled in below.
212            let (hl, hl_registration) =
213                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
214                    Some((h, reg)) => (Some(h), Some(reg)),
215                    None => (None, None),
216                };
217            if let Some(idx) = index {
218                headerline::publish(&hl, headerline::stash_show_fields(idx, ""));
219            }
220
221            // MG.13: publish BEFORE the first `.await` — see the note
222            // in `magit_branch_mode::on_activate`.
223            let Some(states) = ctx.service::<StashShowStatesHandle>() else {
224                return Ok(orphan());
225            };
226            let state = states.publish(
227                buffer_id,
228                StashShowState {
229                    workdir: workdir.clone(),
230                    repo: crate::repo_scope::label_of_buffer(&store, buffer_id),
231                    index,
232                },
233            );
234            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
235                .with_headerline(hl_registration);
236            // MG.23g: without the view, `a` / `-` have nothing to ask
237            // about this buffer and refuse in it.
238            if let Some(views) = ctx.service::<crate::buffer_state::MagitViewsHandle>() {
239                views.publish(buffer_id, Arc::new(StashShowView(state.clone())));
240                guard = guard.with_views((*views).clone());
241            }
242
243            let wd = workdir.clone();
244            let (text, message) = tokio::task::spawn_blocking(move || match index {
245                Some(idx) => (run_stash_show(&wd, idx), run_stash_message(&wd, idx)),
246                None => ("No stash given.\n".to_string(), String::new()),
247            })
248            .await
249            .unwrap_or_default();
250            if let Some(idx) = index {
251                headerline::publish(&hl, headerline::stash_show_fields(idx, &message));
252            }
253
254            // `git stash show -p` is a plain unified diff, so the
255            // whole-buffer styler applies directly — the same reuse
256            // `magit-diff-mode` and `magit-revision-mode` make.
257            let spans = crate::highlight::diff_styled_spans(&text);
258            crate::buffer_io::replace_buffer_text(&handle, text).await;
259            if let Some(ph) = ctx.service::<lattice_mode::PendingSyntheticHighlights>() {
260                ph.store_and_wake(buffer_id, spans);
261            }
262
263            Ok(guard)
264        })
265    }
266}
267
268/// `"*magit:stash:<n>*"` → `n`. Rejects a non-numeric or empty index
269/// rather than defaulting to 0 — showing stash@{0}'s patch under a
270/// name claiming some other stash is worse than saying nothing.
271fn parse_buffer_name(name: &str) -> Option<usize> {
272    let parsed = crate::workdir::parse_magit_name(name)?;
273    (parsed.view == "stash").then_some(())?;
274    // MR.3b: the index is this view's `rest`. It shares the `stash` view
275    // word with the stash LIST (`*magit:stash:<repo>*`), which is why it
276    // could not keep the old shape: with the repository absent, a list
277    // buffer and a show buffer were the same string with a different
278    // second segment, and only the parser's `usize` check told them
279    // apart — a repository called `3` would have been a stash.
280    parsed.rest?.parse().ok()
281}
282
283/// This view's `rest` for stash `index` — the single place the `<CR>`
284/// handler and this mode's parser agree on the format.
285pub fn stash_view_rest(index: usize) -> String {
286    index.to_string()
287}
288
289fn run_stash_show(workdir: &std::path::Path, index: usize) -> String {
290    let spec = format!("stash@{{{index}}}");
291    std::process::Command::new("git")
292        .args(["stash", "show", "-p", &spec])
293        .current_dir(workdir)
294        .output()
295        .ok()
296        .filter(|o| o.status.success())
297        .and_then(|o| String::from_utf8(o.stdout).ok())
298        .filter(|s| !s.trim().is_empty())
299        // A stash whose patch is empty is a real outcome (a stash of
300        // untracked files only, shown without `-u`), and so is a bad
301        // index after a concurrent drop. Both read better as a
302        // sentence than as a blank buffer.
303        .unwrap_or_else(|| format!("No patch to show for {spec}.\n"))
304}
305
306/// The stash's own subject line, for the headerline. Separate from
307/// the patch so a failure in one does not blank the other.
308fn run_stash_message(workdir: &std::path::Path, index: usize) -> String {
309    let spec = format!("stash@{{{index}}}");
310    std::process::Command::new("git")
311        .args(["log", "-1", "--format=%s", &spec])
312        .current_dir(workdir)
313        .output()
314        .ok()
315        .filter(|o| o.status.success())
316        .and_then(|o| String::from_utf8(o.stdout).ok())
317        .map(|s| s.trim().to_string())
318        .unwrap_or_default()
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn parse_buffer_name_extracts_the_index() {
327        assert_eq!(parse_buffer_name("*magit:stash:lattice:0*"), Some(0));
328        assert_eq!(parse_buffer_name("*magit:stash:lattice:12*"), Some(12));
329    }
330
331    /// MR.3b: the stash LIST shares this view word
332    /// (`*magit:stash:<repo>*`), and it must not parse as a stash to
333    /// show. Before the repository took segment 2 the two forms were
334    /// told apart only by whether the segment happened to be numeric —
335    /// so a checkout called `3` would have opened stash@{3}.
336    #[test]
337    fn the_stash_list_is_not_a_stash_to_show() {
338        assert_eq!(parse_buffer_name("*magit:stash:lattice*"), None);
339        assert_eq!(parse_buffer_name("*magit:stash:3*"), None);
340    }
341
342    #[test]
343    fn parse_buffer_name_rejects_malformed_names() {
344        assert_eq!(parse_buffer_name("*magit:stash:lattice:*"), None);
345        assert_eq!(parse_buffer_name("*magit:stash:lattice:abc*"), None);
346        assert_eq!(parse_buffer_name("*magit:stash*"), None);
347        assert_eq!(parse_buffer_name("*magit:commit:a1b2c3d*"), None);
348    }
349
350    /// The name the `<CR>` handler builds must be the name this mode
351    /// parses. They live in different files; this is the seam that
352    /// would otherwise drift into a buffer that opens empty.
353    #[test]
354    fn the_name_builder_and_the_parser_agree() {
355        for idx in [0usize, 3, 42] {
356            let name =
357                crate::workdir::magit_buffer_name_with("stash", "lattice", &stash_view_rest(idx));
358            assert_eq!(parse_buffer_name(&name), Some(idx));
359        }
360    }
361}