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