project_dropdown.rs

  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}