stash_picker.rs

  1use fuzzy::StringMatchCandidate;
  2
  3use chrono;
  4use git::stash::StashEntry;
  5use gpui::{
  6    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  7    InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render,
  8    SharedString, Styled, Subscription, Task, Window, actions, rems,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::git_store::{Repository, RepositoryEvent};
 12use std::sync::Arc;
 13use time::{OffsetDateTime, UtcOffset};
 14use time_format;
 15use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*};
 16use util::ResultExt;
 17use workspace::notifications::DetachAndPromptErr;
 18use workspace::{ModalView, Workspace};
 19
 20use crate::stash_picker;
 21
 22actions!(
 23    stash_picker,
 24    [
 25        /// Drop the selected stash entry.
 26        DropStashItem,
 27    ]
 28);
 29
 30pub fn register(workspace: &mut Workspace) {
 31    workspace.register_action(open);
 32}
 33
 34pub fn open(
 35    workspace: &mut Workspace,
 36    _: &zed_actions::git::ViewStash,
 37    window: &mut Window,
 38    cx: &mut Context<Workspace>,
 39) {
 40    let repository = workspace.project().read(cx).active_repository(cx);
 41    workspace.toggle_modal(window, cx, |window, cx| {
 42        StashList::new(repository, rems(34.), window, cx)
 43    })
 44}
 45
 46pub struct StashList {
 47    width: Rems,
 48    pub picker: Entity<Picker<StashListDelegate>>,
 49    picker_focus_handle: FocusHandle,
 50    _subscriptions: Vec<Subscription>,
 51}
 52
 53impl StashList {
 54    fn new(
 55        repository: Option<Entity<Repository>>,
 56        width: Rems,
 57        window: &mut Window,
 58        cx: &mut Context<Self>,
 59    ) -> Self {
 60        let mut _subscriptions = Vec::new();
 61        let stash_request = repository
 62            .clone()
 63            .map(|repository| repository.read_with(cx, |repo, _| repo.cached_stash()));
 64
 65        if let Some(repo) = repository.clone() {
 66            _subscriptions.push(
 67                cx.subscribe_in(&repo, window, |this, _, event, window, cx| {
 68                    if matches!(event, RepositoryEvent::Updated { .. }) {
 69                        let stash_entries = this.picker.read_with(cx, |picker, cx| {
 70                            picker
 71                                .delegate
 72                                .repo
 73                                .clone()
 74                                .map(|repo| repo.read(cx).cached_stash().entries.to_vec())
 75                        });
 76                        this.picker.update(cx, |this, cx| {
 77                            this.delegate.all_stash_entries = stash_entries;
 78                            this.refresh(window, cx);
 79                        });
 80                    }
 81                }),
 82            )
 83        }
 84
 85        cx.spawn_in(window, async move |this, cx| {
 86            let stash_entries = stash_request
 87                .map(|git_stash| git_stash.entries.to_vec())
 88                .unwrap_or_default();
 89
 90            this.update_in(cx, |this, window, cx| {
 91                this.picker.update(cx, |picker, cx| {
 92                    picker.delegate.all_stash_entries = Some(stash_entries);
 93                    picker.refresh(window, cx);
 94                })
 95            })?;
 96
 97            anyhow::Ok(())
 98        })
 99        .detach_and_log_err(cx);
100
101        let delegate = StashListDelegate::new(repository, window, cx);
102        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
103        let picker_focus_handle = picker.focus_handle(cx);
104        picker.update(cx, |picker, _| {
105            picker.delegate.focus_handle = picker_focus_handle.clone();
106        });
107
108        _subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
109            cx.emit(DismissEvent);
110        }));
111
112        Self {
113            picker,
114            picker_focus_handle,
115            width,
116            _subscriptions,
117        }
118    }
119
120    fn handle_drop_stash(
121        &mut self,
122        _: &DropStashItem,
123        window: &mut Window,
124        cx: &mut Context<Self>,
125    ) {
126        self.picker.update(cx, |picker, cx| {
127            picker
128                .delegate
129                .drop_stash_at(picker.delegate.selected_index(), window, cx);
130        });
131        cx.notify();
132    }
133
134    fn handle_modifiers_changed(
135        &mut self,
136        ev: &ModifiersChangedEvent,
137        _: &mut Window,
138        cx: &mut Context<Self>,
139    ) {
140        self.picker
141            .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
142    }
143}
144
145impl ModalView for StashList {}
146impl EventEmitter<DismissEvent> for StashList {}
147impl Focusable for StashList {
148    fn focus_handle(&self, _: &App) -> FocusHandle {
149        self.picker_focus_handle.clone()
150    }
151}
152
153impl Render for StashList {
154    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
155        v_flex()
156            .key_context("StashList")
157            .w(self.width)
158            .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
159            .on_action(cx.listener(Self::handle_drop_stash))
160            .child(self.picker.clone())
161    }
162}
163
164#[derive(Debug, Clone)]
165struct StashEntryMatch {
166    entry: StashEntry,
167    positions: Vec<usize>,
168    formatted_timestamp: String,
169}
170
171pub struct StashListDelegate {
172    matches: Vec<StashEntryMatch>,
173    all_stash_entries: Option<Vec<StashEntry>>,
174    repo: Option<Entity<Repository>>,
175    selected_index: usize,
176    last_query: String,
177    modifiers: Modifiers,
178    focus_handle: FocusHandle,
179    timezone: UtcOffset,
180}
181
182impl StashListDelegate {
183    fn new(
184        repo: Option<Entity<Repository>>,
185        _window: &mut Window,
186        cx: &mut Context<StashList>,
187    ) -> Self {
188        let timezone =
189            UtcOffset::from_whole_seconds(chrono::Local::now().offset().local_minus_utc())
190                .unwrap_or(UtcOffset::UTC);
191
192        Self {
193            matches: vec![],
194            repo,
195            all_stash_entries: None,
196            selected_index: 0,
197            last_query: Default::default(),
198            modifiers: Default::default(),
199            focus_handle: cx.focus_handle(),
200            timezone,
201        }
202    }
203
204    fn format_message(ix: usize, message: &String) -> String {
205        format!("#{}: {}", ix, message)
206    }
207
208    fn format_timestamp(timestamp: i64, timezone: UtcOffset) -> String {
209        let timestamp =
210            OffsetDateTime::from_unix_timestamp(timestamp).unwrap_or(OffsetDateTime::now_utc());
211        time_format::format_localized_timestamp(
212            timestamp,
213            OffsetDateTime::now_utc(),
214            timezone,
215            time_format::TimestampFormat::EnhancedAbsolute,
216        )
217    }
218
219    fn drop_stash_at(&self, ix: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
220        let Some(entry_match) = self.matches.get(ix) else {
221            return;
222        };
223        let stash_index = entry_match.entry.index;
224        let Some(repo) = self.repo.clone() else {
225            return;
226        };
227
228        cx.spawn(async move |_, cx| {
229            repo.update(cx, |repo, cx| repo.stash_drop(Some(stash_index), cx))?
230                .await??;
231            Ok(())
232        })
233        .detach_and_prompt_err("Failed to drop stash", window, cx, |e, _, _| {
234            Some(e.to_string())
235        });
236    }
237
238    fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
239        let Some(repo) = self.repo.clone() else {
240            return;
241        };
242
243        cx.spawn(async move |_, cx| {
244            repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))?
245                .await?;
246            Ok(())
247        })
248        .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
249            Some(e.to_string())
250        });
251        cx.emit(DismissEvent);
252    }
253
254    fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
255        let Some(repo) = self.repo.clone() else {
256            return;
257        };
258
259        cx.spawn(async move |_, cx| {
260            repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))?
261                .await?;
262            Ok(())
263        })
264        .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
265            Some(e.to_string())
266        });
267        cx.emit(DismissEvent);
268    }
269}
270
271impl PickerDelegate for StashListDelegate {
272    type ListItem = ListItem;
273
274    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
275        "Select a stash…".into()
276    }
277
278    fn match_count(&self) -> usize {
279        self.matches.len()
280    }
281
282    fn selected_index(&self) -> usize {
283        self.selected_index
284    }
285
286    fn set_selected_index(
287        &mut self,
288        ix: usize,
289        _window: &mut Window,
290        _: &mut Context<Picker<Self>>,
291    ) {
292        self.selected_index = ix;
293    }
294
295    fn update_matches(
296        &mut self,
297        query: String,
298        window: &mut Window,
299        cx: &mut Context<Picker<Self>>,
300    ) -> Task<()> {
301        let Some(all_stash_entries) = self.all_stash_entries.clone() else {
302            return Task::ready(());
303        };
304
305        let timezone = self.timezone;
306
307        cx.spawn_in(window, async move |picker, cx| {
308            let matches: Vec<StashEntryMatch> = if query.is_empty() {
309                all_stash_entries
310                    .into_iter()
311                    .map(|entry| {
312                        let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
313
314                        StashEntryMatch {
315                            entry,
316                            positions: Vec::new(),
317                            formatted_timestamp,
318                        }
319                    })
320                    .collect()
321            } else {
322                let candidates = all_stash_entries
323                    .iter()
324                    .enumerate()
325                    .map(|(ix, entry)| {
326                        StringMatchCandidate::new(
327                            ix,
328                            &Self::format_message(entry.index, &entry.message),
329                        )
330                    })
331                    .collect::<Vec<StringMatchCandidate>>();
332                fuzzy::match_strings(
333                    &candidates,
334                    &query,
335                    false,
336                    true,
337                    10000,
338                    &Default::default(),
339                    cx.background_executor().clone(),
340                )
341                .await
342                .into_iter()
343                .map(|candidate| {
344                    let entry = all_stash_entries[candidate.candidate_id].clone();
345                    let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
346
347                    StashEntryMatch {
348                        entry,
349                        positions: candidate.positions,
350                        formatted_timestamp,
351                    }
352                })
353                .collect()
354            };
355
356            picker
357                .update(cx, |picker, _| {
358                    let delegate = &mut picker.delegate;
359                    delegate.matches = matches;
360                    if delegate.matches.is_empty() {
361                        delegate.selected_index = 0;
362                    } else {
363                        delegate.selected_index =
364                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
365                    }
366                    delegate.last_query = query;
367                })
368                .log_err();
369        })
370    }
371
372    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
373        let Some(entry_match) = self.matches.get(self.selected_index()) else {
374            return;
375        };
376        let stash_index = entry_match.entry.index;
377        if secondary {
378            self.pop_stash(stash_index, window, cx);
379        } else {
380            self.apply_stash(stash_index, window, cx);
381        }
382    }
383
384    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
385        cx.emit(DismissEvent);
386    }
387
388    fn render_match(
389        &self,
390        ix: usize,
391        selected: bool,
392        _window: &mut Window,
393        _cx: &mut Context<Picker<Self>>,
394    ) -> Option<Self::ListItem> {
395        let entry_match = &self.matches[ix];
396
397        let stash_message =
398            Self::format_message(entry_match.entry.index, &entry_match.entry.message);
399        let positions = entry_match.positions.clone();
400        let stash_label = HighlightedLabel::new(stash_message, positions)
401            .truncate()
402            .into_any_element();
403        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
404        let branch_label = h_flex()
405            .gap_1()
406            .w_full()
407            .child(
408                Icon::new(IconName::GitBranch)
409                    .color(Color::Muted)
410                    .size(IconSize::Small),
411            )
412            .child(
413                Label::new(branch_name)
414                    .truncate()
415                    .color(Color::Muted)
416                    .size(LabelSize::Small),
417            );
418
419        let tooltip_text = format!(
420            "stash@{{{}}} created {}",
421            entry_match.entry.index, entry_match.formatted_timestamp
422        );
423
424        Some(
425            ListItem::new(SharedString::from(format!("stash-{ix}")))
426                .inset(true)
427                .spacing(ListItemSpacing::Sparse)
428                .toggle_state(selected)
429                .child(
430                    v_flex()
431                        .w_full()
432                        .overflow_hidden()
433                        .child(stash_label)
434                        .child(branch_label.into_element()),
435                )
436                .tooltip(Tooltip::text(tooltip_text)),
437        )
438    }
439
440    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
441        Some("No stashes found".into())
442    }
443
444    fn render_footer(
445        &self,
446        window: &mut Window,
447        cx: &mut Context<Picker<Self>>,
448    ) -> Option<AnyElement> {
449        let focus_handle = self.focus_handle.clone();
450
451        Some(
452            h_flex()
453                .w_full()
454                .p_1p5()
455                .justify_between()
456                .border_t_1()
457                .border_color(cx.theme().colors().border_variant)
458                .child(
459                    h_flex()
460                        .gap_0p5()
461                        .child(
462                            Button::new("apply-stash", "Apply")
463                                .key_binding(
464                                    KeyBinding::for_action_in(
465                                        &menu::Confirm,
466                                        &focus_handle,
467                                        window,
468                                        cx,
469                                    )
470                                    .map(|kb| kb.size(rems_from_px(12.))),
471                                )
472                                .on_click(|_, window, cx| {
473                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
474                                }),
475                        )
476                        .child(
477                            Button::new("pop-stash", "Pop")
478                                .key_binding(
479                                    KeyBinding::for_action_in(
480                                        &menu::SecondaryConfirm,
481                                        &focus_handle,
482                                        window,
483                                        cx,
484                                    )
485                                    .map(|kb| kb.size(rems_from_px(12.))),
486                                )
487                                .on_click(|_, window, cx| {
488                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
489                                }),
490                        )
491                        .child(
492                            Button::new("drop-stash", "Drop")
493                                .key_binding(
494                                    KeyBinding::for_action_in(
495                                        &stash_picker::DropStashItem,
496                                        &focus_handle,
497                                        window,
498                                        cx,
499                                    )
500                                    .map(|kb| kb.size(rems_from_px(12.))),
501                                )
502                                .on_click(|_, window, cx| {
503                                    window.dispatch_action(
504                                        stash_picker::DropStashItem.boxed_clone(),
505                                        cx,
506                                    )
507                                }),
508                        ),
509                )
510                .into_any(),
511        )
512    }
513}