lib.rs

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