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