1use fuzzy::StringMatchCandidate;
2
3use git::stash::StashEntry;
4use gpui::{
5 Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
6 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
7 SharedString, Styled, Subscription, Task, WeakEntity, Window, actions, rems,
8};
9use picker::{Picker, PickerDelegate};
10use project::git_store::{Repository, RepositoryEvent};
11use std::sync::Arc;
12use time::{OffsetDateTime, UtcOffset};
13use time_format;
14use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
15use util::ResultExt;
16use workspace::notifications::DetachAndPromptErr;
17use workspace::{ModalView, Workspace};
18
19use crate::commit_view::CommitView;
20use crate::stash_picker;
21
22actions!(
23 stash_picker,
24 [
25 /// Drop the selected stash entry.
26 DropStashItem,
27 /// Show the diff view of the selected stash entry.
28 ShowStashItem,
29 ]
30);
31
32pub fn open(
33 workspace: &mut Workspace,
34 _: &zed_actions::git::ViewStash,
35 window: &mut Window,
36 cx: &mut Context<Workspace>,
37) {
38 let repository = workspace.project().read(cx).active_repository(cx);
39 let weak_workspace = workspace.weak_handle();
40 workspace.toggle_modal(window, cx, |window, cx| {
41 StashList::new(repository, weak_workspace, rems(34.), window, cx)
42 })
43}
44
45pub fn create_embedded(
46 repository: Option<Entity<Repository>>,
47 workspace: WeakEntity<Workspace>,
48 width: Rems,
49 window: &mut Window,
50 cx: &mut Context<StashList>,
51) -> StashList {
52 StashList::new_embedded(repository, workspace, width, window, cx)
53}
54
55pub struct StashList {
56 width: Rems,
57 pub picker: Entity<Picker<StashListDelegate>>,
58 picker_focus_handle: FocusHandle,
59 _subscriptions: Vec<Subscription>,
60}
61
62impl StashList {
63 fn new(
64 repository: Option<Entity<Repository>>,
65 workspace: WeakEntity<Workspace>,
66 width: Rems,
67 window: &mut Window,
68 cx: &mut Context<Self>,
69 ) -> Self {
70 let mut this = Self::new_inner(repository, workspace, width, false, window, cx);
71 this._subscriptions
72 .push(cx.subscribe(&this.picker, |_, _, _, cx| {
73 cx.emit(DismissEvent);
74 }));
75 this
76 }
77
78 fn new_inner(
79 repository: Option<Entity<Repository>>,
80 workspace: WeakEntity<Workspace>,
81 width: Rems,
82 embedded: bool,
83 window: &mut Window,
84 cx: &mut Context<Self>,
85 ) -> Self {
86 let mut _subscriptions = Vec::new();
87 let stash_request = repository
88 .clone()
89 .map(|repository| repository.read_with(cx, |repo, _| repo.cached_stash()));
90
91 if let Some(repo) = repository.clone() {
92 _subscriptions.push(
93 cx.subscribe_in(&repo, window, |this, _, event, window, cx| {
94 if matches!(event, RepositoryEvent::StashEntriesChanged) {
95 let stash_entries = this.picker.read_with(cx, |picker, cx| {
96 picker
97 .delegate
98 .repo
99 .clone()
100 .map(|repo| repo.read(cx).cached_stash().entries.to_vec())
101 });
102 this.picker.update(cx, |this, cx| {
103 this.delegate.all_stash_entries = stash_entries;
104 this.refresh(window, cx);
105 });
106 }
107 }),
108 )
109 }
110
111 cx.spawn_in(window, async move |this, cx| {
112 let stash_entries = stash_request
113 .map(|git_stash| git_stash.entries.to_vec())
114 .unwrap_or_default();
115
116 this.update_in(cx, |this, window, cx| {
117 this.picker.update(cx, |picker, cx| {
118 picker.delegate.all_stash_entries = Some(stash_entries);
119 picker.refresh(window, cx);
120 })
121 })?;
122
123 anyhow::Ok(())
124 })
125 .detach_and_log_err(cx);
126
127 let delegate = StashListDelegate::new(repository, workspace, window, cx);
128 let picker = cx.new(|cx| {
129 Picker::uniform_list(delegate, window, cx)
130 .show_scrollbar(true)
131 .modal(!embedded)
132 });
133 let picker_focus_handle = picker.focus_handle(cx);
134 picker.update(cx, |picker, _| {
135 picker.delegate.focus_handle = picker_focus_handle.clone();
136 });
137
138 Self {
139 picker,
140 picker_focus_handle,
141 width,
142 _subscriptions,
143 }
144 }
145
146 fn new_embedded(
147 repository: Option<Entity<Repository>>,
148 workspace: WeakEntity<Workspace>,
149 width: Rems,
150 window: &mut Window,
151 cx: &mut Context<Self>,
152 ) -> Self {
153 let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
154 this._subscriptions
155 .push(cx.subscribe(&this.picker, |_, _, _, cx| {
156 cx.emit(DismissEvent);
157 }));
158 this
159 }
160
161 pub fn handle_drop_stash(
162 &mut self,
163 _: &DropStashItem,
164 window: &mut Window,
165 cx: &mut Context<Self>,
166 ) {
167 self.picker.update(cx, |picker, cx| {
168 picker
169 .delegate
170 .drop_stash_at(picker.delegate.selected_index(), window, cx);
171 });
172 cx.notify();
173 }
174
175 pub fn handle_show_stash(
176 &mut self,
177 _: &ShowStashItem,
178 window: &mut Window,
179 cx: &mut Context<Self>,
180 ) {
181 self.picker.update(cx, |picker, cx| {
182 picker
183 .delegate
184 .show_stash_at(picker.delegate.selected_index(), window, cx);
185 });
186
187 cx.emit(DismissEvent);
188 }
189
190 pub fn handle_modifiers_changed(
191 &mut self,
192 ev: &ModifiersChangedEvent,
193 _: &mut Window,
194 cx: &mut Context<Self>,
195 ) {
196 self.picker
197 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
198 }
199}
200
201impl ModalView for StashList {}
202impl EventEmitter<DismissEvent> for StashList {}
203impl Focusable for StashList {
204 fn focus_handle(&self, _: &App) -> FocusHandle {
205 self.picker_focus_handle.clone()
206 }
207}
208
209impl Render for StashList {
210 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
211 v_flex()
212 .key_context("StashList")
213 .w(self.width)
214 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
215 .on_action(cx.listener(Self::handle_drop_stash))
216 .on_action(cx.listener(Self::handle_show_stash))
217 .child(self.picker.clone())
218 }
219}
220
221#[derive(Debug, Clone)]
222struct StashEntryMatch {
223 entry: StashEntry,
224 positions: Vec<usize>,
225 formatted_timestamp: String,
226}
227
228pub struct StashListDelegate {
229 matches: Vec<StashEntryMatch>,
230 all_stash_entries: Option<Vec<StashEntry>>,
231 repo: Option<Entity<Repository>>,
232 workspace: WeakEntity<Workspace>,
233 selected_index: usize,
234 last_query: String,
235 modifiers: Modifiers,
236 focus_handle: FocusHandle,
237 timezone: UtcOffset,
238}
239
240impl StashListDelegate {
241 fn new(
242 repo: Option<Entity<Repository>>,
243 workspace: WeakEntity<Workspace>,
244 _window: &mut Window,
245 cx: &mut Context<StashList>,
246 ) -> Self {
247 let timezone = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
248
249 Self {
250 matches: vec![],
251 repo,
252 workspace,
253 all_stash_entries: None,
254 selected_index: 0,
255 last_query: Default::default(),
256 modifiers: Default::default(),
257 focus_handle: cx.focus_handle(),
258 timezone,
259 }
260 }
261
262 fn format_message(ix: usize, message: &String) -> String {
263 format!("#{}: {}", ix, message)
264 }
265
266 fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
267 let timestamp =
268 OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
269 time_format::format_localized_timestamp(
270 timestamp,
271 OffsetDateTime::now_utc(),
272 timezone,
273 time_format::TimestampFormat::EnhancedAbsolute,
274 )
275 }
276
277 fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
278 let Some(entry_match) = self.matches.get(ix) else {
279 return;
280 };
281 let stash_index = entry_match.entry.index;
282 let Some(repo) = self.repo.clone() else {
283 return;
284 };
285
286 cx.spawn(async move |_, cx| {
287 repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))
288 .await??;
289 Ok(())
290 })
291 .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
292 Some(e.to_string())
293 });
294 }
295
296 fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
297 let Some(entry_match) = self.matches.get(ix) else {
298 return;
299 };
300 let stash_sha = entry_match.entry.oid.to_string();
301 let stash_index = entry_match.entry.index;
302 let Some(repo) = self.repo.clone() else {
303 return;
304 };
305 CommitView::open(
306 stash_sha,
307 repo.downgrade(),
308 self.workspace.clone(),
309 Some(stash_index),
310 None,
311 None,
312 window,
313 cx,
314 );
315 }
316
317 fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
318 let Some(repo) = self.repo.clone() else {
319 return;
320 };
321
322 cx.spawn(async move |_, cx| {
323 repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))
324 .await?;
325 Ok(())
326 })
327 .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
328 Some(e.to_string())
329 });
330 cx.emit(DismissEvent);
331 }
332
333 fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
334 let Some(repo) = self.repo.clone() else {
335 return;
336 };
337
338 cx.spawn(async move |_, cx| {
339 repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))
340 .await?;
341 Ok(())
342 })
343 .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
344 Some(e.to_string())
345 });
346 cx.emit(DismissEvent);
347 }
348}
349
350impl PickerDelegate for StashListDelegate {
351 type ListItem = ListItem;
352
353 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
354 "Select a stash…".into()
355 }
356
357 fn match_count(&self) -> usize {
358 self.matches.len()
359 }
360
361 fn selected_index(&self) -> usize {
362 self.selected_index
363 }
364
365 fn set_selected_index(
366 &mut self,
367 ix: usize,
368 _window: &mut Window,
369 _: &mut Context<Picker<Self>>,
370 ) {
371 self.selected_index = ix;
372 }
373
374 fn update_matches(
375 &mut self,
376 query: String,
377 window: &mut Window,
378 cx: &mut Context<Picker<Self>>,
379 ) -> Task<()> {
380 let Some(all_stash_entries) = self.all_stash_entries.clone() else {
381 return Task::ready(());
382 };
383
384 let timezone = self.timezone;
385
386 cx.spawn_in(window, async move |picker, cx| {
387 let matches: Vec<StashEntryMatch> = if query.is_empty() {
388 all_stash_entries
389 .into_iter()
390 .map(|entry| {
391 let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
392
393 StashEntryMatch {
394 entry,
395 positions: Vec::new(),
396 formatted_timestamp,
397 }
398 })
399 .collect()
400 } else {
401 let candidates = all_stash_entries
402 .iter()
403 .enumerate()
404 .map(|(ix, entry)| {
405 StringMatchCandidate::new(
406 ix,
407 &Self::format_message(entry.index, &entry.message),
408 )
409 })
410 .collect::<Vec<StringMatchCandidate>>();
411 fuzzy::match_strings(
412 &candidates,
413 &query,
414 false,
415 true,
416 10000,
417 &Default::default(),
418 cx.background_executor().clone(),
419 )
420 .await
421 .into_iter()
422 .map(|candidate| {
423 let entry = all_stash_entries[candidate.candidate_id].clone();
424 let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
425
426 StashEntryMatch {
427 entry,
428 positions: candidate.positions,
429 formatted_timestamp,
430 }
431 })
432 .collect()
433 };
434
435 picker
436 .update(cx, |picker, _| {
437 let delegate = &mut picker.delegate;
438 delegate.matches = matches;
439 if delegate.matches.is_empty() {
440 delegate.selected_index = 0;
441 } else {
442 delegate.selected_index =
443 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
444 }
445 delegate.last_query = query;
446 })
447 .log_err();
448 })
449 }
450
451 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
452 let Some(entry_match) = self.matches.get(self.selected_index()) else {
453 return;
454 };
455 let stash_index = entry_match.entry.index;
456 if secondary {
457 self.pop_stash(stash_index, window, cx);
458 } else {
459 self.apply_stash(stash_index, window, cx);
460 }
461 }
462
463 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
464 cx.emit(DismissEvent);
465 }
466
467 fn render_match(
468 &self,
469 ix: usize,
470 selected: bool,
471 _window: &mut Window,
472 cx: &mut Context<Picker<Self>>,
473 ) -> Option<Self::ListItem> {
474 let entry_match = &self.matches[ix];
475
476 let stash_message =
477 Self::format_message(entry_match.entry.index, &entry_match.entry.message);
478 let positions = entry_match.positions.clone();
479 let stash_label = HighlightedLabel::new(stash_message, positions)
480 .truncate()
481 .into_any_element();
482
483 let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
484 let branch_info = h_flex()
485 .gap_1p5()
486 .w_full()
487 .child(
488 Label::new(branch_name)
489 .truncate()
490 .color(Color::Muted)
491 .size(LabelSize::Small),
492 )
493 .child(
494 Label::new("•")
495 .alpha(0.5)
496 .color(Color::Muted)
497 .size(LabelSize::Small),
498 )
499 .child(
500 Label::new(entry_match.formatted_timestamp.clone())
501 .color(Color::Muted)
502 .size(LabelSize::Small),
503 );
504
505 let view_button = {
506 let focus_handle = self.focus_handle.clone();
507 IconButton::new(("view-stash", ix), IconName::Eye)
508 .icon_size(IconSize::Small)
509 .tooltip(move |_, cx| {
510 Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx)
511 })
512 .on_click(cx.listener(move |this, _, window, cx| {
513 this.delegate.show_stash_at(ix, window, cx);
514 }))
515 };
516
517 let pop_button = {
518 let focus_handle = self.focus_handle.clone();
519 IconButton::new(("pop-stash", ix), IconName::MaximizeAlt)
520 .icon_size(IconSize::Small)
521 .tooltip(move |_, cx| {
522 Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx)
523 })
524 .on_click(|_, window, cx| {
525 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
526 })
527 };
528
529 let drop_button = {
530 let focus_handle = self.focus_handle.clone();
531 IconButton::new(("drop-stash", ix), IconName::Trash)
532 .icon_size(IconSize::Small)
533 .tooltip(move |_, cx| {
534 Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx)
535 })
536 .on_click(cx.listener(move |this, _, window, cx| {
537 this.delegate.drop_stash_at(ix, window, cx);
538 }))
539 };
540
541 Some(
542 ListItem::new(format!("stash-{ix}"))
543 .inset(true)
544 .spacing(ListItemSpacing::Sparse)
545 .toggle_state(selected)
546 .child(
547 h_flex()
548 .w_full()
549 .gap_2p5()
550 .child(
551 Icon::new(IconName::BoxOpen)
552 .size(IconSize::Small)
553 .color(Color::Muted),
554 )
555 .child(div().w_full().child(stash_label).child(branch_info)),
556 )
557 .end_slot(
558 h_flex()
559 .gap_0p5()
560 .child(view_button)
561 .child(pop_button)
562 .child(drop_button),
563 )
564 .show_end_slot_on_hover(),
565 )
566 }
567
568 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
569 Some("No stashes found".into())
570 }
571
572 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
573 if self.matches.is_empty() {
574 return None;
575 }
576
577 let focus_handle = self.focus_handle.clone();
578
579 Some(
580 h_flex()
581 .w_full()
582 .p_1p5()
583 .gap_0p5()
584 .justify_end()
585 .flex_wrap()
586 .border_t_1()
587 .border_color(cx.theme().colors().border_variant)
588 .child(
589 Button::new("drop-stash", "Drop")
590 .key_binding(
591 KeyBinding::for_action_in(
592 &stash_picker::DropStashItem,
593 &focus_handle,
594 cx,
595 )
596 .map(|kb| kb.size(rems_from_px(12.))),
597 )
598 .on_click(|_, window, cx| {
599 window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
600 }),
601 )
602 .child(
603 Button::new("view-stash", "View")
604 .key_binding(
605 KeyBinding::for_action_in(
606 &stash_picker::ShowStashItem,
607 &focus_handle,
608 cx,
609 )
610 .map(|kb| kb.size(rems_from_px(12.))),
611 )
612 .on_click(cx.listener(move |picker, _, window, cx| {
613 cx.stop_propagation();
614 let selected_ix = picker.delegate.selected_index();
615 picker.delegate.show_stash_at(selected_ix, window, cx);
616 })),
617 )
618 .child(
619 Button::new("pop-stash", "Pop")
620 .key_binding(
621 KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
622 .map(|kb| kb.size(rems_from_px(12.))),
623 )
624 .on_click(|_, window, cx| {
625 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
626 }),
627 )
628 .child(
629 Button::new("apply-stash", "Apply")
630 .key_binding(
631 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
632 .map(|kb| kb.size(rems_from_px(12.))),
633 )
634 .on_click(|_, window, cx| {
635 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
636 }),
637 )
638 .into_any(),
639 )
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use std::str::FromStr;
646
647 use super::*;
648 use git::{Oid, stash::StashEntry};
649 use gpui::{TestAppContext, VisualTestContext, rems};
650 use picker::PickerDelegate;
651 use project::{FakeFs, Project};
652 use settings::SettingsStore;
653 use workspace::MultiWorkspace;
654
655 fn init_test(cx: &mut TestAppContext) {
656 cx.update(|cx| {
657 let settings_store = SettingsStore::test(cx);
658 cx.set_global(settings_store);
659
660 theme_settings::init(theme::LoadThemes::JustBase, cx);
661 editor::init(cx);
662 })
663 }
664
665 /// Convenience function for creating `StashEntry` instances during tests.
666 /// Feel free to update in case you need to provide extra fields.
667 fn stash_entry(index: usize, message: &str, branch: Option<&str>) -> StashEntry {
668 let oid = Oid::from_str(&format!("{:0>40x}", index)).unwrap();
669
670 StashEntry {
671 index,
672 oid,
673 message: message.to_string(),
674 branch: branch.map(Into::into),
675 timestamp: 1000 - index as i64,
676 }
677 }
678
679 #[gpui::test]
680 async fn test_show_stash_dismisses(cx: &mut TestAppContext) {
681 init_test(cx);
682
683 let fs = FakeFs::new(cx.executor());
684 let project = Project::test(fs, [], cx).await;
685 let multi_workspace =
686 cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
687 let cx = &mut VisualTestContext::from_window(*multi_workspace, cx);
688 let workspace = multi_workspace
689 .update(cx, |workspace, _, _| workspace.workspace().clone())
690 .unwrap();
691 let stash_entries = vec![
692 stash_entry(0, "stash #0", Some("main")),
693 stash_entry(1, "stash #1", Some("develop")),
694 ];
695
696 let stash_list = workspace.update_in(cx, |workspace, window, cx| {
697 let weak_workspace = workspace.weak_handle();
698
699 workspace.toggle_modal(window, cx, move |window, cx| {
700 StashList::new(None, weak_workspace, rems(34.), window, cx)
701 });
702
703 assert!(workspace.active_modal::<StashList>(cx).is_some());
704 workspace.active_modal::<StashList>(cx).unwrap()
705 });
706
707 cx.run_until_parked();
708 stash_list.update(cx, |stash_list, cx| {
709 stash_list.picker.update(cx, |picker, _| {
710 picker.delegate.all_stash_entries = Some(stash_entries);
711 });
712 });
713
714 stash_list
715 .update_in(cx, |stash_list, window, cx| {
716 stash_list.picker.update(cx, |picker, cx| {
717 picker.delegate.update_matches(String::new(), window, cx)
718 })
719 })
720 .await;
721
722 cx.run_until_parked();
723 stash_list.update_in(cx, |stash_list, window, cx| {
724 assert_eq!(stash_list.picker.read(cx).delegate.matches.len(), 2);
725 stash_list.handle_show_stash(&Default::default(), window, cx);
726 });
727
728 workspace.update(cx, |workspace, cx| {
729 assert!(workspace.active_modal::<StashList>(cx).is_none());
730 });
731 }
732}