repository_selector.rs

  1use crate::git_status_icon;
  2use git::status::{FileStatus, StatusCode, TrackedStatus, UnmergedStatus, UnmergedStatusCode};
  3use gpui::{App, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity};
  4use itertools::Itertools;
  5use picker::{Picker, PickerDelegate, PickerEditorPosition};
  6use project::{Project, git_store::Repository};
  7use std::sync::Arc;
  8use ui::{ListItem, ListItemSpacing, prelude::*};
  9use workspace::{ModalView, Workspace};
 10
 11pub fn register(workspace: &mut Workspace) {
 12    workspace.register_action(open);
 13}
 14
 15pub fn open(
 16    workspace: &mut Workspace,
 17    _: &zed_actions::git::SelectRepo,
 18    window: &mut Window,
 19    cx: &mut Context<Workspace>,
 20) {
 21    let project = workspace.project().clone();
 22    workspace.toggle_modal(window, cx, |window, cx| {
 23        RepositorySelector::new(project, rems(34.), window, cx)
 24    })
 25}
 26
 27pub struct RepositorySelector {
 28    width: Rems,
 29    picker: Entity<Picker<RepositorySelectorDelegate>>,
 30}
 31
 32impl RepositorySelector {
 33    pub fn new(
 34        project_handle: Entity<Project>,
 35        width: Rems,
 36        window: &mut Window,
 37        cx: &mut Context<Self>,
 38    ) -> Self {
 39        let git_store = project_handle.read(cx).git_store().clone();
 40        let repository_entries = git_store.update(cx, |git_store, _cx| {
 41            let mut repos: Vec<_> = git_store.repositories().values().cloned().collect();
 42
 43            repos.sort_by(|a, b| {
 44                a.read(_cx)
 45                    .display_name()
 46                    .to_lowercase()
 47                    .cmp(&b.read(_cx).display_name().to_lowercase())
 48            });
 49
 50            repos
 51        });
 52        let filtered_repositories = repository_entries.clone();
 53
 54        let widest_item_ix = repository_entries.iter().position_max_by(|a, b| {
 55            a.read(cx)
 56                .display_name()
 57                .len()
 58                .cmp(&b.read(cx).display_name().len())
 59        });
 60
 61        let active_repository = git_store.read(cx).active_repository();
 62        let selected_index = active_repository
 63            .as_ref()
 64            .and_then(|active| filtered_repositories.iter().position(|repo| repo == active))
 65            .unwrap_or(0);
 66        let delegate = RepositorySelectorDelegate {
 67            repository_selector: cx.entity().downgrade(),
 68            repository_entries,
 69            filtered_repositories,
 70            active_repository,
 71            selected_index,
 72        };
 73
 74        let picker = cx.new(|cx| {
 75            Picker::uniform_list(delegate, window, cx)
 76                .widest_item(widest_item_ix)
 77                .max_height(Some(rems(20.).into()))
 78                .show_scrollbar(true)
 79        });
 80
 81        RepositorySelector { picker, width }
 82    }
 83}
 84
 85//pub(crate) fn filtered_repository_entries(
 86//    git_store: &GitStore,
 87//    cx: &App,
 88//) -> Vec<Entity<Repository>> {
 89//    let repositories = git_store
 90//        .repositories()
 91//        .values()
 92//        .sorted_by_key(|repo| {
 93//            let repo = repo.read(cx);
 94//            (
 95//                repo.dot_git_abs_path.clone(),
 96//                repo.worktree_abs_path.clone(),
 97//            )
 98//        })
 99//        .collect::<Vec<&Entity<Repository>>>();
