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