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