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        cx.notify();
187    }
188
189    pub fn handle_modifiers_changed(
190        &mut self,
191        ev: &ModifiersChangedEvent,
192        _: &mut Window,
193        cx: &mut Context<Self>,
194    ) {
195        self.picker
196            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
197    }
198}
199
200impl ModalView for StashList {}
201impl EventEmitter<DismissEvent> for StashList {}
202impl Focusable for StashList {
203    fn focus_handle(&self, _: &App) -> FocusHandle {
204        self.picker_focus_handle.clone()
205    }
206}
207
208impl Render for StashList {
209    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
210        v_flex()
211            .key_context("StashList")
212            .w(self.width)
213            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
214            .on_action(cx.listener(Self::handle_drop_stash))
215            .on_action(cx.listener(Self::handle_show_stash))
216            .child(self.picker.clone())
217    }
218}
219
220#[derive(Debug, Clone)]
221struct StashEntryMatch {
222    entry: StashEntry,
223    positions: Vec<usize>,
224    formatted_timestamp: String,
225}
226
227pub struct StashListDelegate {
228    matches: Vec<StashEntryMatch>,
229    all_stash_entries: Option<Vec<StashEntry>>,
230    repo: Option<Entity<Repository>>,
231    workspace: WeakEntity<Workspace>,
232    selected_index: usize,
233    last_query: String,
234    modifiers: Modifiers,
235    focus_handle: FocusHandle,
236    timezone: UtcOffset,
237}
238
239impl StashListDelegate {
240    fn new(
241        repo: Option<Entity<Repository>>,
242        workspace: WeakEntity<Workspace>,
243        _window: &mut Window,
244        cx: &mut Context<StashList>,
245    ) -> Self {
246        let timezone = UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC);
247
248        Self {
249            matches: vec![],
250            repo,
251            workspace,
252            all_stash_entries: None,
253            selected_index: 0,
254            last_query: Default::default(),
255            modifiers: Default::default(),
256            focus_handle: cx.focus_handle(),
257            timezone,
258        }
259    }
260
261    fn format_message(ix: usize, message: &String) -> String {
262        format!("#{}: {}", ix, message)
263    }
264
265    fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
266        let timestamp =
267            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
268        time_format::format_localized_timestamp(
269            timestamp,
270            OffsetDateTime::now_utc(),
271            timezone,
272            time_format::TimestampFormat::EnhancedAbsolute,
273        )
274    }
275
276    fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
277        let Some(entry_match) = self.matches.get(ix) else {
278            return;
279        };
280        let stash_index = entry_match.entry.index;
281        let Some(repo) = self.repo.clone() else {
282            return;
283        };
284
285        cx.spawn(async move |_, cx| {
286            repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))
287                .await??;
288            Ok(())
289        })
290        .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
291            Some(e.to_string())
292        });
293    }
294
295    fn show_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
296        let Some(entry_match) = self.matches.get(ix) else {
297            return;
298        };
299        let stash_sha = entry_match.entry.oid.to_string();
300        let stash_index = entry_match.entry.index;
301        let Some(repo) = self.repo.clone() else {
302            return;
303        };
304        CommitView::open(
305            stash_sha,
306            repo.downgrade(),
307            self.workspace.clone(),
308            Some(stash_index),
309            None,
310            window,
311            cx,
312        );
313    }
314
315    fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
316        let Some(repo) = self.repo.clone() else {
317            return;
318        };
319
320        cx.spawn(async move |_, cx| {
321            repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))
322                .await?;
323            Ok(())
324        })
325        .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
326            Some(e.to_string())
327        });
328        cx.emit(DismissEvent);
329    }
330
331    fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
332        let Some(repo) = self.repo.clone() else {
333            return;
334        };
335
336        cx.spawn(async move |_, cx| {
337            repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))
338                .await?;
339            Ok(())
340        })
341        .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
342            Some(e.to_string())
343        });
344        cx.emit(DismissEvent);
345    }
346}
347
348impl PickerDelegate for StashListDelegate {
349    type ListItem = ListItem;
350
351    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
352        "Select a stash…".into()
353    }
354
355    fn match_count(&self) -> usize {
356        self.matches.len()
357    }
358
359    fn selected_index(&self) -> usize {
360        self.selected_index
361    }
362
363    fn set_selected_index(
364        &mut self,
365        ix: usize,
366        _window: &mut Window,
367        _: &mut Context<Picker<Self>>,
368    ) {
369        self.selected_index = ix;
370    }
371
372    fn update_matches(
373        &mut self,
374        query: String,
375        window: &mut Window,
376        cx: &mut Context<Picker<Self>>,
377    ) -> Task<()> {
378        let Some(all_stash_entries) = self.all_stash_entries.clone() else {
379            return Task::ready(());
380        };
381
382        let timezone = self.timezone;
383
384        cx.spawn_in(window, async move |picker, cx| {
385            let matches: Vec<StashEntryMatch> = if query.is_empty() {
386                all_stash_entries
387                    .into_iter()
388                    .map(|entry| {
389                        let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
390
391                        StashEntryMatch {
392                            entry,
393                            positions: Vec::new(),
394                            formatted_timestamp,
395                        }
396                    })
397                    .collect()
398            } else {
399                let candidates = all_stash_entries
400                    .iter()
401                    .enumerate()
402                    .map(|(ix, entry)| {
403                        StringMatchCandidate::new(
404                            ix,
405                            &Self::format_message(entry.index, &entry.message),
406                        )
407                    })
408                    .collect::<Vec<StringMatchCandidate>>();
409                fuzzy::match_strings(
410                    &candidates,
411                    &query,
412                    false,
413                    true,
414                    10000,
415                    &Default::default(),
416                    cx.background_executor().clone(),
417                )
418                .await
419                .into_iter()
420                .map(|candidate| {
421                    let entry = all_stash_entries[candidate.candidate_id].clone();
422                    let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
423
424                    StashEntryMatch {
425                        entry,
426                        positions: candidate.positions,
427                        formatted_timestamp,
428                    }
429                })
430                .collect()
431            };
432
433            picker
434                .update(cx, |picker, _| {
435                    let delegate = &mut picker.delegate;
436                    delegate.matches = matches;
437                    if delegate.matches.is_empty() {
438                        delegate.selected_index = 0;
439                    } else {
440                        delegate.selected_index =
441                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
442                    }
443                    delegate.last_query = query;
444                })
445                .log_err();
446        })
447    }
448
449    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
450        let Some(entry_match) = self.matches.get(self.selected_index()) else {
451            return;
452        };
453        let stash_index = entry_match.entry.index;
454        if secondary {
455            self.pop_stash(stash_index, window, cx);
456        } else {
457            self.apply_stash(stash_index, window, cx);
458        }
459    }
460
461    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
462        cx.emit(DismissEvent);
463    }
464
465    fn render_match(
466        &self,
467        ix: usize,
468        selected: bool,
469        _window: &mut Window,
470        _cx: &mut Context<Picker<Self>>,
471    ) -> Option<Self::ListItem> {
472        let entry_match = &self.matches[ix];
473
474        let stash_message =
475            Self::format_message(entry_match.entry.index, &entry_match.entry.message);
476        let positions = entry_match.positions.clone();
477        let stash_label = HighlightedLabel::new(stash_message, positions)
478            .truncate()
479            .into_any_element();
480
481        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
482        let branch_info = h_flex()
483            .gap_1p5()
484            .w_full()
485            .child(
486                Label::new(branch_name)
487                    .truncate()
488                    .color(Color::Muted)
489                    .size(LabelSize::Small),
490            )
491            .child(
492                Label::new("")
493                    .alpha(0.5)
494                    .color(Color::Muted)
495                    .size(LabelSize::Small),
496            )
497            .child(
498                Label::new(entry_match.formatted_timestamp.clone())
499                    .color(Color::Muted)
500                    .size(LabelSize::Small),
501            );
502
503        Some(
504            ListItem::new(format!("stash-{ix}"))
505                .inset(true)
506                .spacing(ListItemSpacing::Sparse)
507                .toggle_state(selected)
508                .child(v_flex().w_full().child(stash_label).child(branch_info))
509                .tooltip(Tooltip::text(format!(
510                    "stash@{{{}}}",
511                    entry_match.entry.index
512                ))),
513        )
514    }
515
516    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
517        Some("No stashes found".into())
518    }
519
520    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
521        let focus_handle = self.focus_handle.clone();
522
523        Some(
524            h_flex()
525                .w_full()
526                .p_1p5()
527                .gap_0p5()
528                .justify_end()
529                .border_t_1()
530                .border_color(cx.theme().colors().border_variant)
531                .child(
532                    Button::new("drop-stash", "Drop")
533                        .key_binding(
534                            KeyBinding::for_action_in(
535                                &stash_picker::DropStashItem,
536                                &focus_handle,
537                                cx,
538                            )
539                            .map(|kb| kb.size(rems_from_px(12.))),
540                        )
541                        .on_click(|_, window, cx| {
542                            window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
543                        }),
544                )
545                .child(
546                    Button::new("view-stash", "View")
547                        .key_binding(
548                            KeyBinding::for_action_in(
549                                &stash_picker::ShowStashItem,
550                                &focus_handle,
551                                cx,
552                            )
553                            .map(|kb| kb.size(rems_from_px(12.))),
554                        )
555                        .on_click(cx.listener(move |picker, _, window, cx| {
556                            cx.stop_propagation();
557                            let selected_ix = picker.delegate.selected_index();
558                            picker.delegate.show_stash_at(selected_ix, window, cx);
559                        })),
560                )
561                .child(
562                    Button::new("pop-stash", "Pop")
563                        .key_binding(
564                            KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
565                                .map(|kb| kb.size(rems_from_px(12.))),
566                        )
567                        .on_click(|_, window, cx| {
568                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
569                        }),
570                )
571                .child(
572                    Button::new("apply-stash", "Apply")
573                        .key_binding(
574                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
575                                .map(|kb| kb.size(rems_from_px(12.))),
576                        )
577                        .on_click(|_, window, cx| {
578                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
579                        }),
580                )
581                .into_any(),
582        )
583    }
584}