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