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