lib.rs

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