1use anyhow::{anyhow, bail, Result};
2use fs::repository::Branch;
3use fuzzy::{StringMatch, StringMatchCandidate};
4use gpui::{
5 actions,
6 elements::*,
7 platform::{CursorStyle, MouseButton},
8 AppContext, MouseState, Task, ViewContext, ViewHandle,
9};
10use picker::{Picker, PickerDelegate, PickerEvent};
11use std::{ops::Not, sync::Arc};
12use util::ResultExt;
13use workspace::{Toast, Workspace};
14
15actions!(branches, [OpenRecent]);
16
17pub fn init(cx: &mut AppContext) {
18 Picker::<BranchListDelegate>::init(cx);
19 cx.add_action(toggle);
20}
21pub type BranchList = Picker<BranchListDelegate>;
22
23pub fn build_branch_list(
24 workspace: ViewHandle<Workspace>,
25 cx: &mut ViewContext<BranchList>,
26) -> Result<BranchList> {
27 let delegate = workspace.read_with(cx, |workspace, cx| {
28 BranchListDelegate::new(workspace, cx.handle(), 29, cx)
29 })?;
30
31 Ok(Picker::new(delegate, cx).with_theme(|theme| theme.picker.clone()))
32}
33
34fn toggle(
35 workspace: &mut Workspace,
36 _: &OpenRecent,
37 cx: &mut ViewContext<Workspace>,
38) -> Result<()> {
39 // Modal branch picker has a longer trailoff than a popover one.
40 let delegate = BranchListDelegate::new(workspace, cx.handle(), 70, cx)?;
41 workspace.toggle_modal(cx, |_, cx| {
42 cx.add_view(|cx| {
43 Picker::new(delegate, cx)
44 .with_theme(|theme| theme.picker.clone())
45 .with_max_size(800., 1200.)
46 })
47 });
48
49 Ok(())
50}
51
52pub struct BranchListDelegate {
53 matches: Vec<StringMatch>,
54 all_branches: Vec<Branch>,
55 workspace: ViewHandle<Workspace>,
56 selected_index: usize,
57 last_query: String,
58 /// Max length of branch name before we truncate it and add a trailing `...`.
59 branch_name_trailoff_after: usize,
60}
61
62impl BranchListDelegate {
63 fn new(
64 workspace: &Workspace,
65 handle: ViewHandle<Workspace>,
66 branch_name_trailoff_after: usize,
67 cx: &AppContext,
68 ) -> Result<Self> {
69 let project = workspace.project().read(&cx);
70 let Some(worktree) = project.visible_worktrees(cx).next() else {
71 bail!("Cannot update branch list as there are no visible worktrees")
72 };
73
74 let mut cwd = worktree.read(cx).abs_path().to_path_buf();
75 cwd.push(".git");
76 let Some(repo) = project.fs().open_repo(&cwd) else {
77 bail!("Project does not have associated git repository.")
78 };
79 let all_branches = repo.lock().branches()?;
80 Ok(Self {
81 matches: vec![],
82 workspace: handle,
83 all_branches,
84 selected_index: 0,
85 last_query: Default::default(),
86 branch_name_trailoff_after,
87 })
88 }
89
90 fn display_error_toast(&self, message: String, cx: &mut ViewContext<BranchList>) {
91 const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
92 self.workspace.update(cx, |model, ctx| {
93 model.show_toast(Toast::new(GIT_CHECKOUT_FAILURE_ID, message), ctx)
94 });
95 }
96}
97
98impl PickerDelegate for BranchListDelegate {
99 fn placeholder_text(&self) -> Arc<str> {
100 "Select branch...".into()
101 }
102
103 fn match_count(&self) -> usize {
104 self.matches.len()
105 }
106
107 fn selected_index(&self) -> usize {
108 self.selected_index
109 }
110
111 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
112 self.selected_index = ix;
113 }
114
115 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
116 cx.spawn(move |picker, mut cx| async move {
117 let candidates = picker.read_with(&mut cx, |view, _| {
118 const RECENT_BRANCHES_COUNT: usize = 10;
119 let mut branches = view.delegate().all_branches.clone();
120 if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
121 // Truncate list of recent branches
122 // Do a partial sort to show recent-ish branches first.
123 branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
124 rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
125 });
126 branches.truncate(RECENT_BRANCHES_COUNT);
127 branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
128 }
129 branches
130 .into_iter()
131 .enumerate()
132 .map(|(ix, command)| StringMatchCandidate {
133 id: ix,
134 char_bag: command.name.chars().collect(),
135 string: command.name.into(),
136 })
137 .collect::<Vec<StringMatchCandidate>>()
138 });
139 let Some(candidates) = candidates.log_err() else {
140 return;
141 };
142 let matches = if query.is_empty() {
143 candidates
144 .into_iter()
145 .enumerate()
146 .map(|(index, candidate)| StringMatch {
147 candidate_id: index,
148 string: candidate.string,
149 positions: Vec::new(),
150 score: 0.0,
151 })
152 .collect()
153 } else {
154 fuzzy::match_strings(
155 &candidates,
156 &query,
157 true,
158 10000,
159 &Default::default(),
160 cx.background(),
161 )
162 .await
163 };
164 picker
165 .update(&mut cx, |picker, _| {
166 let delegate = picker.delegate_mut();
167 delegate.matches = matches;
168 if delegate.matches.is_empty() {
169 delegate.selected_index = 0;
170 } else {
171 delegate.selected_index =
172 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
173 }
174 delegate.last_query = query;
175 })
176 .log_err();
177 })
178 }
179
180 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
181 let current_pick = self.selected_index();
182 let Some(current_pick) = self
183 .matches
184 .get(current_pick)
185 .map(|pick| pick.string.clone())
186 else {
187 return;
188 };
189 cx.spawn(|picker, mut cx| async move {
190 picker
191 .update(&mut cx, |this, cx| {
192 let project = this.delegate().workspace.read(cx).project().read(cx);
193 let mut cwd = project
194 .visible_worktrees(cx)
195 .next()
196 .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
197 .read(cx)
198 .abs_path()
199 .to_path_buf();
200 cwd.push(".git");
201 let status = project
202 .fs()
203 .open_repo(&cwd)
204 .ok_or_else(|| {
205 anyhow!(
206 "Could not open repository at path `{}`",
207 cwd.as_os_str().to_string_lossy()
208 )
209 })?
210 .lock()
211 .change_branch(¤t_pick);
212 if status.is_err() {
213 this.delegate().display_error_toast(format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"), cx);
214 status?;
215 }
216 cx.emit(PickerEvent::Dismiss);
217
218 Ok::<(), anyhow::Error>(())
219 })
220 .log_err();
221 })
222 .detach();
223 }
224
225 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
226 cx.emit(PickerEvent::Dismiss);
227 }
228
229 fn render_match(
230 &self,
231 ix: usize,
232 mouse_state: &mut MouseState,
233 selected: bool,
234 cx: &gpui::AppContext,
235 ) -> AnyElement<Picker<Self>> {
236 let theme = &theme::current(cx);
237 let hit = &self.matches[ix];
238 let shortened_branch_name =
239 util::truncate_and_trailoff(&hit.string, self.branch_name_trailoff_after);
240 let highlights = hit
241 .positions
242 .iter()
243 .copied()
244 .filter(|index| index < &self.branch_name_trailoff_after)
245 .collect();
246 let style = theme.picker.item.in_state(selected).style_for(mouse_state);
247 Flex::row()
248 .with_child(
249 Label::new(shortened_branch_name.clone(), style.label.clone())
250 .with_highlights(highlights)
251 .contained()
252 .aligned()
253 .left(),
254 )
255 .contained()
256 .with_style(style.container)
257 .constrained()
258 .with_height(theme.collab_panel.tabbed_modal.row_height)
259 .into_any()
260 }
261 fn render_header(
262 &self,
263 cx: &mut ViewContext<Picker<Self>>,
264 ) -> Option<AnyElement<Picker<Self>>> {
265 let theme = &theme::current(cx);
266 let style = theme.picker.header.clone();
267 let label = if self.last_query.is_empty() {
268 Flex::row()
269 .with_child(Label::new("Recent branches", style.label.clone()))
270 .contained()
271 .with_style(style.container)
272 } else {
273 Flex::row()
274 .with_child(Label::new("Branches", style.label.clone()))
275 .with_children(self.matches.is_empty().not().then(|| {
276 let suffix = if self.matches.len() == 1 { "" } else { "es" };
277 Label::new(
278 format!("{} match{}", self.matches.len(), suffix),
279 style.label,
280 )
281 .flex_float()
282 }))
283 .contained()
284 .with_style(style.container)
285 };
286 Some(label.into_any())
287 }
288 fn render_footer(
289 &self,
290 cx: &mut ViewContext<Picker<Self>>,
291 ) -> Option<AnyElement<Picker<Self>>> {
292 if !self.last_query.is_empty() {
293 let theme = &theme::current(cx);
294 let style = theme.picker.footer.clone();
295 enum BranchCreateButton {}
296 Some(
297 Flex::row().with_child(MouseEventHandler::new::<BranchCreateButton, _>(0, cx, |state, _| {
298 let style = style.style_for(state);
299 Label::new("Create branch", style.label.clone())
300 .contained()
301 .with_style(style.container)
302 })
303 .with_cursor_style(CursorStyle::PointingHand)
304 .on_down(MouseButton::Left, |_, _, cx| {
305 cx.spawn(|picker, mut cx| async move {
306 picker.update(&mut cx, |this, cx| {
307 let project = this.delegate().workspace.read(cx).project().read(cx);
308 let current_pick = &this.delegate().last_query;
309 let mut cwd = project
310 .visible_worktrees(cx)
311 .next()
312 .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
313 .read(cx)
314 .abs_path()
315 .to_path_buf();
316 cwd.push(".git");
317 let repo = project
318 .fs()
319 .open_repo(&cwd)
320 .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?;
321 let repo = repo
322 .lock();
323 let status = repo
324 .create_branch(¤t_pick);
325 if status.is_err() {
326 this.delegate().display_error_toast(format!("Failed to create branch '{current_pick}', check for conflicts or unstashed files"), cx);
327 status?;
328 }
329 let status = repo.change_branch(¤t_pick);
330 if status.is_err() {
331 this.delegate().display_error_toast(format!("Failed to chec branch '{current_pick}', check for conflicts or unstashed files"), cx);
332 status?;
333 }
334 cx.emit(PickerEvent::Dismiss);
335 Ok::<(), anyhow::Error>(())
336 })
337 }).detach();
338 })).aligned().right()
339 .into_any(),
340 )
341 } else {
342 None
343 }
344 }
345}