1use anyhow::{anyhow, bail, Result};
2use fs::repository::Branch;
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions, rems, AnyElement, AppContext, DismissEvent, Element, EventEmitter, FocusHandle,
6 FocusableView, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled,
7 Subscription, Task, View, ViewContext, VisualContext, WindowContext,
8};
9use picker::{Picker, PickerDelegate};
10use std::{ops::Not, sync::Arc};
11use ui::{
12 h_flex, v_flex, Button, ButtonCommon, Clickable, Color, HighlightedLabel, Label, LabelCommon,
13 LabelSize, ListItem, ListItemSpacing, Selectable,
14};
15use util::ResultExt;
16use workspace::{ModalView, Toast, Workspace};
17
18actions!(branches, [OpenRecent]);
19
20pub fn init(cx: &mut AppContext) {
21 cx.observe_new_views(|workspace: &mut Workspace, _| {
22 workspace.register_action(|workspace, action, cx| {
23 BranchList::toggle_modal(workspace, action, cx).log_err();
24 });
25 })
26 .detach();
27}
28
29pub struct BranchList {
30 pub picker: View<Picker<BranchListDelegate>>,
31 rem_width: f32,
32 _subscription: Subscription,
33}
34
35impl BranchList {
36 fn new(delegate: BranchListDelegate, rem_width: f32, cx: &mut ViewContext<Self>) -> Self {
37 let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
38 let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
39 Self {
40 picker,
41 rem_width,
42 _subscription,
43 }
44 }
45 fn toggle_modal(
46 workspace: &mut Workspace,
47 _: &OpenRecent,
48 cx: &mut ViewContext<Workspace>,
49 ) -> Result<()> {
50 // Modal branch picker has a longer trailoff than a popover one.
51 let delegate = BranchListDelegate::new(workspace, cx.view().clone(), 70, cx)?;
52 workspace.toggle_modal(cx, |cx| BranchList::new(delegate, 34., cx));
53
54 Ok(())
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
79pub fn build_branch_list(
80 workspace: View<Workspace>,
81 cx: &mut WindowContext<'_>,
82) -> Result<View<BranchList>> {
83 let delegate = workspace.update(cx, |workspace, cx| {
84 BranchListDelegate::new(workspace, cx.view().clone(), 29, cx)
85 })?;
86 Ok(cx.new_view(move |cx| BranchList::new(delegate, 20., cx)))
87}
88
89pub struct BranchListDelegate {
90 matches: Vec<StringMatch>,
91 all_branches: Vec<Branch>,
92 workspace: View<Workspace>,
93 selected_index: usize,
94 last_query: String,
95 /// Max length of branch name before we truncate it and add a trailing `...`.
96 branch_name_trailoff_after: usize,
97}
98
99impl BranchListDelegate {
100 fn new(
101 workspace: &Workspace,
102 handle: View<Workspace>,
103 branch_name_trailoff_after: usize,
104 cx: &AppContext,
105 ) -> Result<Self> {
106 let project = workspace.project().read(&cx);
107 let Some(worktree) = project.visible_worktrees(cx).next() else {
108 bail!("Cannot update branch list as there are no visible worktrees")
109 };
110
111 let mut cwd = worktree.read(cx).abs_path().to_path_buf();
112 cwd.push(".git");
113 let Some(repo) = project.fs().open_repo(&cwd) else {
114 bail!("Project does not have associated git repository.")
115 };
116 let all_branches = repo.lock().branches()?;
117 Ok(Self {
118 matches: vec![],
119 workspace: handle,
120 all_branches,
121 selected_index: 0,
122 last_query: Default::default(),
123 branch_name_trailoff_after,
124 })
125 }
126
127 fn display_error_toast(&self, message: String, cx: &mut WindowContext<'_>) {
128 const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
129 self.workspace.update(cx, |model, ctx| {
130 model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
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() && 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.unix_timestamp.cmp(&lhs.unix_timestamp)
164 });
165 branches.truncate(RECENT_BRANCHES_COUNT);
166 branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
167 }
168 branches
169 .into_iter()
170 .enumerate()
171 .map(|(ix, command)| StringMatchCandidate {
172 id: ix,
173 char_bag: command.name.chars().collect(),
174 string: command.name.into(),
175 })
176 .collect::<Vec<StringMatchCandidate>>()
177 });
178 let Some(candidates) = candidates.log_err() else {
179 return;
180 };
181 let matches = if query.is_empty() {
182 candidates
183 .into_iter()
184 .enumerate()
185 .map(|(index, candidate)| StringMatch {
186 candidate_id: index,
187 string: candidate.string,
188 positions: Vec::new(),
189 score: 0.0,
190 })
191 .collect()
192 } else {
193 fuzzy::match_strings(
194 &candidates,
195 &query,
196 true,
197 10000,
198 &Default::default(),
199 cx.background_executor().clone(),
200 )
201 .await
202 };
203 picker
204 .update(&mut cx, |picker, _| {
205 let delegate = &mut picker.delegate;
206 delegate.matches = matches;
207 if delegate.matches.is_empty() {
208 delegate.selected_index = 0;
209 } else {
210 delegate.selected_index =
211 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
212 }
213 delegate.last_query = query;
214 })
215 .log_err();
216 })
217 }
218
219 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
220 let current_pick = self.selected_index();
221 let Some(current_pick) = self
222 .matches
223 .get(current_pick)
224 .map(|pick| pick.string.clone())
225 else {
226 return;
227 };
228 cx.spawn(|picker, mut cx| async move {
229 picker
230 .update(&mut cx, |this, cx| {
231 let project = this.delegate.workspace.read(cx).project().read(cx);
232 let mut cwd = project
233 .visible_worktrees(cx)
234 .next()
235 .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
236 .read(cx)
237 .abs_path()
238 .to_path_buf();
239 cwd.push(".git");
240 let status = project
241 .fs()
242 .open_repo(&cwd)
243 .ok_or_else(|| {
244 anyhow!(
245 "Could not open repository at path `{}`",
246 cwd.as_os_str().to_string_lossy()
247 )
248 })?
249 .lock()
250 .change_branch(¤t_pick);
251 if status.is_err() {
252 this.delegate.display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
253 status?;
254 }
255 cx.emit(DismissEvent);
256
257 Ok::<(), anyhow::Error>(())
258 })
259 .log_err();
260 })
261 .detach();
262 }
263
264 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
265 cx.emit(DismissEvent);
266 }
267
268 fn render_match(
269 &self,
270 ix: usize,
271 selected: bool,
272 _cx: &mut ViewContext<Picker<Self>>,
273 ) -> Option<Self::ListItem> {
274 let hit = &self.matches[ix];
275 let shortened_branch_name =
276 util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
277 let highlights: Vec<_> = hit
278 .positions
279 .iter()
280 .filter(|index| index < &&self.branch_name_trailoff_after)
281 .copied()
282 .collect();
283 Some(
284 ListItem::new(SharedString::from(format!("vcs-menu-{ix}")))
285 .inset(true)
286 .spacing(ListItemSpacing::Sparse)
287 .selected(selected)
288 .start_slot(HighlightedLabel::new(shortened_branch_name, highlights)),
289 )
290 }
291 fn render_header(&self, _: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
292 let label = if self.last_query.is_empty() {
293 h_flex()
294 .ml_3()
295 .child(Label::new("Recent Branches").size(LabelSize::Small))
296 } else {
297 let match_label = self.matches.is_empty().not().then(|| {
298 let suffix = if self.matches.len() == 1 { "" } else { "es" };
299 Label::new(format!("{} match{}", self.matches.len(), suffix))
300 .color(Color::Muted)
301 .size(LabelSize::Small)
302 });
303 h_flex()
304 .px_3()
305 .h_full()
306 .justify_between()
307 .child(Label::new("Branches").size(LabelSize::Small))
308 .children(match_label)
309 };
310 Some(label.mt_1().into_any())
311 }
312 fn render_footer(&self, cx: &mut ViewContext<Picker<Self>>) -> Option<AnyElement> {
313 if self.last_query.is_empty() {
314 return None;
315 }
316
317 Some(
318 h_flex().mr_3().pb_2().child(h_flex().w_full()).child(
319 Button::new("branch-picker-create-branch-button", "Create branch").on_click(
320 cx.listener(|_, _, cx| {
321 cx.spawn(|picker, mut cx| async move {
322 picker.update(&mut cx, |this, cx| {
323 let project = this.delegate.workspace.read(cx).project().read(cx);
324 let current_pick = &this.delegate.last_query;
325 let mut cwd = project
326 .visible_worktrees(cx)
327 .next()
328 .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
329 .read(cx)
330 .abs_path()
331 .to_path_buf();
332 cwd.push(".git");
333 let repo = project
334 .fs()
335 .open_repo(&cwd)
336 .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
337 let repo = repo
338 .lock();
339 let status = repo
340 .create_branch(¤t_pick);
341 if status.is_err() {
342 this.delegate.display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
343 status?;
344 }
345 let status = repo.change_branch(¤t_pick);
346 if status.is_err() {
347 this.delegate.display_error_toast(format!("Failed to check branch '{current_pick}', check for conflicts or unstashed files"), cx);
348 status?;
349 }
350 this.cancel(&Default::default(), cx);
351 Ok::<(), anyhow::Error>(())
352 })
353
354 }).detach_and_log_err(cx);
355 }),
356 ).style(ui::ButtonStyle::Filled)).into_any_element(),
357 )
358 }
359}