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