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