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}
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}