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
404        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
405        let branch_label = h_flex()
406            .gap_1p5()
407            .w_full()
408            .child(
409                h_flex()
410                    .gap_0p5()
411                    .child(
412                        Icon::new(IconName::GitBranch)
413                            .color(Color::Muted)
414                            .size(IconSize::Small),
415                    )
416                    .child(
417                        Label::new(branch_name)
418                            .truncate()
419                            .color(Color::Muted)
420                            .size(LabelSize::Small),
421                    ),
422            )
423            .child(
424                Label::new("")
425                    .alpha(0.5)
426                    .color(Color::Muted)
427                    .size(LabelSize::Small),
428            )
429            .child(
430                Label::new(entry_match.formatted_timestamp.clone())
431                    .color(Color::Muted)
432                    .size(LabelSize::Small),
433            );
434
435        Some(
436            ListItem::new(SharedString::from(format!("stash-{ix}")))
437                .inset(true)
438                .spacing(ListItemSpacing::Sparse)
439                .toggle_state(selected)
440                .child(
441                    v_flex()
442                        .w_full()
443                        .overflow_hidden()
444                        .child(stash_label)
445                        .child(branch_label.into_element()),
446                )
447                .tooltip(Tooltip::text(format!(
448                    "stash@{{{}}}",
449                    entry_match.entry.index
450                ))),
451        )
452    }
453
454    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
455        Some("No stashes found".into())
456    }
457
458    fn render_footer(
459        &self,
460        window: &mut Window,
461        cx: &mut Context<Picker<Self>>,
462    ) -> Option<AnyElement> {
463        let focus_handle = self.focus_handle.clone();
464
465        Some(
466            h_flex()
467                .w_full()
468                .p_1p5()
469                .gap_0p5()
470                .justify_end()
471                .border_t_1()
472                .border_color(cx.theme().colors().border_variant)
473                .child(
474                    Button::new("apply-stash", "Apply")
475                        .key_binding(
476                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx)
477                                .map(|kb| kb.size(rems_from_px(12.))),
478                        )
479                        .on_click(|_, window, cx| {
480                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
481                        }),
482                )
483                .child(
484                    Button::new("pop-stash", "Pop")
485                        .key_binding(
486                            KeyBinding::for_action_in(
487                                &menu::SecondaryConfirm,
488                                &focus_handle,
489                                window,
490                                cx,
491                            )
492                            .map(|kb| kb.size(rems_from_px(12.))),
493                        )
494                        .on_click(|_, window, cx| {
495                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
496                        }),
497                )
498                .child(
499                    Button::new("drop-stash", "Drop")
500                        .key_binding(
501                            KeyBinding::for_action_in(
502                                &stash_picker::DropStashItem,
503                                &focus_handle,
504                                window,
505                                cx,
506                            )
507                            .map(|kb| kb.size(rems_from_px(12.))),
508                        )
509                        .on_click(|_, window, cx| {
510                            window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
511                        }),
512                )
513                .into_any(),
514        )
515    }
516}