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