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