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