branch_list.rs

  1use anyhow::{anyhow, bail};
  2use fuzzy::{StringMatch, StringMatchCandidate};
  3use gpui::{elements::*, AppContext, MouseState, Task, ViewContext, ViewHandle};
  4use picker::{Picker, PickerDelegate, PickerEvent};
  5use std::{ops::Not, sync::Arc};
  6use util::ResultExt;
  7use workspace::{Toast, Workspace};
  8
  9pub fn init(cx: &mut AppContext) {
 10    Picker::<BranchListDelegate>::init(cx);
 11}
 12
 13pub type BranchList = Picker<BranchListDelegate>;
 14
 15pub fn build_branch_list(
 16    workspace: ViewHandle<Workspace>,
 17    cx: &mut ViewContext<BranchList>,
 18) -> BranchList {
 19    Picker::new(
 20        BranchListDelegate {
 21            matches: vec![],
 22            workspace,
 23            selected_index: 0,
 24            last_query: String::default(),
 25        },
 26        cx,
 27    )
 28    .with_theme(|theme| theme.picker.clone())
 29}
 30
 31pub struct BranchListDelegate {
 32    matches: Vec<StringMatch>,
 33    workspace: ViewHandle<Workspace>,
 34    selected_index: usize,
 35    last_query: String,
 36}
 37
 38impl PickerDelegate for BranchListDelegate {
 39    fn placeholder_text(&self) -> Arc<str> {
 40        "Select branch...".into()
 41    }
 42
 43    fn match_count(&self) -> usize {
 44        self.matches.len()
 45    }
 46
 47    fn selected_index(&self) -> usize {
 48        self.selected_index
 49    }
 50
 51    fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
 52        self.selected_index = ix;
 53    }
 54
 55    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
 56        cx.spawn(move |picker, mut cx| async move {
 57            let Some(candidates) = picker
 58                .read_with(&mut cx, |view, cx| {
 59                    let delegate = view.delegate();
 60                    let project = delegate.workspace.read(cx).project().read(&cx);
 61                    let mut cwd =
 62                    project
 63                        .visible_worktrees(cx)
 64                        .next()
 65                        .unwrap()
 66                        .read(cx)
 67                        .abs_path()
 68                        .to_path_buf();
 69                    cwd.push(".git");
 70                    let Some(repo) = project.fs().open_repo(&cwd) else {bail!("Project does not have associated git repository.")};
 71                    let mut branches = repo
 72                        .lock()
 73                        .branches()?;
 74                    const RECENT_BRANCHES_COUNT: usize = 10;
 75                    if query.is_empty() && branches.len() > RECENT_BRANCHES_COUNT {
 76                        // Truncate list of recent branches
 77                        // Do a partial sort to show recent-ish branches first.
 78                        branches.select_nth_unstable_by(RECENT_BRANCHES_COUNT - 1, |lhs, rhs| {
 79                            rhs.unix_timestamp.cmp(&lhs.unix_timestamp)
 80                        });
 81                        branches.truncate(RECENT_BRANCHES_COUNT);
 82                        branches.sort_unstable_by(|lhs, rhs| lhs.name.cmp(&rhs.name));
 83                    }
 84                    Ok(branches
 85                        .iter()
 86                        .cloned()
 87                        .enumerate()
 88                        .map(|(ix, command)| StringMatchCandidate {
 89                            id: ix,
 90                            char_bag: command.name.chars().collect(),
 91                            string: command.name.into(),
 92                        })
 93                        .collect::<Vec<_>>())
 94                })
 95                .log_err() else { return; };
 96            let Some(candidates) = candidates.log_err() else {return;};
 97            let matches = if query.is_empty() {
 98                candidates
 99                    .into_iter()
100                    .enumerate()
101                    .map(|(index, candidate)| StringMatch {
102                        candidate_id: index,
103                        string: candidate.string,
104                        positions: Vec::new(),
105                        score: 0.0,
106                    })
107                    .collect()
108            } else {
109                fuzzy::match_strings(
110                    &candidates,
111                    &query,
112                    true,
113                    10000,
114                    &Default::default(),
115                    cx.background(),
116                )
117                .await
118            };
119            picker
120                .update(&mut cx, |picker, _| {
121                    let delegate = picker.delegate_mut();
122                    delegate.matches = matches;
123                    if delegate.matches.is_empty() {
124                        delegate.selected_index = 0;
125                    } else {
126                        delegate.selected_index =
127                            core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
128                    }
129                    delegate.last_query = query;
130                })
131                .log_err();
132        })
133    }
134
135    fn confirm(&mut self, cx: &mut ViewContext<Picker<Self>>) {
136        let current_pick = self.selected_index();
137        let current_pick = self.matches[current_pick].string.clone();
138        cx.spawn(|picker, mut cx| async move {
139            picker.update(&mut cx, |this, cx| {
140                let project = this.delegate().workspace.read(cx).project().read(cx);
141                let mut cwd = project
142                .visible_worktrees(cx)
143                .next()
144                .ok_or_else(|| anyhow!("There are no visisible worktrees."))?
145                .read(cx)
146                .abs_path()
147                .to_path_buf();
148                cwd.push(".git");
149                let status = project
150                    .fs()
151                    .open_repo(&cwd)
152                    .ok_or_else(|| anyhow!("Could not open repository at path `{}`", cwd.as_os_str().to_string_lossy()))?
153                    .lock()
154                    .change_branch(&current_pick);
155                if status.is_err() {
156                    const GIT_CHECKOUT_FAILURE_ID: usize = 2048;
157                    this.delegate().workspace.update(cx, |model, ctx| {
158                        model.show_toast(
159                            Toast::new(
160                                GIT_CHECKOUT_FAILURE_ID,
161                                format!("Failed to checkout branch '{current_pick}', check for conflicts or unstashed files"),
162                            ),
163                            ctx,
164                        )
165                    });
166                    status?;
167                }
168                cx.emit(PickerEvent::Dismiss);
169
170                Ok::<(), anyhow::Error>(())
171            }).log_err();
172        }).detach();
173    }
174
175    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
176        cx.emit(PickerEvent::Dismiss);
177    }
178
179    fn render_match(
180        &self,
181        ix: usize,
182        mouse_state: &mut MouseState,
183        selected: bool,
184        cx: &gpui::AppContext,
185    ) -> AnyElement<Picker<Self>> {
186        const DISPLAYED_MATCH_LEN: usize = 29;
187        let theme = &theme::current(cx);
188        let hit = &self.matches[ix];
189        let shortened_branch_name = util::truncate_and_trailoff(&hit.string, DISPLAYED_MATCH_LEN);
190        let highlights = hit
191            .positions
192            .iter()
193            .copied()
194            .filter(|index| index < &DISPLAYED_MATCH_LEN)
195            .collect();
196        let style = theme.picker.item.in_state(selected).style_for(mouse_state);
197        Flex::row()
198            .with_child(
199                Label::new(shortened_branch_name.clone(), style.label.clone())
200                    .with_highlights(highlights)
201                    .contained()
202                    .aligned()
203                    .left(),
204            )
205            .contained()
206            .with_style(style.container)
207            .constrained()
208            .with_height(theme.contact_finder.row_height)
209            .into_any()
210    }
211    fn render_header(
212        &self,
213        cx: &mut ViewContext<Picker<Self>>,
214    ) -> Option<AnyElement<Picker<Self>>> {
215        let theme = &theme::current(cx);
216        let style = theme.picker.header.clone();
217        let label = if self.last_query.is_empty() {
218            Flex::row()
219                .with_child(Label::new("Recent branches", style.label.clone()))
220                .contained()
221                .with_style(style.container)
222        } else {
223            Flex::row()
224                .with_child(Label::new("Branches", style.label.clone()))
225                .with_children(self.matches.is_empty().not().then(|| {
226                    let suffix = if self.matches.len() == 1 { "" } else { "es" };
227                    Label::new(
228                        format!("{} match{}", self.matches.len(), suffix),
229                        style.label,
230                    )
231                    .flex_float()
232                }))
233                .contained()
234                .with_style(style.container)
235        };
236        Some(label.into_any())
237    }
238}