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::Updated { .. }) {
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(
527 &self,
528 window: &mut Window,
529 cx: &mut Context<Picker<Self>>,
530 ) -> Option<AnyElement> {
531 let focus_handle = self.focus_handle.clone();
532
533 Some(
534 h_flex()
535 .w_full()
536 .p_1p5()
537 .gap_0p5()
538 .justify_end()
539 .border_t_1()
540 .border_color(cx.theme().colors().border_variant)
541 .child(
542 Button::new("apply-stash", "Apply")
543 .key_binding(
544 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, window, cx)
545 .map(|kb| kb.size(rems_from_px(12.))),
546 )
547 .on_click(|_, window, cx| {
548 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
549 }),
550 )
551 .child(
552 Button::new("pop-stash", "Pop")
553 .key_binding(
554 KeyBinding::for_action_in(
555 &menu::SecondaryConfirm,
556 &focus_handle,
557 window,
558 cx,
559 )
560 .map(|kb| kb.size(rems_from_px(12.))),
561 )
562 .on_click(|_, window, cx| {
563 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
564 }),
565 )
566 .child(
567 Button::new("drop-stash", "Drop")
568 .key_binding(
569 KeyBinding::for_action_in(
570 &stash_picker::DropStashItem,
571 &focus_handle,
572 window,
573 cx,
574 )
575 .map(|kb| kb.size(rems_from_px(12.))),
576 )
577 .on_click(|_, window, cx| {
578 window.dispatch_action(stash_picker::DropStashItem.boxed_clone(), cx)
579 }),
580 )
581 .into_any(),
582 )
583 }
584}