1use anyhow::{anyhow, Context, Result};
2use fuzzy::{StringMatch, StringMatchCandidate};
3use git::repository::Branch;
4use gpui::{
5 rems, AnyElement, AppContext, AsyncAppContext, DismissEvent, EventEmitter, FocusHandle,
6 FocusableView, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
7 Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
8};
9use picker::{Picker, PickerDelegate};
10use project::ProjectPath;
11use std::{ops::Not, sync::Arc};
12use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
13use util::ResultExt;
14use workspace::notifications::DetachAndPromptErr;
15use workspace::{ModalView, Workspace};
16use zed_actions::branches::OpenRecent;
17
18pub fn init(cx: &mut AppContext) {
19 cx.observe_new_views(|workspace: &mut Workspace, _| {
20 workspace.register_action(BranchList::open);
21 })
22 .detach();
23}
24
25pub struct BranchList {
26 pub picker: View<Picker<BranchListDelegate>>,
27 rem_width: f32,
28 _subscription: Subscription,
29}
30
31impl BranchList {
32 pub fn open(_: &mut Workspace, _: &OpenRecent, cx: &mut ViewContext<Workspace>) {
33 let this = cx.view().clone();
34 cx.spawn(|_, mut cx| async move {
35 // Modal branch picker has a longer trailoff than a popover one.
36 let delegate = BranchListDelegate::new(this.clone(), 70, &cx).await?;
37
38 this.update(&mut cx, |workspace, cx| {
39 workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx))
40 })?;
41
42 Ok(())
43 })
44 .detach_and_prompt_err("Failed to read branches", cx, |_, _| None)
45 }
46
47 fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
48 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
49 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
50 Self {
51 picker,
52 rem_width,
53 _subscription,
54 }
55 }
56}
57impl ModalView for BranchList {}
58impl EventEmitter<DismissEvent> for BranchList {}
59
60impl FocusableView for BranchList {
61 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
62 self.picker.focus_handle(cx)
63 }
64}
65
66impl Render for BranchList {
67 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
68 v_flex()
69 .w(rems(self.rem_width))
70 .child(self.picker.clone())
71 .on_mouse_down_out(cx.listener(|this, _, cx| {
72 this.picker.update(cx, |this, cx| {
73 this.cancel(&Default::default(), cx);
74 })
75 }))
76 }
77}
78
79#[derive(Debug, Clone)]
80enum BranchEntry {
81 Branch(StringMatch),
82 NewBranch { name: String },
83}
84
85impl BranchEntry {
86 fn name(&self) -> &str {
87 match self {
88 Self::Branch(branch) => &branch.string,
89 Self::NewBranch { name } => &name,
90 }
91 }
92}
93
94pub struct BranchListDelegate {
95 matches: Vec<BranchEntry>,
96 all_branches: Vec<Branch>,
97 workspace: WeakView<Workspace>,
98 selected_index: usize,
99 last_query: String,
100 /// Max length of branch name before we truncate it and add a trailing `...`.
101 branch_name_trailoff_after: usize,
102}
103
104impl BranchListDelegate {
105 async fn new(
106 workspace: View<Workspace>,
107 branch_name_trailoff_after: usize,
108 cx: &AsyncAppContext,
109 ) -> Result<Self> {
110 let all_branches_request = cx.update(|cx| {
111 let project = workspace.read(cx).project().read(cx);
112 let first_worktree = project
113 .visible_worktrees(cx)
114 .next()
115 .context("No worktrees found")?;
116 let project_path = ProjectPath::root_path(first_worktree.read(cx).id());
117 anyhow::Ok(project.branches(project_path, cx))
118 })??;
119
120 let all_branches = all_branches_request.await?;
121
122 Ok(Self {
123 matches: vec![],
124 workspace: workspace.downgrade(),
125 all_branches,
126 selected_index: 0,
127 last_query: Default::default(),
128 branch_name_trailoff_after,
129 })
130 }
131
132 fn branch_count(&self) -> usize {
133 self.matches
134 .iter()
135 .filter(|item| matches!(item, BranchEntry::Branch(_)))
136 .count()
137 }
138}
139
140impl PickerDelegate for BranchListDelegate {
141 type ListItem = ListItem;
142
143 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
144 "Select branch...".into()
145 }
146
147 fn match_count(&self) -> usize {
148 self.matches.len()
149 }
150
151 fn selected_index(&self) -> usize {
152 self.selected_index
153 }
154
155 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
156 self.selected_index = ix;
157 }
158
159 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
160 cx.spawn(move |picker, mut cx| async move {
161 let candidates = picker.update(&mut cx, |view, _| {
162 const RECENT_BRANCHES_COUNT: usize = 10;
163 let mut branches = view.delegate.all_branches.clone();
164 if query.is_empty() {
165 if branches.len() > RECENT_BRANCHES_COUNT {
166 // Truncate list of recent branches
167 // Do a partial sort to show recent-ish branches first.
168 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
169 rhs.is_head
170 .cmp(&lhs.is_head)
171 .then(rhs.unix_timestamp.cmp(&lhs.unix_timestamp))
172 });
173 branches.truncate(RECENT_BRANCHES_COUNT);
174 }
175 branches.sort_unstable_by(|lhs, rhs| {
176 rhs.is_head.cmp(&lhs.is_head).then(lhs.name.cmp(&rhs.name))
177 });
178 }
179 branches
180 .into_iter()
181 .enumerate()
182 .map(|(ix, command)| StringMatchCandidate::new(ix, &command.name))
183 .collect::<Vec<StringMatchCandidate>>()
184 });
185 let Some(candidates) = candidates.log_err() else {
186 return;
187 };
188 let matches = if query.is_empty() {
189 candidates
190 .into_iter()
191 .enumerate()
192 .map(|(index, candidate)| StringMatch {
193 candidate_id: index,
194 string: candidate.string,
195 positions: Vec::new(),
196 score: 0.0,
197 })
198 .collect()
199 } else {
200 fuzzy::match_strings(
201 &candidates,
202 &query,
203 true,
204 10000,
205 &Default::default(),
206 cx.background_executor().clone(),
207 )
208 .await
209 };
210 picker
211 .update(&mut cx, |picker, _| {
212 let delegate = &mut picker.delegate;
213 delegate.matches = matches.into_iter().map(BranchEntry::Branch).collect();
214 if delegate.matches.is_empty() {
215 if !query.is_empty() {
216 delegate.matches.push(BranchEntry::NewBranch {
217 name: query.trim().replace(' ', "-"),
218 });
219 }
220
221 delegate.selected_index = 0;
222 } else {
223 delegate.selected_index =
224 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
225 }
226 delegate.last_query = query;
227 })
228 .log_err();
229 })
230 }
231
232 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
233 let Some(branch) = self.matches.get(self.selected_index()) else {
234 return;
235 };
236 cx.spawn({
237 let branch = branch.clone();
238 |picker, mut cx| async move {
239 let branch_change_task = picker.update(&mut cx, |this, cx| {
240 let workspace = this
241 .delegate
242 .workspace
243 .upgrade()
244 .ok_or_else(|| anyhow!("workspace was dropped"))?;
245
246 let project = workspace.read(cx).project().read(cx);
247 let branch_to_checkout = match branch {
248 BranchEntry::Branch(branch) => branch.string,
249 BranchEntry::NewBranch { name: branch_name } => branch_name,
250 };
251 let worktree = project
252 .visible_worktrees(cx)
253 .next()
254 .context("worktree disappeared")?;
255 let repository = ProjectPath::root_path(worktree.read(cx).id());
256
257 anyhow::Ok(project.update_or_create_branch(repository, branch_to_checkout, cx))
258 })??;
259
260 branch_change_task.await?;
261
262 picker.update(&mut cx, |_, cx| {
263 cx.emit(DismissEvent);
264
265 Ok::<(), anyhow::Error>(())
266 })
267 }
268 })
269 .detach_and_prompt_err("Failed to change branch", cx, |_, _| None);
270 }
271
272 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
273 cx.emit(DismissEvent);
274 }
275
276 fn render_match(
277 &self,
278 ix: usize,
279 selected: bool,
280 _cx: &mut ViewContext<Picker<Self>>,
281 ) -> Option<Self::ListItem> {
282 let hit = &self.matches[ix];
283 let shortened_branch_name =
284 util::truncate_and_trailoff(&hit.name(), self.branch_name_trailoff_after);
285
286 Some(
287 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
288 .inset(true)
289 .spacing(ListItemSpacing::Sparse)
290 .toggle_state(selected)
291 .map(|parent| match hit {
292 BranchEntry::Branch(branch) => {
293 let highlights: Vec<_> = branch
294 .positions
295 .iter()
296 .filter(|index| index < &&self.branch_name_trailoff_after)
297 .copied()
298 .collect();
299
300 parent.child(HighlightedLabel::new(shortened_branch_name, highlights))
301 }
302 BranchEntry::NewBranch { name } => {
303 parent.child(Label::new(format!("Create branch '{name}'")))
304 }
305 }),
306 )
307 }
308
309 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
310 let label = if self.last_query.is_empty() {
311 Label::new("Recent Branches")
312 .size(LabelSize::Small)
313 .mt_1()
314 .ml_3()
315 .into_any_element()
316 } else {
317 let match_label = self.matches.is_empty().not().then(|| {
318 let suffix = if self.branch_count() == 1 { "" } else { "es" };
319 Label::new(format!("{} match{}", self.branch_count(), suffix))
320 .color(Color::Muted)
321 .size(LabelSize::Small)
322 });
323 h_flex()
324 .px_3()
325 .justify_between()
326 .child(Label::new("Branches").size(LabelSize::Small))
327 .children(match_label)
328 .into_any_element()
329 };
330 Some(v_flex().mt_1().child(label).into_any_element())
331 }
332}