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}