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