lib.rs

  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(&current_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(&current_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(&current_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}