1use 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 headerline: Option<MagitHeaderlineHandle>,
49}
50
51pub type StashStatesHandle = Arc<BufferStates<StashState>>;
55
56fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<StashState>>> {
59 crate::buffer_state::state_for::<StashState>(ctx)
60}
61
62enum 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
89type StashOp = fn(&Repository, usize) -> lattice_vcs::Result<()>;
93
94fn 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
130struct StashView(Arc<Mutex<StashState>>);
132
133impl MagitView for StashView {
134 fn refresh(&self) -> Option<Effect> {
135 refresh(self.0.clone())
136 }
137
138 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 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 fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
199 vec![
200 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 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 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 ActionHandlerContribution {
237 action_name: "action:magit-stash-drop-execute",
238 handler: Arc::new(|ctx: &ActionContext<'_>| {
239 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 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 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 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 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 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 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
363fn 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 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
394fn 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 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
442fn 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
453pub 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
467pub 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
478fn 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 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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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}