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}