100//
101//    repositories
102//        .chunk_by(|a, b| a.read(cx).dot_git_abs_path == b.read(cx).dot_git_abs_path)
103//        .flat_map(|chunk| {
104//            let has_non_single_file_worktree = chunk
105//                .iter()
106//                .any(|repo| !repo.read(cx).is_from_single_file_worktree);
107//            chunk.iter().filter(move |repo| {
108//                // Remove any entry that comes from a single file worktree and represents a repository that is also represented by a non-single-file worktree.
109//                !repo.read(cx).is_from_single_file_worktree || !has_non_single_file_worktree
110//            })
111//        })
112//        .map(|&repo| repo.clone())
113//        .collect()
114//}
115
116impl EventEmitter<DismissEvent> for RepositorySelector {}
117
118impl Focusable for RepositorySelector {
119    fn focus_handle(&self, cx: &App) -> FocusHandle {
120        self.picker.focus_handle(cx)
121    }
122}
123
124impl Render for RepositorySelector {
125    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
126        div()
127            .key_context("GitRepositorySelector")
128            .w(self.width)
129            .child(self.picker.clone())
130    }
131}
132
133impl ModalView for RepositorySelector {}
134
135pub struct RepositorySelectorDelegate {
136    repository_selector: WeakEntity<RepositorySelector>,
137    repository_entries: Vec<Entity<Repository>>,
138    filtered_repositories: Vec<Entity<Repository>>,
139    active_repository: Option<Entity<Repository>>,
140    selected_index: usize,
141}
142
143impl RepositorySelectorDelegate {
144    pub fn update_repository_entries(&mut self, all_repositories: Vec<Entity<Repository>>) {
145        self.repository_entries = all_repositories.clone();
146        self.filtered_repositories = all_repositories;
147        self.selected_index = self
148            .active_repository
149            .as_ref()
150            .and_then(|active| {
151                self.filtered_repositories
152                    .iter()
153                    .position(|repo| repo == active)
154            })
155            .unwrap_or(0);
156    }
157}
158
159impl PickerDelegate for RepositorySelectorDelegate {
160    type ListItem = ListItem;
161
162    fn match_count(&self) -> usize {
163        self.filtered_repositories.len()
164    }
165
166    fn selected_index(&self) -> usize {
167        self.selected_index
168    }
169
170    fn set_selected_index(
171        &mut self,
172        ix: usize,
173        _window: &mut Window,
174        cx: &mut Context<Picker<Self>>,
175    ) {
176        self.selected_index = ix.min(self.filtered_repositories.len().saturating_sub(1));
177        cx.notify();
178    }
179
180    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
181        "Select a repository...".into()
182    }
183
184    fn editor_position(&self) -> PickerEditorPosition {
185        PickerEditorPosition::End
186    }
187
188    fn update_matches(
189        &mut self,
190        query: String,
191        window: &mut Window,
192        cx: &mut Context<Picker<Self>>,
193    ) -> Task<()> {
194        let all_repositories = self.repository_entries.clone();
195
196        let repo_names: Vec<(Entity<Repository>, String)> = all_repositories
197            .iter()
198            .map(|repo| (repo.clone(), repo.read(cx).display_name().to_lowercase()))
199            .collect();
200
201        cx.spawn_in(window, async move |this, cx| {
202            let filtered_repositories = cx
203                .background_spawn(async move {
204                    if query.is_empty() {
205                        all_repositories
206                    } else {
207                        let query_lower = query.to_lowercase();
208                        repo_names
209                            .into_iter()
210                            .filter(|(_, display_name)| display_name.contains(&query_lower))
211                            .map(|(repo, _)| repo)
212                            .collect()
213                    }
214                })
215                .await;
216
217            this.update_in(cx, |this, window, cx| {
218                let mut sorted_repositories = filtered_repositories;
219                sorted_repositories.sort_by(|a, b| {
220                    a.read(cx)
221                        .display_name()
222                        .to_lowercase()
223                        .cmp(&b.read(cx).display_name().to_lowercase())
224                });
225                let selected_index = this
226                    .delegate
227                    .active_repository
228                    .as_ref()
229                    .and_then(|active| sorted_repositories.iter().position(|repo| repo == active))
230                    .unwrap_or(0);
231                this.delegate.filtered_repositories = sorted_repositories;
232                this.delegate.set_selected_index(selected_index, window, cx);
233                cx.notify();
234            })
235            .ok();
236        })
237    }
238
239    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
240        let Some(selected_repo) = self.filtered_repositories.get(self.selected_index) else {
241            return;
242        };
243        selected_repo.update(cx, |selected_repo, cx| {
244            selected_repo.set_as_active_repository(cx)
245        });
246        self.dismissed(window, cx);
247    }
248
249    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
250        self.repository_selector
251            .update(cx, |_this, cx| cx.emit(DismissEvent))
252            .ok();
253    }
254
255    fn render_match(
256        &self,
257        ix: usize,
258        selected: bool,
259        _window: &mut Window,
260        cx: &mut Context<Picker<Self>>,
261    ) -> Option<Self::ListItem> {
262        let repo_info = self.filtered_repositories.get(ix)?;
263        let repo = repo_info.read(cx);
264        let display_name = repo.display_name();
265        let summary = repo.status_summary();
266        let is_active = self
267            .active_repository
268            .as_ref()
269            .is_some_and(|active| active == repo_info);
270
271        let mut item = ListItem::new(ix)
272            .inset(true)
273            .spacing(ListItemSpacing::Sparse)
274            .toggle_state(selected)
275            .child(
276                h_flex()
277                    .gap_1()
278                    .child(Label::new(display_name))
279                    .when(is_active, |this| {
280                        this.child(
281                            Icon::new(IconName::Check)
282                                .size(IconSize::Small)
283                                .color(Color::Accent),
284                        )
285                    }),
286            );
287
288        if summary.count > 0 {
289            let status = if summary.conflict > 0 {
290                FileStatus::Unmerged(UnmergedStatus {
291                    first_head: UnmergedStatusCode::Updated,
292                    second_head: UnmergedStatusCode::Updated,
293                })
294            } else if summary.worktree.deleted > 0 || summary.index.deleted > 0 {
295                FileStatus::Tracked(TrackedStatus {
296                    index_status: StatusCode::Deleted,
297                    worktree_status: StatusCode::Unmodified,
298                })
299            } else if summary.worktree.modified > 0 || summary.index.modified > 0 {
300                FileStatus::Tracked(TrackedStatus {
301                    index_status: StatusCode::Modified,
302                    worktree_status: StatusCode::Unmodified,
303                })
304            } else {
305                FileStatus::Tracked(TrackedStatus {
306                    index_status: StatusCode::Added,
307                    worktree_status: StatusCode::Unmodified,
308                })
309            };
310            item = item.end_slot(div().pr_2().child(git_status_icon(status)));
311        }
312
313        Some(item)
314    }
315}