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            None,
273            window,
274            cx,
275        );
276    }
277
278    fn pop_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
279        let Some(repo) = self.repo.clone() else {
280            return;
281        };
282
283        cx.spawn(async move |_, cx| {
284            repo.update(cx, |repo, cx| repo.stash_pop(Some(stash_index), cx))?
285                .await?;
286            Ok(())
287        })
288        .detach_and_prompt_err("Failed to pop stash", window, cx, |e, _, _| {
289            Some(e.to_string())
290        });
291        cx.emit(DismissEvent);
292    }
293
294    fn apply_stash(&self, stash_index: usize, window: &mut Window, cx: &mut Context<Picker<Self>>) {
295        let Some(repo) = self.repo.clone() else {
296            return;
297        };
298
299        cx.spawn(async move |_, cx| {
300            repo.update(cx, |repo, cx| repo.stash_apply(Some(stash_index), cx))?
301                .await?;
302            Ok(())
303        })
304        .detach_and_prompt_err("Failed to apply stash", window, cx, |e, _, _| {
305            Some(e.to_string())
306        });
307        cx.emit(DismissEvent);
308    }
309}
310
311impl PickerDelegate for StashListDelegate {
312    type ListItem = ListItem;
313
314    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
315        "Select a stash…".into()
316    }
317
318    fn match_count(&self) -> usize {
319        self.matches.len()
320    }
321
322    fn selected_index(&self) -> usize {
323        self.selected_index
324    }
325
326    fn set_selected_index(
327        &mut self,
328        ix: usize,
329        _window: &mut Window,
330        _: &mut Context<Picker<Self>>,
331    ) {
332        self.selected_index = ix;
333    }
334
335    fn update_matches(
336        &mut self,
337        query: String,
338        window: &mut Window,
339        cx: &mut Context<Picker<Self>>,
340    ) -> Task<()> {
341        let Some(all_stash_entries) = self.all_stash_entries.clone() else {
342            return Task::ready(());
343        };
344
345        let timezone = self.timezone;
346
347        cx.spawn_in(window, async move |picker, cx| {
348            let matches: Vec<StashEntryMatch> = if query.is_empty() {
349                all_stash_entries
350                    .into_iter()
351                    .map(|entry| {
352                        let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
353
354                        StashEntryMatch {
355                            entry,
356                            positions: Vec::new(),
357                            formatted_timestamp,
358                        }
359                    })
360                    .collect()
361            } else {
362                let candidates = all_stash_entries
363                    .iter()
364                    .enumerate()
365                    .map(|(ix, entry)| {
366                        StringMatchCandidate::new(
367                            ix,
368                            &Self::format_message(entry.index, &entry.message),
369                        )
370                    })
371                    .collect::<Vec<StringMatchCandidate>>();
372                fuzzy::match_strings(
373                    &candidates,
374                    &query,
375                    false,
376                    true,
377                    10000,
378                    &Default::default(),
379                    cx.background_executor().clone(),
380                )
381                .await
382                .into_iter()
383                .map(|candidate| {
384                    let entry = all_stash_entries[candidate.candidate_id].clone();
385                    let formatted_timestamp = Self::format_timestamp(entry.timestamp, timezone);
386
387                    StashEntryMatch {
388                        entry,
389                        positions: candidate.positions,
390                        formatted_timestamp,
391                    }
392                })
393                .collect()
394            };
395
396            picker
397                .update(cx, |picker, _| {
398                    let delegate = &mut picker.delegate;
399                    delegate.matches = matches;
400                    if delegate.matches.is_empty() {
401                        delegate.selected_index = 0;
402                    } else {
403                        delegate.selected_index =
404                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
405                    }
406                    delegate.last_query = query;
407                })
408                .log_err();
409        })
410    }
411
412    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
413        let Some(entry_match) = self.matches.get(self.selected_index()) else {
414            return;
415        };
416        let stash_index = entry_match.entry.index;
417        if secondary {
418            self.pop_stash(stash_index, window, cx);
419        } else {
420            self.apply_stash(stash_index, window, cx);
421        }
422    }
423
424    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
425        cx.emit(DismissEvent);
426    }
427
428    fn render_match(
429        &self,
430        ix: usize,
431        selected: bool,
432        _window: &mut Window,
433        _cx: &mut Context<Picker<Self>>,
434    ) -> Option<Self::ListItem> {
435        let entry_match = &self.matches[ix];
436
437        let stash_message =
438            Self::format_message(entry_match.entry.index, &entry_match.entry.message);
439        let positions = entry_match.positions.clone();
440        let stash_label = HighlightedLabel::new(stash_message, positions)
441            .truncate()
442            .into_any_element();
443
444        let branch_name = entry_match.entry.branch.clone().unwrap_or_default();
445        let branch_info = h_flex()
446            .gap_1p5()
447            .w_full()
448            .child(
449                Label::new(branch_name)
450                    .truncate()
451                    .color(Color::Muted)
452                    .size(LabelSize::Small),
453            )
454            .child(
455                Label::new("")
456                    .alpha(0.5)
457                    .color(Color::Muted)
458                    .size(LabelSize::Small),
459            )
460            .child(
461                Label::new(entry_match.formatted_timestamp.clone())
462                    .color(Color::Muted)
463                    .size(LabelSize::Small),
464            );
465
466        Some(
467            ListItem::new(SharedString::from(format!("stash-{ix}")))
468                .inset(true)
469                .spacing(ListItemSpacing::Sparse)
470                .toggle_state(selected)
471                .child(v_flex().w_full().child(stash_label).child(branch_info))
472                .tooltip(Tooltip::text(format!(
473                    "stash@{{{}}}",
474                    entry_match.entry.index
475                ))),
476        )
477    }
478
479    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
480        Some("No stashes found".into())
481    }
482
483    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
484        let focus_handle = self.focus_handle.clone();
485
486        Some(
487            h_flex()
488                .w_full()
489                .p_1p5()
490                .gap_0p5()
491                .justify_end()
492                .border_t_1()
493                .border_color(cx.theme().colors().border_variant)
494                .child(
495                    Button::new("drop-stash", "Drop")
496                        .key_binding(
497                            KeyBinding::for_action_in(
498                                &stash_picker::DropStashItem,
499                                &focus_handle,
500                                cx,
501                            )
502                            .map(|kb| kb.size(rems_from_px(12.))),
503                        )
504                        .on_click(|_, window, cx| {
505                            window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
506                        }),
507                )
508                .child(
509                    Button::new("view-stash", "View")
510                        .key_binding(
511                            KeyBinding::for_action_in(
512                                &stash_picker::ShowStashItem,
513                                &focus_handle,
514                                cx,
515                            )
516                            .map(|kb| kb.size(rems_from_px(12.))),
517                        )
518                        .on_click(cx.listener(move |picker, _, window, cx| {
519                            cx.stop_propagation();
520                            let selected_ix = picker.delegate.selected_index();
521                            picker.delegate.show_stash_at(selected_ix, window, cx);
522                        })),
523                )
524                .child(
525                    Button::new("pop-stash", "Pop")
526                        .key_binding(
527                            KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx)
528                                .map(|kb| kb.size(rems_from_px(12.))),
529                        )
530                        .on_click(|_, window, cx| {
531                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
532                        }),
533                )
534                .child(
535                    Button::new("apply-stash", "Apply")
536                        .key_binding(
537                            KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
538                                .map(|kb| kb.size(rems_from_px(12.))),
539                        )
540                        .on_click(|_, window, cx| {
541                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
542                        }),
543                )
544                .into_any(),
545        )
546    }
547}