1use std::cell::RefCell;
2use std::path::PathBuf;
3use std::rc::Rc;
4
5use gpui::{
6 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
7 WeakEntity, actions,
8};
9use menu;
10use project::{Project, Worktree, git_store::Repository};
11use recent_projects::{RecentProjectEntry, delete_recent_project, get_recent_projects};
12use settings::WorktreeId;
13use ui::{ContextMenu, DocumentationAside, DocumentationSide, Tooltip, prelude::*};
14use workspace::{CloseIntent, Workspace};
15
16actions!(project_dropdown, [RemoveSelectedFolder]);
17
18const RECENT_PROJECTS_INLINE_LIMIT: usize = 5;
19
20struct ProjectEntry {
21 worktree_id: WorktreeId,
22 name: SharedString,
23 branch: Option<SharedString>,
24 is_active: bool,
25}
26
27pub struct ProjectDropdown {
28 menu: Entity<ContextMenu>,
29 workspace: WeakEntity<Workspace>,
30 worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
31 menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
32 _recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
33 _subscription: Subscription,
34}
35
36impl ProjectDropdown {
37 pub fn new(
38 project: Entity<Project>,
39 workspace: WeakEntity<Workspace>,
40 initial_active_worktree_id: Option<WorktreeId>,
41 window: &mut Window,
42 cx: &mut Context<Self>,
43 ) -> Self {
44 let menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>> = Rc::new(RefCell::new(None));
45 let worktree_ids: Rc<RefCell<Vec<WorktreeId>>> = Rc::new(RefCell::new(Vec::new()));
46 let recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>> =
47 Rc::new(RefCell::new(Vec::new()));
48
49 let menu = Self::build_menu(
50 project,
51 workspace.clone(),
52 initial_active_worktree_id,
53 menu_shell.clone(),
54 worktree_ids.clone(),
55 recent_projects.clone(),
56 window,
57 cx,
58 );
59
60 *menu_shell.borrow_mut() = Some(menu.clone());
61
62 let _subscription = cx.subscribe(&menu, |_, _, _: &DismissEvent, cx| {
63 cx.emit(DismissEvent);
64 });
65
66 let recent_projects_for_fetch = recent_projects.clone();
67 let menu_shell_for_fetch = menu_shell.clone();
68 let workspace_for_fetch = workspace.clone();
69
70 cx.spawn_in(window, async move |_this, cx| {
71 let current_workspace_id = cx
72 .update(|_, cx| {
73 workspace_for_fetch
74 .upgrade()
75 .and_then(|ws| ws.read(cx).database_id())
76 })
77 .ok()
78 .flatten();
79
80 let projects = get_recent_projects(current_workspace_id, None).await;
81
82 cx.update(|window, cx| {
83 *recent_projects_for_fetch.borrow_mut() = projects;
84
85 if let Some(menu_entity) = menu_shell_for_fetch.borrow().clone() {
86 menu_entity.update(cx, |menu, cx| {
87 menu.rebuild(window, cx);
88 });
89 }
90 })
91 .ok()
92 })
93 .detach();
94
95 Self {
96 menu,
97 workspace,
98 worktree_ids,
99 menu_shell,
100 _recent_projects: recent_projects,
101 _subscription,
102 }
103 }
104
105 fn build_menu(
106 project: Entity<Project>,
107 workspace: WeakEntity<Workspace>,
108 initial_active_worktree_id: Option<WorktreeId>,
109 menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
110 worktree_ids: Rc<RefCell<Vec<WorktreeId>>>,
111 recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
112 window: &mut Window,
113 cx: &mut Context<Self>,
114 ) -> Entity<ContextMenu> {
115 ContextMenu::build_persistent(window, cx, move |menu, window, cx| {
116 let active_worktree_id = if menu_shell.borrow().is_some() {
117 workspace
118 .upgrade()
119 .and_then(|ws| ws.read(cx).active_worktree_override())
120 .or(initial_active_worktree_id)
121 } else {
122 initial_active_worktree_id
123 };
124
125 let entries = Self::get_project_entries(&project, active_worktree_id, cx);
126
127 // Update the worktree_ids list so we can map selected_index -> worktree_id.
128 {
129 let mut ids = worktree_ids.borrow_mut();
130 ids.clear();
131 for entry in &entries {
132 ids.push(entry.worktree_id);
133 }
134 }
135
136 let mut menu = menu.header("Open Folders");
137
138 for entry in entries {
139 let worktree_id = entry.worktree_id;
140 let name = entry.name.clone();
141 let branch = entry.branch.clone();
142 let is_active = entry.is_active;
143
144 let workspace_for_select = workspace.clone();
145 let workspace_for_remove = workspace.clone();
146 let menu_shell_for_remove = menu_shell.clone();
147
148 menu = menu.custom_entry(
149 move |_window, _cx| {
150 let name = name.clone();
151 let branch = branch.clone();
152 let workspace_for_remove = workspace_for_remove.clone();
153 let menu_shell = menu_shell_for_remove.clone();
154
155 h_flex()
156 .group(name.clone())
157 .w_full()
158 .justify_between()
159 .child(
160 h_flex()
161 .gap_1()
162 .child(
163 Label::new(name.clone())
164 .when(is_active, |label| label.color(Color::Accent)),
165 )
166 .when_some(branch, |this, branch| {
167 this.child(Label::new(branch).color(Color::Muted))
168 }),
169 )
170 .child(
171 IconButton::new(
172 ("remove", worktree_id.to_usize()),
173 IconName::Close,
174 )
175 .visible_on_hover(name)
176 .icon_size(IconSize::Small)
177 .icon_color(Color::Muted)
178 .tooltip({
179 let menu_shell = menu_shell.clone();
180 move |window, cx| {
181 if let Some(menu_entity) = menu_shell.borrow().as_ref() {
182 let focus_handle = menu_entity.focus_handle(cx);
183 Tooltip::for_action_in(
184 "Remove Folder",
185 &RemoveSelectedFolder,
186 &focus_handle,
187 cx,
188 )
189 } else {
190 Tooltip::text("Remove Folder")(window, cx)
191 }
192 }
193 })
194 .on_click({
195 let workspace = workspace_for_remove;
196 move |_, window, cx| {
197 Self::handle_remove(
198 workspace.clone(),
199 worktree_id,
200 window,
201 cx,
202 );
203
204 if let Some(menu_entity) = menu_shell.borrow().clone() {
205 menu_entity.update(cx, |menu, cx| {
206 menu.rebuild(window, cx);
207 });
208 }
209 }
210 }),
211 )
212 .into_any_element()
213 },
214 move |window, cx| {
215 Self::handle_select(workspace_for_select.clone(), worktree_id, window, cx);
216 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
217 },
218 );
219 }
220
221 menu = menu.separator();
222
223 let recent = recent_projects.borrow();
224
225 if !recent.is_empty() {
226 menu = menu.header("Recent Projects");
227
228 let enter_hint = window.keystroke_text_for(&menu::Confirm);
229 let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm);
230
231 let inline_count = recent.len().min(RECENT_PROJECTS_INLINE_LIMIT);
232 for entry in recent.iter().take(inline_count) {
233 menu = Self::add_recent_project_entry(
234 menu,
235 entry.clone(),
236 workspace.clone(),
237 menu_shell.clone(),
238 recent_projects.clone(),
239 &enter_hint,
240 &cmd_enter_hint,
241 );
242 }
243
244 if recent.len() > RECENT_PROJECTS_INLINE_LIMIT {
245 let remaining_projects: Vec<RecentProjectEntry> = recent
246 .iter()
247 .skip(RECENT_PROJECTS_INLINE_LIMIT)
248 .cloned()
249 .collect();
250 let workspace_for_submenu = workspace.clone();
251 let menu_shell_for_submenu = menu_shell.clone();
252 let recent_projects_for_submenu = recent_projects.clone();
253
254 menu = menu.submenu("View More…", move |submenu, window, _cx| {
255 let enter_hint = window.keystroke_text_for(&menu::Confirm);
256 let cmd_enter_hint = window.keystroke_text_for(&menu::SecondaryConfirm);
257
258 let mut submenu = submenu;
259 for entry in &remaining_projects {
260 submenu = Self::add_recent_project_entry(
261 submenu,
262 entry.clone(),
263 workspace_for_submenu.clone(),
264 menu_shell_for_submenu.clone(),
265 recent_projects_for_submenu.clone(),
266 &enter_hint,
267 &cmd_enter_hint,
268 );
269 }
270 submenu
271 });
272 }
273
274 menu = menu.separator();
275 }
276 drop(recent);
277
278 menu.action(
279 "Add Folder to Workspace",
280 workspace::AddFolderToProject.boxed_clone(),
281 )
282 })
283 }
284
285 fn add_recent_project_entry(
286 menu: ContextMenu,
287 entry: RecentProjectEntry,
288 workspace: WeakEntity<Workspace>,
289 menu_shell: Rc<RefCell<Option<Entity<ContextMenu>>>>,
290 recent_projects: Rc<RefCell<Vec<RecentProjectEntry>>>,
291 enter_hint: &str,
292 cmd_enter_hint: &str,
293 ) -> ContextMenu {
294 let name = entry.name.clone();
295 let full_path = entry.full_path.clone();
296 let paths = entry.paths.clone();
297 let workspace_id = entry.workspace_id;
298
299 let element_id = format!("remove-recent-{}", full_path);
300
301 let enter_hint = enter_hint.to_string();
302 let cmd_enter_hint = cmd_enter_hint.to_string();
303 let full_path_for_docs = full_path;
304 let docs_aside = DocumentationAside {
305 side: DocumentationSide::Right,
306 render: Rc::new(move |cx| {
307 v_flex()
308 .gap_1()
309 .child(Label::new(full_path_for_docs.clone()).size(LabelSize::Small))
310 .child(
311 h_flex()
312 .pt_1()
313 .gap_1()
314 .border_t_1()
315 .border_color(cx.theme().colors().border_variant)
316 .child(
317 Label::new(format!("{} reuses this window", enter_hint))
318 .size(LabelSize::Small)
319 .color(Color::Muted),
320 )
321 .child(
322 Label::new(format!("{} opens a new one", cmd_enter_hint))
323 .size(LabelSize::Small)
324 .color(Color::Muted),
325 ),
326 )
327 .into_any_element()
328 }),
329 };
330
331 menu.custom_entry_with_docs(
332 {
333 let menu_shell_for_delete = menu_shell;
334 let recent_projects_for_delete = recent_projects;
335
336 move |_window, _cx| {
337 let name = name.clone();
338 let menu_shell = menu_shell_for_delete.clone();
339 let recent_projects = recent_projects_for_delete.clone();
340
341 h_flex()
342 .group(name.clone())
343 .w_full()
344 .justify_between()
345 .child(Label::new(name.clone()))
346 .child(
347 IconButton::new(element_id.clone(), IconName::Close)
348 .visible_on_hover(name)
349 .icon_size(IconSize::Small)
350 .icon_color(Color::Muted)
351 .tooltip(Tooltip::text("Remove from Recent Projects"))
352 .on_click({
353 move |_, window, cx| {
354 let menu_shell = menu_shell.clone();
355 let recent_projects = recent_projects.clone();
356
357 recent_projects
358 .borrow_mut()
359 .retain(|p| p.workspace_id != workspace_id);
360
361 if let Some(menu_entity) = menu_shell.borrow().clone() {
362 menu_entity.update(cx, |menu, cx| {
363 menu.rebuild(window, cx);
364 });
365 }
366
367 cx.background_spawn(async move {
368 delete_recent_project(workspace_id).await;
369 })
370 .detach();
371 }
372 }),
373 )
374 .into_any_element()
375 }
376 },
377 move |window, cx| {
378 let create_new_window = window.modifiers().platform;
379 Self::open_recent_project(
380 workspace.clone(),
381 paths.clone(),
382 create_new_window,
383 window,
384 cx,
385 );
386 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
387 },
388 Some(docs_aside),
389 )
390 }
391
392 fn open_recent_project(
393 workspace: WeakEntity<Workspace>,
394 paths: Vec<PathBuf>,
395 create_new_window: bool,
396 window: &mut Window,
397 cx: &mut App,
398 ) {
399 let Some(workspace) = workspace.upgrade() else {
400 return;
401 };
402
403 workspace.update(cx, |workspace, cx| {
404 if create_new_window {
405 workspace.open_workspace_for_paths(false, paths, window, cx)
406 } else {
407 cx.spawn_in(window, {
408 let paths = paths.clone();
409 async move |workspace, cx| {
410 let continue_replacing = workspace
411 .update_in(cx, |workspace, window, cx| {
412 workspace.prepare_to_close(CloseIntent::ReplaceWindow, window, cx)
413 })?
414 .await?;
415 if continue_replacing {
416 workspace
417 .update_in(cx, |workspace, window, cx| {
418 workspace.open_workspace_for_paths(true, paths, window, cx)
419 })?
420 .await
421 } else {
422 Ok(())
423 }
424 }
425 })
426 }
427 .detach_and_log_err(cx);
428 });
429 }
430
431 /// Get all projects sorted alphabetically with their branch info.
432 fn get_project_entries(
433 project: &Entity<Project>,
434 active_worktree_id: Option<WorktreeId>,
435 cx: &App,
436 ) -> Vec<ProjectEntry> {
437 let project = project.read(cx);
438 let git_store = project.git_store().read(cx);
439 let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
440
441 let mut entries: Vec<ProjectEntry> = project
442 .visible_worktrees(cx)
443 .map(|worktree| {
444 let worktree_ref = worktree.read(cx);
445 let worktree_id = worktree_ref.id();
446 let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
447
448 let branch = Self::get_branch_for_worktree(worktree_ref, &repositories, cx);
449
450 let is_active = active_worktree_id == Some(worktree_id);
451
452 ProjectEntry {
453 worktree_id,
454 name,
455 branch,
456 is_active,
457 }
458 })
459 .collect();
460
461 entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
462 entries
463 }
464
465 fn get_branch_for_worktree(
466 worktree: &Worktree,
467 repositories: &[Entity<Repository>],
468 cx: &App,
469 ) -> Option<SharedString> {
470 let worktree_abs_path = worktree.abs_path();
471
472 for repo in repositories {
473 let repo = repo.read(cx);
474 if repo.work_directory_abs_path == worktree_abs_path
475 || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
476 {
477 if let Some(branch) = &repo.branch {
478 return Some(SharedString::from(branch.name().to_string()));
479 }
480 }
481 }
482 None
483 }
484
485 fn handle_select(
486 workspace: WeakEntity<Workspace>,
487 worktree_id: WorktreeId,
488 _window: &mut Window,
489 cx: &mut App,
490 ) {
491 if let Some(workspace) = workspace.upgrade() {
492 workspace.update(cx, |workspace, cx| {
493 workspace.set_active_worktree_override(Some(worktree_id), cx);
494 });
495 }
496 }
497
498 fn handle_remove(
499 workspace: WeakEntity<Workspace>,
500 worktree_id: WorktreeId,
501 _window: &mut Window,
502 cx: &mut App,
503 ) {
504 if let Some(workspace) = workspace.upgrade() {
505 workspace.update(cx, |workspace, cx| {
506 let project = workspace.project().clone();
507
508 let current_active_id = workspace.active_worktree_override();
509 let is_removing_active = current_active_id == Some(worktree_id);
510
511 if is_removing_active {
512 let worktrees: Vec<_> = project.read(cx).visible_worktrees(cx).collect();
513
514 let mut sorted: Vec<_> = worktrees
515 .iter()
516 .map(|wt| {
517 let wt = wt.read(cx);
518 (wt.root_name().as_unix_str().to_string(), wt.id())
519 })
520 .collect();
521 sorted.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
522
523 if let Some(idx) = sorted.iter().position(|(_, id)| *id == worktree_id) {
524 let new_active_id = if idx > 0 {
525 Some(sorted[idx - 1].1)
526 } else if sorted.len() > 1 {
527 Some(sorted[1].1)
528 } else {
529 None
530 };
531
532 workspace.set_active_worktree_override(new_active_id, cx);
533 }
534 }
535
536 project.update(cx, |project, cx| {
537 project.remove_worktree(worktree_id, cx);
538 });
539 });
540 }
541 }
542
543 fn remove_selected_folder(
544 &mut self,
545 _: &RemoveSelectedFolder,
546 window: &mut Window,
547 cx: &mut Context<Self>,
548 ) {
549 let selected_index = self.menu.read(cx).selected_index();
550
551 if let Some(menu_index) = selected_index {
552 // Early return because the "Open Folders" header is index 0.
553 if menu_index == 0 {
554 return;
555 }
556
557 let entry_index = menu_index - 1;
558 let worktree_ids = self.worktree_ids.borrow();
559
560 if entry_index < worktree_ids.len() {
561 let worktree_id = worktree_ids[entry_index];
562 drop(worktree_ids);
563
564 Self::handle_remove(self.workspace.clone(), worktree_id, window, cx);
565
566 if let Some(menu_entity) = self.menu_shell.borrow().clone() {
567 menu_entity.update(cx, |menu, cx| {
568 menu.rebuild(window, cx);
569 });
570 }
571 }
572 }
573 }
574}
575
576impl Render for ProjectDropdown {
577 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
578 div()
579 .key_context("MultiProjectDropdown")
580 .track_focus(&self.focus_handle(cx))
581 .on_action(cx.listener(Self::remove_selected_folder))
582 .child(self.menu.clone())
583 }
584}
585
586impl EventEmitter<DismissEvent> for ProjectDropdown {}
587
588impl Focusable for ProjectDropdown {
589 fn focus_handle(&self, cx: &App) -> FocusHandle {
590 self.menu.focus_handle(cx)
591 }
592}