1use anyhow::{Context as _, anyhow};
2use fuzzy::StringMatchCandidate;
3
4use git::repository::Branch;
5use gpui::{
6 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement,
7 IntoElement, Modifiers, ModifiersChangedEvent, ParentElement, Render, SharedString, Styled,
8 Subscription, Task, Window, rems,
9};
10use picker::{Picker, PickerDelegate, PickerEditorPosition};
11use project::git_store::Repository;
12use std::sync::Arc;
13use time::OffsetDateTime;
14use time_format::format_local_timestamp;
15use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
16use util::ResultExt;
17use workspace::notifications::DetachAndPromptErr;
18use workspace::{ModalView, Workspace};
19
20pub fn register(workspace: &mut Workspace) {
21 workspace.register_action(open);
22 workspace.register_action(switch);
23 workspace.register_action(checkout_branch);
24}
25
26pub fn checkout_branch(
27 workspace: &mut Workspace,
28 _: &zed_actions::git::CheckoutBranch,
29 window: &mut Window,
30 cx: &mut Context<Workspace>,
31) {
32 open(workspace, &zed_actions::git::Branch, window, cx);
33}
34
35pub fn switch(
36 workspace: &mut Workspace,
37 _: &zed_actions::git::Switch,
38 window: &mut Window,
39 cx: &mut Context<Workspace>,
40) {
41 open(workspace, &zed_actions::git::Branch, window, cx);
42}
43
44pub fn open(
45 workspace: &mut Workspace,
46 _: &zed_actions::git::Branch,
47 window: &mut Window,
48 cx: &mut Context<Workspace>,
49) {
50 let repository = workspace.project().read(cx).active_repository(cx).clone();
51 let style = BranchListStyle::Modal;
52 workspace.toggle_modal(window, cx, |window, cx| {
53 BranchList::new(repository, style, rems(34.), window, cx)
54 })
55}
56
57pub fn popover(
58 repository: Option<Entity<Repository>>,
59 window: &mut Window,
60 cx: &mut App,
61) -> Entity<BranchList> {
62 cx.new(|cx| {
63 let list = BranchList::new(repository, BranchListStyle::Popover, rems(20.), window, cx);
64 list.focus_handle(cx).focus(window);
65 list
66 })
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70enum BranchListStyle {
71 Modal,
72 Popover,
73}
74
75pub struct BranchList {
76 width: Rems,
77 pub picker: Entity<Picker<BranchListDelegate>>,
78 _subscription: Subscription,
79}
80
81impl BranchList {
82 fn new(
83 repository: Option<Entity<Repository>>,
84 style: BranchListStyle,
85 width: Rems,
86 window: &mut Window,
87 cx: &mut Context<Self>,
88 ) -> Self {
89 let all_branches_request = repository
90 .clone()
91 .map(|repository| repository.update(cx, |repository, _| repository.branches()));
92
93 cx.spawn_in(window, async move |this, cx| {
94 let mut all_branches = all_branches_request
95 .context("No active repository")?
96 .await??;
97
98 all_branches.sort_by_key(|branch| {
99 branch
100 .most_recent_commit
101 .as_ref()
102 .map(|commit| 0 - commit.commit_timestamp)
103 });
104
105 this.update_in(cx, |this, window, cx| {
106 this.picker.update(cx, |picker, cx| {
107 picker.delegate.all_branches = Some(all_branches);
108 picker.refresh(window, cx);
109 })
110 })?;
111
112 anyhow::Ok(())
113 })
114 .detach_and_log_err(cx);
115
116 let delegate = BranchListDelegate::new(repository.clone(), style);
117 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
118
119 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
120 cx.emit(DismissEvent);
121 });
122
123 Self {
124 picker,
125 width,
126 _subscription,
127 }
128 }
129
130 fn handle_modifiers_changed(
131 &mut self,
132 ev: &ModifiersChangedEvent,
133 _: &mut Window,
134 cx: &mut Context<Self>,
135 ) {
136 self.picker
137 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
138 }
139}
140impl ModalView for BranchList {}
141impl EventEmitter<DismissEvent> for BranchList {}
142
143impl Focusable for BranchList {
144 fn focus_handle(&self, cx: &App) -> FocusHandle {
145 self.picker.focus_handle(cx)
146 }
147}
148
149impl Render for BranchList {
150 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
151 v_flex()
152 .w(self.width)
153 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
154 .child(self.picker.clone())
155 .on_mouse_down_out({
156 cx.listener(move |this, _, window, cx| {
157 this.picker.update(cx, |this, cx| {
158 this.cancel(&Default::default(), window, cx);
159 })
160 })
161 })
162 }
163}
164
165#[derive(Debug, Clone)]
166struct BranchEntry {
167 branch: Branch,
168 positions: Vec<usize>,
169 is_new: bool,
170}
171
172pub struct BranchListDelegate {
173 matches: Vec<BranchEntry>,
174 all_branches: Option<Vec<Branch>>,
175 repo: Option<Entity<Repository>>,
176 style: BranchListStyle,
177 selected_index: usize,
178 last_query: String,
179 modifiers: Modifiers,
180}
181
182impl BranchListDelegate {
183 fn new(repo: Option<Entity<Repository>>, style: BranchListStyle) -> Self {
184 Self {
185 matches: vec![],
186 repo,
187 style,
188 all_branches: None,
189 selected_index: 0,
190 last_query: Default::default(),
191 modifiers: Default::default(),
192 }
193 }
194
195 fn create_branch(
196 &self,
197 new_branch_name: SharedString,
198 window: &mut Window,
199 cx: &mut Context<Picker<Self>>,
200 ) {
201 let Some(repo) = self.repo.clone() else {
202 return;
203 };
204 cx.spawn(async move |_, cx| {
205 repo.update(cx, |repo, _| {
206 repo.create_branch(new_branch_name.to_string())
207 })?
208 .await??;
209 repo.update(cx, |repo, _| {
210 repo.change_branch(new_branch_name.to_string())
211 })?
212 .await??;
213
214 Ok(())
215 })
216 .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
217 Some(e.to_string())
218 });
219 cx.emit(DismissEvent);
220 }
221}
222
223impl PickerDelegate for BranchListDelegate {
224 type ListItem = ListItem;
225
226 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
227 "Select branch...".into()
228 }
229
230 fn editor_position(&self) -> PickerEditorPosition {
231 match self.style {
232 BranchListStyle::Modal => PickerEditorPosition::Start,
233 BranchListStyle::Popover => PickerEditorPosition::End,
234 }
235 }
236
237 fn match_count(&self) -> usize {
238 self.matches.len()
239 }
240
241 fn selected_index(&self) -> usize {
242 self.selected_index
243 }
244
245 fn set_selected_index(
246 &mut self,
247 ix: usize,
248 _window: &mut Window,
249 _: &mut Context<Picker<Self>>,
250 ) {
251 self.selected_index = ix;
252 }
253
254 fn update_matches(
255 &mut self,
256 query: String,
257 window: &mut Window,
258 cx: &mut Context<Picker<Self>>,
259 ) -> Task<()> {
260 let Some(all_branches) = self.all_branches.clone() else {
261 return Task::ready(());
262 };
263
264 const RECENT_BRANCHES_COUNT: usize = 10;
265 cx.spawn_in(window, async move |picker, cx| {
266 let mut matches: Vec<BranchEntry> = if query.is_empty() {
267 all_branches
268 .into_iter()
269 .take(RECENT_BRANCHES_COUNT)
270 .map(|branch| BranchEntry {
271 branch,
272 positions: Vec::new(),
273 is_new: false,
274 })
275 .collect()
276 } else {
277 let candidates = all_branches
278 .iter()
279 .enumerate()
280 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
281 .collect::<Vec<StringMatchCandidate>>();
282 fuzzy::match_strings(
283 &candidates,
284 &query,
285 true,
286 10000,
287 &Default::default(),
288 cx.background_executor().clone(),
289 )
290 .await
291 .iter()
292 .cloned()
293 .map(|candidate| BranchEntry {
294 branch: all_branches[candidate.candidate_id].clone(),
295 positions: candidate.positions,
296 is_new: false,
297 })
298 .collect()
299 };
300 picker
301 .update(cx, |picker, _| {
302 #[allow(clippy::nonminimal_bool)]
303 if !query.is_empty()
304 && !matches
305 .first()
306 .is_some_and(|entry| entry.branch.name == query)
307 {
308 matches.push(BranchEntry {
309 branch: Branch {
310 name: query.clone().into(),
311 is_head: false,
312 upstream: None,
313 most_recent_commit: None,
314 },
315 positions: Vec::new(),
316 is_new: true,
317 })
318 }
319 let delegate = &mut picker.delegate;
320 delegate.matches = matches;
321 if delegate.matches.is_empty() {
322 delegate.selected_index = 0;
323 } else {
324 delegate.selected_index =
325 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
326 }
327 delegate.last_query = query;
328 })
329 .log_err();
330 })
331 }
332
333 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
334 let Some(entry) = self.matches.get(self.selected_index()) else {
335 return;
336 };
337 if entry.is_new {
338 self.create_branch(entry.branch.name.clone(), window, cx);
339 return;
340 }
341
342 let current_branch = self.repo.as_ref().map(|repo| {
343 repo.update(cx, |repo, _| {
344 repo.branch.as_ref().map(|branch| branch.name.clone())
345 })
346 });
347
348 if current_branch
349 .flatten()
350 .is_some_and(|current_branch| current_branch == entry.branch.name)
351 {
352 cx.emit(DismissEvent);
353 return;
354 }
355
356 cx.spawn_in(window, {
357 let branch = entry.branch.clone();
358 async move |picker, cx| {
359 let branch_change_task = picker.update(cx, |this, cx| {
360 let repo = this
361 .delegate
362 .repo
363 .as_ref()
364 .ok_or_else(|| anyhow!("No active repository"))?
365 .clone();
366
367 let mut cx = cx.to_async();
368
369 anyhow::Ok(async move {
370 repo.update(&mut cx, |repo, _| {
371 repo.change_branch(branch.name.to_string())
372 })?
373 .await?
374 })
375 })??;
376
377 branch_change_task.await?;
378
379 picker.update(cx, |_, cx| {
380 cx.emit(DismissEvent);
381
382 anyhow::Ok(())
383 })
384 }
385 })
386 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
387 }
388
389 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
390 cx.emit(DismissEvent);
391 }
392
393 fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
394 None
395 }
396
397 fn render_match(
398 &self,
399 ix: usize,
400 selected: bool,
401 _window: &mut Window,
402 cx: &mut Context<Picker<Self>>,
403 ) -> Option<Self::ListItem> {
404 let entry = &self.matches[ix];
405
406 let (commit_time, subject) = entry
407 .branch
408 .most_recent_commit
409 .as_ref()
410 .map(|commit| {
411 let subject = commit.subject.clone();
412 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
413 .unwrap_or_else(|_| OffsetDateTime::now_utc());
414 let formatted_time = format_local_timestamp(
415 commit_time,
416 OffsetDateTime::now_utc(),
417 time_format::TimestampFormat::Relative,
418 );
419 (Some(formatted_time), Some(subject))
420 })
421 .unwrap_or_else(|| (None, None));
422
423 Some(
424 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
425 .inset(true)
426 .spacing(match self.style {
427 BranchListStyle::Modal => ListItemSpacing::default(),
428 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
429 })
430 .spacing(ListItemSpacing::Sparse)
431 .toggle_state(selected)
432 .child(
433 v_flex()
434 .w_full()
435 .child(
436 h_flex()
437 .w_full()
438 .flex_shrink()
439 .overflow_x_hidden()
440 .gap_2()
441 .justify_between()
442 .child(div().flex_shrink().overflow_x_hidden().child(
443 if entry.is_new {
444 Label::new(format!(
445 "Create branch \"{}\"…",
446 entry.branch.name
447 ))
448 .single_line()
449 .into_any_element()
450 } else {
451 HighlightedLabel::new(
452 entry.branch.name.clone(),
453 entry.positions.clone(),
454 )
455 .truncate()
456 .into_any_element()
457 },
458 ))
459 .when_some(commit_time, |el, commit_time| {
460 el.child(
461 Label::new(commit_time)
462 .size(LabelSize::Small)
463 .color(Color::Muted)
464 .into_element(),
465 )
466 }),
467 )
468 .when(self.style == BranchListStyle::Modal, |el| {
469 el.child(div().max_w_96().child({
470 let message = if entry.is_new {
471 if let Some(current_branch) =
472 self.repo.as_ref().and_then(|repo| {
473 repo.read(cx).branch.as_ref().map(|b| b.name.clone())
474 })
475 {
476 format!("based off {}", current_branch)
477 } else {
478 "based off the current branch".to_string()
479 }
480 } else {
481 subject.unwrap_or("no commits found".into()).to_string()
482 };
483 Label::new(message)
484 .size(LabelSize::Small)
485 .truncate()
486 .color(Color::Muted)
487 }))
488 }),
489 ),
490 )
491 }
492
493 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
494 None
495 }
496}