stash_picker.rs

  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    formatted_absolute_timestamp: String,
227}
228
229pub struct StashListDelegate {
230    matches: Vec<StashEntryMatch>,
231    all_stash_entries: Option<Vec<StashEntry>>,
232    repo: Option<Entity<Repository>>,
233    workspace: WeakEntity<Workspace>,
234    selected_index: usize,
235    last_query: String,
236    modifiers: Modifiers,
237    focus_handle: FocusHandle,
238    timezone: UtcOffset,
239}
240
241impl StashListDelegate {
242    fn new(
243        repo: Option<Entity<Repository>>,
244        workspace: WeakEntity<Workspace>,
245        _window: &mut Window,
246        cx: &mut Context<StashList>,
247    ) -> Self {
248        let timezone = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
249
250        Self {
251            matches: vec![],
252            repo,
253            workspace,
254            all_stash_entries: None,
255            selected_index: 0,
256            last_query: Default::default(),
257            modifiers: Default::default(),
258            focus_handle: cx.focus_handle(),
259            timezone,
260        }
261    }
262
263    fn format_message(ix: usize, message: &String) -> String {
264        format!("#{}: {}", ix, message)
265    }
266
267    fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
268        let timestamp =
269            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
270        time_format::format_localized_timestamp(
271            timestamp,
272            OffsetDateTime::now_utc(),
273            timezone,
274            time_format::TimestampFormat::Relative,
275        )
276    }
277
278    fn format_absolute_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
279        let timestamp =
280            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
281        time_format::format_localized_timestamp(
282            timestamp,
283            OffsetDateTime::now_utc(),
284            timezone,
285            time_format::TimestampFormat::EnhancedAbsolute,
286        )
287    }
288
289    fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
290        let Some(entry_match) = self.matches.get(ix) else {
291            return;
292        };
293        let stash_index = entry_match.entry.index;
294        let Some(repo) = self.repo.clone() else {
295            return;
296        };
297
298        cx.spawn(async move |_, cx| {
299            repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))
300                .await??;
301            Ok(())
302        })
303        .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
304            Some(e.to_string())
305        });
306    }
307
308    fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
309        let Some(entry_match) = self.matches.get(ix) else {
310            return;
311        };
312        let stash_sha = entry_match.entry.oid.to_string();
313        let stash_index = entry_match.entry.index;
314        let Some(repo) = self.repo.clone() else {
315            return;
316        };
317        CommitView::open(
318            stash_sha,
319            repo.downgrade(),
320            self.workspace.clone(),
321            Some(stash_index),
322            None,
323            window,
324            cx,
325        );
326    }
327
328    fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
329        let Some(repo) = self.repo.clone() else {
330            return;
331        };
332
333        cx.spawn(async move |_, cx| {
334            repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))
335                .await?;
336            Ok(())
337        })
338        .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
339            Some(e.to_string())
340        });
341        cx.emit(DismissEvent);
342    }
343
344    fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
345        let Some(repo) = self.repo.clone() else {
346            return;
347        };
348
349        cx.spawn(async move |_, cx| {
350            repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))
351                .await?;
352            Ok(())
353        })
354        .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
355            Some(e.to_string())
356        });
357        cx.emit(DismissEvent);
358    }
359}
360
361impl PickerDelegate for StashListDelegate {
362    type ListItem = ListItem;
363
364    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
365        "Select a stash…".into()
366    }
367
368    fn match_count(&self) -> usize {
369        self.matches.len()
370    }
371
372    fn selected_index(&self) -> usize {
373        self.selected_index
374    }
375
376    fn set_selected_index(
377        &mut self,
378        ix: usize,
379        _window: &mut Window,
380        _: &mut Context<Picker<Self>>,
381    ) {
382        self.selected_index = ix;
383    }
384
385    fn update_matches(
386        &mut self,
387        query: String,
388        window: &mut Window,
389        cx: &mut Context<Picker<Self>>,
390    ) -> Task<()> {
391        let Some(all_stash_entries) = self.all_stash_entries.clone() else {
392            return Task::ready(());
393        };
394
395        let timezone = self.timezone;
396
397        cx.spawn_in(window, async move |picker, cx| {
398            let matches: Vec<StashEntryMatch> = if query.is_empty() {
399                all_stash_entries
400                    .into_iter()
401                    .map(|entry| {
402                        let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
403                        let formatted_absolute_timestamp =
404                            Self::format_absolute_timestamp(entry.timestamp, timezone);
405
406                        StashEntryMatch {
407                            entry,
408                            positions: Vec::new(),
409                            formatted_timestamp,
410                            formatted_absolute_timestamp,
411                        }
412                    })
413                    .collect()
414            } else {
415                let candidates = all_stash_entries
416                    .iter()
417                    .enumerate()
418                    .map(|(ix, entry)| {
419                        StringMatchCandidate::new(
420                            ix,
421                            &Self::format_message(entry.index, &entry.message),
422                        )
423                    })
424                    .collect::<Vec<StringMatchCandidate>>();
425                fuzzy::match_strings(
426                    &candidates,
427                    &query,
428                    false,
429                    true,
430                    10000,
431                    &Default::default(),
432                    cx.background_executor().clone(),
433                )
434                .await
435                .into_iter()
436                .map(|candidate| {
437                    let entry = all_stash_entries[candidate.candidate_id].clone();
438                    let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
439                    let formatted_absolute_timestamp =
440                        Self::format_absolute_timestamp(entry.timestamp, timezone);
441
442                    StashEntryMatch {
443                        entry,
444                        positions: candidate.positions,
445                        formatted_timestamp,
446                        formatted_absolute_timestamp,
447                    }
448                })
449                .collect()
450            };
451
452            picker
453                .update(cx, |picker, _| {
454                    let delegate = &mut picker.delegate;
455                    delegate.matches = matches;
456                    if delegate.matches.is_empty() {
457                        delegate.selected_index = 0;
458                    } else {
459                        delegate.selected_index =
460                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
461                    }
462                    delegate.last_query = query;
463                })
464                .log_err();
465        })
466    }
467
468    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
469        let Some(entry_match) = self.matches.get(self.selected_index()) else {
470            return;
471        };
472        let stash_index = entry_match.entry.index;
473        if secondary {
474            self.pop_stash(stash_index, window, cx);
475        } else {
476            self.apply_stash(stash_index, window, cx);
477        }
478    }
479
480    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
481        cx.emit(DismissEvent);
482    }
483
484    fn render_match(
485        &self,
486        ix: usize,
487        selected: bool,
488        _window: &mut Window,
489        cx: &mut Context<Picker<Self>>,
490    ) -> Option<Self::ListItem> {
491        let entry_match = &self.matches[ix];
492
493        let stash_message =
494            Self::format_message(entry_match.entry.index, &entry_match.entry.message);
495        let positions = entry_match.positions.clone();
496        let stash_label = HighlightedLabel::new(stash_message, positions)
497            .truncate()
498            .into_any_element();
499
500        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
501        let branch_info = h_flex()
502            .gap_1p5()
503            .w_full()
504            .child(
505                Label::new(branch_name)
506                    .truncate()
507                    .color(Color::Muted)
508                    .size(LabelSize::Small),
509            )
510            .child(
511                Label::new("")
512                    .alpha(0.5)
513                    .color(Color::Muted)
514                    .size(LabelSize::Small),
515            )
516            .child(
517                Label::new(entry_match.formatted_timestamp.clone())
518                    .color(Color::Muted)
519                    .size(LabelSize::Small),
520            );
521
522        let view_button = {
523            let focus_handle = self.focus_handle.clone();
524            IconButton::new(("view-stash", ix), IconName::Eye)
525                .icon_size(IconSize::Small)
526                .tooltip(move |_, cx| {
527                    Tooltip::for_action_in("View Stash", &ShowStashItem, &focus_handle, cx)
528                })
529                .on_click(cx.listener(move |this, _, window, cx| {
530                    this.delegate.show_stash_at(ix, window, cx);
531                }))
532        };
533
534        let pop_button = {
535            let focus_handle = self.focus_handle.clone();
536            IconButton::new(("pop-stash", ix), IconName::MaximizeAlt)
537                .icon_size(IconSize::Small)
538                .tooltip(move |_, cx| {
539                    Tooltip::for_action_in("Pop Stash", &menu::SecondaryConfirm, &focus_handle, cx)
540                })
541                .on_click(|_, window, cx| {
542                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx);
543                })
544        };
545
546        let drop_button = {
547            let focus_handle = self.focus_handle.clone();
548            IconButton::new(("drop-stash", ix), IconName::Trash)
549                .icon_size(IconSize::Small)
550                .tooltip(move |_, cx| {
551                    Tooltip::for_action_in("Drop Stash", &DropStashItem, &focus_handle, cx)
552                })
553                .on_click(cx.listener(move |this, _, window, cx| {
554                    this.delegate.drop_stash_at(ix, window, cx);
555                }))
556        };
557
558        Some(
559            ListItem::new(format!("stash-{ix}"))
560                .inset(true)
561                .spacing(ListItemSpacing::Sparse)
562                .toggle_state(selected)
563                .child(
564                    h_flex()
565                        .min_w_0()
566                        .w_full()
567                        .gap_2p5()
568                        .child(
569                            Icon::new(IconName::BoxOpen)
570                                .size(IconSize::Small)
571                                .color(Color::Muted),
572                        )
573                        .child(
574                            v_flex()
575                                .id(format!("stash-tooltip-{ix}"))
576                                .min_w_0()
577                                .w_full()
578                                .child(stash_label)
579                                .child(branch_info)
580                                .tooltip({
581                                    let stash_message = Self::format_message(
582                                        entry_match.entry.index,
583                                        &entry_match.entry.message,
584                                    );
585                                    let absolute_timestamp =
586                                        entry_match.formatted_absolute_timestamp.clone();
587
588                                    Tooltip::element(move |_, _| {
589                                        v_flex()
590                                            .child(Label::new(stash_message.clone()))
591                                            .child(
592                                                Label::new(absolute_timestamp.clone())
593                                                    .size(LabelSize::Small)
594                                                    .color(Color::Muted),
595                                            )
596                                            .into_any_element()
597                                    })
598                                }),
599                        ),
600                )
601                .end_slot(
602                    h_flex()
603                        .gap_0p5()
604                        .child(view_button)
605                        .child(pop_button)
606                        .child(drop_button),
607                )
608                .show_end_slot_on_hover(),
609        )
610    }
611
612    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
613        Some("No stashes found".into())
614    }
615
616    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
617        if self.matches.is_empty() {
618            return None;
619        }
620
621        let focus_handle = self.focus_handle.clone();
622
623        Some(
624            h_flex()
625                .w_full()
626                .p_1p5()
627                .gap_0p5()
628                .justify_end()
629                .flex_wrap()
630                .border_t_1()
631                .border_color(cx.theme().colors().border_variant)
632                .child(
633                    Button::new("drop-stash", "Drop")
634                        .key_binding(
635                            KeyBinding::for_action_in(
636                                &stash_picker::DropStashItem,
637                                &focus_handle,
638                                cx,
639                            )
640                            .map(|kb| kb.size(rems_from_px(12.))),
641                        )
642                        .on_click(|_, window, cx| {
643                            window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
644                        }),
645                )
646                .child(
647                    Button::new("view-stash", "View")
648                        .key_binding(
649                            KeyBinding::for_action_in(
650                                &stash_picker::ShowStashItem,
651                                &focus_handle,
652                                cx,
653                            )
654                            .map(|kb| kb.size(rems_from_px(12.))),
655                        )
656                        .on_click(cx.listener(move |picker, _, window, cx| {
657                            cx.stop_propagation();
658                            let selected_ix = picker.delegate.selected_index();
659                            picker.delegate.show_stash_at(selected_ix, window, cx);
660                        })),
661                )
662                .child(
663                    Button::new("pop-stash", "Pop")
664                        .key_binding(
665                            KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
666                                .map(|kb| kb.size(rems_from_px(12.))),
667                        )
668                        .on_click(|_, window, cx| {
669                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
670                        }),
671                )
672                .child(
673                    Button::new("apply-stash", "Apply")
674                        .key_binding(
675                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
676                                .map(|kb| kb.size(rems_from_px(12.))),
677                        )
678                        .on_click(|_, window, cx| {
679                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
680                        }),
681                )
682                .into_any(),
683        )
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use std::str::FromStr;
690
691    use super::*;
692    use git::{Oid, stash::StashEntry};
693    use gpui::{TestAppContext, VisualTestContext, rems};
694    use picker::PickerDelegate;
695    use project::{FakeFs, Project};
696    use settings::SettingsStore;
697    use workspace::MultiWorkspace;
698
699    fn init_test(cx: &mut TestAppContext) {
700        cx.update(|cx| {
701            let settings_store = SettingsStore::test(cx);
702            cx.set_global(settings_store);
703
704            theme_settings::init(theme::LoadThemes::JustBase, cx);
705            editor::init(cx);
706        })
707    }
708
709    /// Convenience function for creating `StashEntry` instances during tests.
710    /// Feel free to update in case you need to provide extra fields.
711    fn stash_entry(index: usize, message: &str, branch: Option<&str>) -> StashEntry {
712        let oid = Oid::from_str(&format!("{:0>40x}", index)).unwrap();
713
714        StashEntry {
715            index,
716            oid,
717            message: message.to_string(),
718            branch: branch.map(Into::into),
719            timestamp: 1000 - index as i64,
720        }
721    }
722
723    #[gpui::test]
724    async fn test_show_stash_dismisses(cx: &mut TestAppContext) {
725        init_test(cx);
726
727        let fs = FakeFs::new(cx.executor());
728        let project = Project::test(fs, [], cx).await;
729        let multi_workspace =
730            cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
731        let cx = &mut VisualTestContext::from_window(*multi_workspace, cx);
732        let workspace = multi_workspace
733            .update(cx, |workspace, _, _| workspace.workspace().clone())
734            .unwrap();
735        let stash_entries = vec![
736            stash_entry(0, "stash #0", Some("main")),
737            stash_entry(1, "stash #1", Some("develop")),
738        ];
739
740        let stash_list = workspace.update_in(cx, |workspace, window, cx| {
741            let weak_workspace = workspace.weak_handle();
742
743            workspace.toggle_modal(window, cx, move |window, cx| {
744                StashList::new(None, weak_workspace, rems(34.), window, cx)
745            });
746
747            assert!(workspace.active_modal::<StashList>(cx).is_some());
748            workspace.active_modal::<StashList>(cx).unwrap()
749        });
750
751        cx.run_until_parked();
752        stash_list.update(cx, |stash_list, cx| {
753            stash_list.picker.update(cx, |picker, _| {
754                picker.delegate.all_stash_entries = Some(stash_entries);
755            });
756        });
757
758        stash_list
759            .update_in(cx, |stash_list, window, cx| {
760                stash_list.picker.update(cx, |picker, cx| {
761                    picker.delegate.update_matches(String::new(), window, cx)
762                })
763            })
764            .await;
765
766        cx.run_until_parked();
767        stash_list.update_in(cx, |stash_list, window, cx| {
768            assert_eq!(stash_list.picker.read(cx).delegate.matches.len(), 2);
769            stash_list.handle_show_stash(&Default::default(), window, cx);
770        });
771
772        workspace.update(cx, |workspace, cx| {
773            assert!(workspace.active_modal::<StashList>(cx).is_none());
774        });
775    }
776}