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}