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.read(cx).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 cx.update(|cx| repo.read(cx).create_branch(new_branch_name.to_string()))?
206 .await??;
207 cx.update(|cx| repo.read(cx).change_branch(new_branch_name.to_string()))?
208 .await??;
209 Ok(())
210 })
211 .detach_and_prompt_err("Failed to create branch", window, cx, |e, _, _| {
212 Some(e.to_string())
213 });
214 cx.emit(DismissEvent);
215 }
216}
217
218impl PickerDelegate for BranchListDelegate {
219 type ListItem = ListItem;
220
221 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
222 "Select branch...".into()
223 }
224
225 fn editor_position(&self) -> PickerEditorPosition {
226 match self.style {
227 BranchListStyle::Modal => PickerEditorPosition::Start,
228 BranchListStyle::Popover => PickerEditorPosition::End,
229 }
230 }
231
232 fn match_count(&self) -> usize {
233 self.matches.len()
234 }
235
236 fn selected_index(&self) -> usize {
237 self.selected_index
238 }
239
240 fn set_selected_index(
241 &mut self,
242 ix: usize,
243 _window: &mut Window,
244 _: &mut Context<Picker<Self>>,
245 ) {
246 self.selected_index = ix;
247 }
248
249 fn update_matches(
250 &mut self,
251 query: String,
252 window: &mut Window,
253 cx: &mut Context<Picker<Self>>,
254 ) -> Task<()> {
255 let Some(all_branches) = self.all_branches.clone() else {
256 return Task::ready(());
257 };
258
259 const RECENT_BRANCHES_COUNT: usize = 10;
260 cx.spawn_in(window, async move |picker, cx| {
261 let mut matches: Vec<BranchEntry> = if query.is_empty() {
262 all_branches
263 .into_iter()
264 .take(RECENT_BRANCHES_COUNT)
265 .map(|branch| BranchEntry {
266 branch,
267 positions: Vec::new(),
268 is_new: false,
269 })
270 .collect()
271 } else {
272 let candidates = all_branches
273 .iter()
274 .enumerate()
275 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name.clone()))
276 .collect::<Vec<StringMatchCandidate>>();
277 fuzzy::match_strings(
278 &candidates,
279 &query,
280 true,
281 10000,
282 &Default::default(),
283 cx.background_executor().clone(),
284 )
285 .await
286 .iter()
287 .cloned()
288 .map(|candidate| BranchEntry {
289 branch: all_branches[candidate.candidate_id].clone(),
290 positions: candidate.positions,
291 is_new: false,
292 })
293 .collect()
294 };
295 picker
296 .update(cx, |picker, _| {
297 #[allow(clippy::nonminimal_bool)]
298 if !query.is_empty()
299 && !matches
300 .first()
301 .is_some_and(|entry| entry.branch.name == query)
302 {
303 matches.push(BranchEntry {
304 branch: Branch {
305 name: query.clone().into(),
306 is_head: false,
307 upstream: None,
308 most_recent_commit: None,
309 },
310 positions: Vec::new(),
311 is_new: true,
312 })
313 }
314 let delegate = &mut picker.delegate;
315 delegate.matches = matches;
316 if delegate.matches.is_empty() {
317 delegate.selected_index = 0;
318 } else {
319 delegate.selected_index =
320 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
321 }
322 delegate.last_query = query;
323 })
324 .log_err();
325 })
326 }
327
328 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
329 let Some(entry) = self.matches.get(self.selected_index()) else {
330 return;
331 };
332 if entry.is_new {
333 self.create_branch(entry.branch.name.clone(), window, cx);
334 return;
335 }
336
337 let current_branch = self.repo.as_ref().map(|repo| {
338 repo.update(cx, |repo, _| {
339 repo.branch.as_ref().map(|branch| branch.name.clone())
340 })
341 });
342
343 if current_branch
344 .flatten()
345 .is_some_and(|current_branch| current_branch == entry.branch.name)
346 {
347 cx.emit(DismissEvent);
348 return;
349 }
350
351 cx.spawn_in(window, {
352 let branch = entry.branch.clone();
353 async move |picker, cx| {
354 let branch_change_task = picker.update(cx, |this, cx| {
355 let repo = this
356 .delegate
357 .repo
358 .as_ref()
359 .ok_or_else(|| anyhow!("No active repository"))?
360 .clone();
361
362 let cx = cx.to_async();
363
364 anyhow::Ok(async move {
365 cx.update(|cx| repo.read(cx).change_branch(branch.name.to_string()))?
366 .await?
367 })
368 })??;
369
370 branch_change_task.await?;
371
372 picker.update(cx, |_, cx| {
373 cx.emit(DismissEvent);
374
375 anyhow::Ok(())
376 })
377 }
378 })
379 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
380 }
381
382 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
383 cx.emit(DismissEvent);
384 }
385
386 fn render_header(&self, _: &mut Window, _cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
387 None
388 }
389
390 fn render_match(
391 &self,
392 ix: usize,
393 selected: bool,
394 _window: &mut Window,
395 cx: &mut Context<Picker<Self>>,
396 ) -> Option<Self::ListItem> {
397 let entry = &self.matches[ix];
398
399 let (commit_time, subject) = entry
400 .branch
401 .most_recent_commit
402 .as_ref()
403 .map(|commit| {
404 let subject = commit.subject.clone();
405 let commit_time = OffsetDateTime::from_unix_timestamp(commit.commit_timestamp)
406 .unwrap_or_else(|_| OffsetDateTime::now_utc());
407 let formatted_time = format_local_timestamp(
408 commit_time,
409 OffsetDateTime::now_utc(),
410 time_format::TimestampFormat::Relative,
411 );
412 (Some(formatted_time), Some(subject))
413 })
414 .unwrap_or_else(|| (None, None));
415
416 Some(
417 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
418 .inset(true)
419 .spacing(match self.style {
420 BranchListStyle::Modal => ListItemSpacing::default(),
421 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
422 })
423 .spacing(ListItemSpacing::Sparse)
424 .toggle_state(selected)
425 .child(
426 v_flex()
427 .w_full()
428 .child(
429 h_flex()
430 .w_full()
431 .flex_shrink()
432 .overflow_x_hidden()
433 .gap_2()
434 .justify_between()
435 .child(div().flex_shrink().overflow_x_hidden().child(
436 if entry.is_new {
437 Label::new(format!(
438 "Create branch \"{}\"…",
439 entry.branch.name
440 ))
441 .single_line()
442 .into_any_element()
443 } else {
444 HighlightedLabel::new(
445 entry.branch.name.clone(),
446 entry.positions.clone(),
447 )
448 .truncate()
449 .into_any_element()
450 },
451 ))
452 .when_some(commit_time, |el, commit_time| {
453 el.child(
454 Label::new(commit_time)
455 .size(LabelSize::Small)
456 .color(Color::Muted)
457 .into_element(),
458 )
459 }),
460 )
461 .when(self.style == BranchListStyle::Modal, |el| {
462 el.child(div().max_w_96().child({
463 let message = if entry.is_new {
464 if let Some(current_branch) =
465 self.repo.as_ref().and_then(|repo| {
466 repo.read(cx).branch.as_ref().map(|b| b.name.clone())
467 })
468 {
469 format!("based off {}", current_branch)
470 } else {
471 "based off the current branch".to_string()
472 }
473 } else {
474 subject.unwrap_or("no commits found".into()).to_string()
475 };
476 Label::new(message)
477 .size(LabelSize::Small)
478 .truncate()
479 .color(Color::Muted)
480 }))
481 }),
482 ),
483 )
484 }
485
486 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
487 None
488 }
489}