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