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