1use anyhow::{Context as _, Result};
2use fuzzy::{StringMatch, StringMatchCandidate};
3
4use git::repository::Branch;
5use gpui::{
6 rems, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
7 InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription,
8 Task, Window,
9};
10use picker::{Picker, PickerDelegate};
11use project::{Project, ProjectPath};
12use std::sync::Arc;
13use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing, PopoverMenuHandle};
14use util::ResultExt;
15use workspace::notifications::DetachAndPromptErr;
16use workspace::{ModalView, Workspace};
17
18pub fn init(cx: &mut App) {
19 cx.observe_new(|workspace: &mut Workspace, _, _| {
20 workspace.register_action(open);
21 })
22 .detach();
23}
24
25pub fn open(
26 workspace: &mut Workspace,
27 _: &zed_actions::git::Branch,
28 window: &mut Window,
29 cx: &mut Context<Workspace>,
30) {
31 let project = workspace.project().clone();
32 let this = cx.entity();
33 let style = BranchListStyle::Modal;
34 cx.spawn_in(window, |_, mut cx| async move {
35 // Modal branch picker has a longer trailoff than a popover one.
36 let delegate = BranchListDelegate::new(project.clone(), style, 70, &cx).await?;
37
38 this.update_in(&mut cx, move |workspace, window, cx| {
39 workspace.toggle_modal(window, cx, |window, cx| {
40 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
41 let _subscription = cx.subscribe(&picker, |_, _, _, cx| {
42 cx.emit(DismissEvent);
43 });
44
45 let mut list = BranchList::new(project, style, 34., cx);
46 list._subscription = Some(_subscription);
47 list.picker = Some(picker);
48 list
49 })
50 })?;
51
52 Ok(())
53 })
54 .detach_and_prompt_err("Failed to read branches", window, cx, |_, _, _| None)
55}
56
57pub fn popover(project: Entity<Project>, window: &mut Window, cx: &mut App) -> Entity<BranchList> {
58 cx.new(|cx| {
59 let mut list = BranchList::new(project, BranchListStyle::Popover, 15., cx);
60 list.reload_branches(window, cx);
61 list
62 })
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66enum BranchListStyle {
67 Modal,
68 Popover,
69}
70
71pub struct BranchList {
72 rem_width: f32,
73 popover_handle: PopoverMenuHandle<Self>,
74 default_focus_handle: FocusHandle,
75 project: Entity<Project>,
76 style: BranchListStyle,
77 pub picker: Option<Entity<Picker<BranchListDelegate>>>,
78 _subscription: Option<Subscription>,
79}
80
81impl popover_button::TriggerablePopover for BranchList {
82 fn menu_handle(
83 &mut self,
84 _window: &mut Window,
85 _cx: &mut gpui::Context<Self>,
86 ) -> PopoverMenuHandle<Self> {
87 self.popover_handle.clone()
88 }
89}
90
91impl BranchList {
92 fn new(project: Entity<Project>, style: BranchListStyle, rem_width: f32, cx: &mut App) -> Self {
93 let popover_handle = PopoverMenuHandle::default();
94 Self {
95 project,
96 picker: None,
97 rem_width,
98 popover_handle,
99 default_focus_handle: cx.focus_handle(),
100 style,
101 _subscription: None,
102 }
103 }
104
105 fn reload_branches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
106 let project = self.project.clone();
107 let style = self.style;
108 cx.spawn_in(window, |this, mut cx| async move {
109 let delegate = BranchListDelegate::new(project, style, 20, &cx).await?;
110 let picker =
111 cx.new_window_entity(|window, cx| Picker::uniform_list(delegate, window, cx))?;
112
113 this.update(&mut cx, |branch_list, cx| {
114 let subscription =
115 cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| cx.emit(DismissEvent));
116
117 branch_list.picker = Some(picker);
118 branch_list._subscription = Some(subscription);
119
120 cx.notify();
121 })?;
122
123 anyhow::Ok(())
124 })
125 .detach_and_log_err(cx);
126 }
127}
128impl ModalView for BranchList {}
129impl EventEmitter<DismissEvent> for BranchList {}
130
131impl Focusable for BranchList {
132 fn focus_handle(&self, cx: &App) -> FocusHandle {
133 self.picker
134 .as_ref()
135 .map(|picker| picker.focus_handle(cx))
136 .unwrap_or_else(|| self.default_focus_handle.clone())
137 }
138}
139
140impl Render for BranchList {
141 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
142 v_flex()
143 .w(rems(self.rem_width))
144 .map(|parent| match self.picker.as_ref() {
145 Some(picker) => parent.child(picker.clone()).on_mouse_down_out({
146 let picker = picker.clone();
147 cx.listener(move |_, _, window, cx| {
148 picker.update(cx, |this, cx| {
149 this.cancel(&Default::default(), window, cx);
150 })
151 })
152 }),
153 None => parent.child(
154 h_flex()
155 .id("branch-picker-error")
156 .on_click(
157 cx.listener(|this, _, window, cx| this.reload_branches(window, cx)),
158 )
159 .child("Could not load branches.")
160 .child("Click to retry"),
161 ),
162 })
163 }
164}
165
166#[derive(Debug, Clone)]
167enum BranchEntry {
168 Branch(StringMatch),
169 History(String),
170 NewBranch { name: String },
171}
172
173impl BranchEntry {
174 fn name(&self) -> &str {
175 match self {
176 Self::Branch(branch) => &branch.string,
177 Self::History(branch) => &branch,
178 Self::NewBranch { name } => &name,
179 }
180 }
181}
182
183pub struct BranchListDelegate {
184 matches: Vec<BranchEntry>,
185 all_branches: Vec<Branch>,
186 project: Entity<Project>,
187 style: BranchListStyle,
188 selected_index: usize,
189 last_query: String,
190 /// Max length of branch name before we truncate it and add a trailing `...`.
191 branch_name_trailoff_after: usize,
192}
193
194impl BranchListDelegate {
195 async fn new(
196 project: Entity<Project>,
197 style: BranchListStyle,
198 branch_name_trailoff_after: usize,
199 cx: &AsyncApp,
200 ) -> Result<Self> {
201 let all_branches_request = cx.update(|cx| {
202 let project = project.read(cx);
203 let first_worktree = project
204 .visible_worktrees(cx)
205 .next()
206 .context("No worktrees found")?;
207 let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
208 anyhow::Ok(project.branches(project_path, cx))
209 })??;
210
211 let all_branches = all_branches_request.await?;
212
213 Ok(Self {
214 matches: vec![],
215 project,
216 style,
217 all_branches,
218 selected_index: 0,
219 last_query: Default::default(),
220 branch_name_trailoff_after,
221 })
222 }
223
224 pub fn branch_count(&self) -> usize {
225 self.matches
226 .iter()
227 .filter(|item| matches!(item, BranchEntry::Branch(_)))
228 .count()
229 }
230}
231
232impl PickerDelegate for BranchListDelegate {
233 type ListItem = ListItem;
234
235 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
236 "Select branch...".into()
237 }
238
239 fn match_count(&self) -> usize {
240 self.matches.len()
241 }
242
243 fn selected_index(&self) -> usize {
244 self.selected_index
245 }
246
247 fn set_selected_index(
248 &mut self,
249 ix: usize,
250 _window: &mut Window,
251 _: &mut Context<Picker<Self>>,
252 ) {
253 self.selected_index = ix;
254 }
255
256 fn update_matches(
257 &mut self,
258 query: String,
259 window: &mut Window,
260 cx: &mut Context<Picker<Self>>,
261 ) -> Task<()> {
262 cx.spawn_in(window, move |picker, mut cx| async move {
263 let candidates = picker.update(&mut cx, |picker, _| {
264 const RECENT_BRANCHES_COUNT: usize = 10;
265 let mut branches = picker.delegate.all_branches.clone();
266 if query.is_empty() {
267 if branches.len() > RECENT_BRANCHES_COUNT {
268 // Truncate list of recent branches
269 // Do a partial sort to show recent-ish branches first.
270 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
271 rhs.priority_key().cmp(&lhs.priority_key())
272 });
273 branches.truncate(RECENT_BRANCHES_COUNT);
274 }
275 branches.sort_unstable_by(|lhs, rhs| {
276 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
277 });
278 }
279 branches
280 .into_iter()
281 .enumerate()
282 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
283 .collect::<Vec<StringMatchCandidate>>()
284 });
285 let Some(candidates) = candidates.log_err() else {
286 return;
287 };
288 let matches: Vec<BranchEntry> = if query.is_empty() {
289 candidates
290 .into_iter()
291 .map(|candidate| BranchEntry::History(candidate.string))
292 .collect()
293 } else {
294 fuzzy::match_strings(
295 &candidates,
296 &query,
297 true,
298 10000,
299 &Default::default(),
300 cx.background_executor().clone(),
301 )
302 .await
303 .iter()
304 .cloned()
305 .map(BranchEntry::Branch)
306 .collect()
307 };
308 picker
309 .update(&mut cx, |picker, _| {
310 let delegate = &mut picker.delegate;
311 delegate.matches = matches;
312 if delegate.matches.is_empty() {
313 if !query.is_empty() {
314 delegate.matches.push(BranchEntry::NewBranch {
315 name: query.trim().replace(' ', "-"),
316 });
317 }
318
319 delegate.selected_index = 0;
320 } else {
321 delegate.selected_index =
322 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
323 }
324 delegate.last_query = query;
325 })
326 .log_err();
327 })
328 }
329
330 fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
331 let Some(branch) = self.matches.get(self.selected_index()) else {
332 return;
333 };
334
335 let current_branch = self.project.update(cx, |project, cx| {
336 project
337 .active_repository(cx)
338 .and_then(|repo| repo.read(cx).current_branch())
339 .map(|branch| branch.name.to_string())
340 });
341
342 if current_branch == Some(branch.name().to_string()) {
343 cx.emit(DismissEvent);
344 return;
345 }
346
347 cx.spawn_in(window, {
348 let branch = branch.clone();
349 |picker, mut cx| async move {
350 let branch_change_task = picker.update(&mut cx, |this, cx| {
351 let project = this.delegate.project.read(cx);
352 let branch_to_checkout = match branch {
353 BranchEntry::Branch(branch) => branch.string,
354 BranchEntry::History(string) => string,
355 BranchEntry::NewBranch { name: branch_name } => branch_name,
356 };
357 let worktree = project
358 .visible_worktrees(cx)
359 .next()
360 .context("worktree disappeared")?;
361 let repository = ProjectPath::root_path(worktree.read(cx).id());
362
363 anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
364 })??;
365
366 branch_change_task.await?;
367
368 picker.update(&mut cx, |_, cx| {
369 cx.emit(DismissEvent);
370
371 Ok::<(), anyhow::Error>(())
372 })
373 }
374 })
375 .detach_and_prompt_err("Failed to change branch", window, cx, |_, _, _| None);
376 }
377
378 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
379 cx.emit(DismissEvent);
380 }
381
382 fn render_match(
383 &self,
384 ix: usize,
385 selected: bool,
386 _window: &mut Window,
387 _cx: &mut Context<Picker<Self>>,
388 ) -> Option<Self::ListItem> {
389 let hit = &self.matches[ix];
390 let shortened_branch_name =
391 util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
392
393 Some(
394 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
395 .inset(true)
396 .spacing(match self.style {
397 BranchListStyle::Modal => ListItemSpacing::default(),
398 BranchListStyle::Popover => ListItemSpacing::ExtraDense,
399 })
400 .spacing(ListItemSpacing::Sparse)
401 .toggle_state(selected)
402 .when(matches!(hit, BranchEntry::History(_)), |el| {
403 el.end_slot(
404 Icon::new(IconName::HistoryRerun)
405 .color(Color::Muted)
406 .size(IconSize::Small),
407 )
408 })
409 .map(|el| match hit {
410 BranchEntry::Branch(branch) => {
411 let highlights: Vec<_> = branch
412 .positions
413 .iter()
414 .filter(|index| index < &&self.branch_name_trailoff_after)
415 .copied()
416 .collect();
417
418 el.child(HighlightedLabel::new(shortened_branch_name, highlights))
419 }
420 BranchEntry::History(_) => el.child(Label::new(shortened_branch_name)),
421 BranchEntry::NewBranch { name } => {
422 el.child(Label::new(format!("Create branch '{name}'")))
423 }
424 }),
425 )
426 }
427}