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