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}