recent_projects.rs

   1mod dev_container_suggest;
   2pub mod disconnected_overlay;
   3mod remote_connections;
   4mod remote_servers;
   5pub mod sidebar_recent_projects;
   6mod ssh_config;
   7
   8use std::{
   9    path::{Path, PathBuf},
  10    sync::Arc,
  11};
  12
  13use chrono::{DateTime, Utc};
  14
  15use fs::Fs;
  16
  17#[cfg(target_os = "windows")]
  18mod wsl_picker;
  19
  20use remote::RemoteConnectionOptions;
  21pub use remote_connection::{RemoteConnectionModal, connect};
  22pub use remote_connections::{navigate_to_positions, open_remote_project};
  23
  24use disconnected_overlay::DisconnectedOverlay;
  25use fuzzy::{StringMatch, StringMatchCandidate};
  26use gpui::{
  27    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  28    Subscription, Task, WeakEntity, Window, actions, px,
  29};
  30
  31use picker::{
  32    Picker, PickerDelegate,
  33    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
  34};
  35use project::{Worktree, git_store::Repository};
  36pub use remote_connections::RemoteSettings;
  37pub use remote_servers::RemoteServerProjects;
  38use settings::{Settings, WorktreeId};
  39use ui_input::ErasedEditor;
  40use workspace::ProjectGroupKey;
  41
  42use dev_container::{DevContainerContext, find_devcontainer_configs};
  43use ui::{
  44    ContextMenu, Divider, HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, ListSubHeader,
  45    PopoverMenu, PopoverMenuHandle, TintColor, Tooltip, prelude::*,
  46};
  47use util::{ResultExt, paths::PathExt};
  48use workspace::{
  49    HistoryManager, ModalView, MultiWorkspace, OpenMode, OpenOptions, OpenVisible, PathList,
  50    SerializedWorkspaceLocation, Workspace, WorkspaceDb, WorkspaceId,
  51    notifications::DetachAndPromptErr, with_active_or_new_workspace,
  52};
  53use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
  54
  55actions!(
  56    recent_projects,
  57    [ToggleActionsMenu, RemoveSelected, AddToWorkspace,]
  58);
  59
  60#[derive(Clone, Debug)]
  61pub struct RecentProjectEntry {
  62    pub name: SharedString,
  63    pub full_path: SharedString,
  64    pub paths: Vec<PathBuf>,
  65    pub workspace_id: WorkspaceId,
  66    pub timestamp: DateTime<Utc>,
  67}
  68
  69#[derive(Clone, Debug)]
  70struct OpenFolderEntry {
  71    worktree_id: WorktreeId,
  72    name: SharedString,
  73    path: PathBuf,
  74    branch: Option<SharedString>,
  75    is_active: bool,
  76}
  77
  78#[derive(Clone, Debug)]
  79enum ProjectPickerEntry {
  80    Header(SharedString),
  81    OpenFolder { index: usize, positions: Vec<usize> },
  82    ProjectGroup(StringMatch),
  83    RecentProject(StringMatch),
  84}
  85
  86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
  87enum ProjectPickerStyle {
  88    Modal,
  89    Popover,
  90}
  91
  92pub async fn get_recent_projects(
  93    current_workspace_id: Option<WorkspaceId>,
  94    limit: Option<usize>,
  95    fs: Arc<dyn fs::Fs>,
  96    db: &WorkspaceDb,
  97) -> Vec<RecentProjectEntry> {
  98    let workspaces = db
  99        .recent_workspaces_on_disk(fs.as_ref())
 100        .await
 101        .unwrap_or_default();
 102
 103    let filtered: Vec<_> = workspaces
 104        .into_iter()
 105        .filter(|(id, _, _, _)| Some(*id) != current_workspace_id)
 106        .filter(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local))
 107        .collect();
 108
 109    let mut all_paths: Vec<PathBuf> = filtered
 110        .iter()
 111        .flat_map(|(_, _, path_list, _)| path_list.paths().iter().cloned())
 112        .collect();
 113    all_paths.sort();
 114    all_paths.dedup();
 115    let path_details =
 116        util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
 117            project::path_suffix(path, detail)
 118        });
 119    let path_detail_map: std::collections::HashMap<PathBuf, usize> =
 120        all_paths.into_iter().zip(path_details).collect();
 121
 122    let entries: Vec<RecentProjectEntry> = filtered
 123        .into_iter()
 124        .map(|(workspace_id, _, path_list, timestamp)| {
 125            let paths: Vec<PathBuf> = path_list.paths().to_vec();
 126            let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
 127
 128            let name = ordered_paths
 129                .iter()
 130                .map(|p| {
 131                    let detail = path_detail_map.get(*p).copied().unwrap_or(0);
 132                    project::path_suffix(p, detail)
 133                })
 134                .filter(|s| !s.is_empty())
 135                .collect::<Vec<_>>()
 136                .join(", ");
 137
 138            let full_path = ordered_paths
 139                .iter()
 140                .map(|p| p.to_string_lossy().to_string())
 141                .collect::<Vec<_>>()
 142                .join("\n");
 143
 144            RecentProjectEntry {
 145                name: SharedString::from(name),
 146                full_path: SharedString::from(full_path),
 147                paths,
 148                workspace_id,
 149                timestamp,
 150            }
 151        })
 152        .collect();
 153
 154    match limit {
 155        Some(n) => entries.into_iter().take(n).collect(),
 156        None => entries,
 157    }
 158}
 159
 160pub async fn delete_recent_project(workspace_id: WorkspaceId, db: &WorkspaceDb) {
 161    let _ = db.delete_workspace_by_id(workspace_id).await;
 162}
 163
 164fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
 165    let project = workspace.project().read(cx);
 166    let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
 167
 168    if visible_worktrees.len() <= 1 {
 169        return Vec::new();
 170    }
 171
 172    let active_worktree_id = if let Some(repo) = project.active_repository(cx) {
 173        let repo = repo.read(cx);
 174        let repo_path = &repo.work_directory_abs_path;
 175        project.visible_worktrees(cx).find_map(|worktree| {
 176            let worktree_path = worktree.read(cx).abs_path();
 177            (worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()))
 178                .then(|| worktree.read(cx).id())
 179        })
 180    } else {
 181        project
 182            .visible_worktrees(cx)
 183            .next()
 184            .map(|wt| wt.read(cx).id())
 185    };
 186
 187    let mut all_paths: Vec<PathBuf> = visible_worktrees
 188        .iter()
 189        .map(|wt| wt.read(cx).abs_path().to_path_buf())
 190        .collect();
 191    all_paths.sort();
 192    all_paths.dedup();
 193    let path_details =
 194        util::disambiguate::compute_disambiguation_details(&all_paths, |path, detail| {
 195            project::path_suffix(path, detail)
 196        });
 197    let path_detail_map: std::collections::HashMap<PathBuf, usize> =
 198        all_paths.into_iter().zip(path_details).collect();
 199
 200    let git_store = project.git_store().read(cx);
 201    let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
 202
 203    let mut entries: Vec<OpenFolderEntry> = visible_worktrees
 204        .into_iter()
 205        .map(|worktree| {
 206            let worktree_ref = worktree.read(cx);
 207            let worktree_id = worktree_ref.id();
 208            let path = worktree_ref.abs_path().to_path_buf();
 209            let detail = path_detail_map.get(&path).copied().unwrap_or(0);
 210            let name = SharedString::from(project::path_suffix(&path, detail));
 211            let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
 212            let is_active = active_worktree_id == Some(worktree_id);
 213            OpenFolderEntry {
 214                worktree_id,
 215                name,
 216                path,
 217                branch,
 218                is_active,
 219            }
 220        })
 221        .collect();
 222
 223    entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
 224    entries
 225}
 226
 227fn get_branch_for_worktree(
 228    worktree: &Worktree,
 229    repositories: &[Entity<Repository>],
 230    cx: &App,
 231) -> Option<SharedString> {
 232    let worktree_abs_path = worktree.abs_path();
 233    repositories
 234        .iter()
 235        .filter(|repo| {
 236            let repo_path = &repo.read(cx).work_directory_abs_path;
 237            *repo_path == worktree_abs_path || worktree_abs_path.starts_with(repo_path.as_ref())
 238        })
 239        .max_by_key(|repo| repo.read(cx).work_directory_abs_path.as_os_str().len())
 240        .and_then(|repo| {
 241            repo.read(cx)
 242                .branch
 243                .as_ref()
 244                .map(|branch| SharedString::from(branch.name().to_string()))
 245        })
 246}
 247
 248pub fn init(cx: &mut App) {
 249    #[cfg(target_os = "windows")]
 250    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
 251        let create_new_window = open_wsl.create_new_window;
 252        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 253            use gpui::PathPromptOptions;
 254            use project::DirectoryLister;
 255
 256            let paths = workspace.prompt_for_open_path(
 257                PathPromptOptions {
 258                    files: true,
 259                    directories: true,
 260                    multiple: false,
 261                    prompt: None,
 262                },
 263                DirectoryLister::Local(
 264                    workspace.project().clone(),
 265                    workspace.app_state().fs.clone(),
 266                ),
 267                window,
 268                cx,
 269            );
 270
 271            let app_state = workspace.app_state().clone();
 272            let window_handle = window.window_handle().downcast::<MultiWorkspace>();
 273
 274            cx.spawn_in(window, async move |workspace, cx| {
 275                use util::paths::SanitizedPath;
 276
 277                let Some(paths) = paths.await.log_err().flatten() else {
 278                    return;
 279                };
 280
 281                let wsl_path = paths
 282                    .iter()
 283                    .find_map(util::paths::WslPath::from_path);
 284
 285                if let Some(util::paths::WslPath { distro, path }) = wsl_path {
 286                    use remote::WslConnectionOptions;
 287
 288                    let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
 289                        distro_name: distro.to_string(),
 290                        user: None,
 291                    });
 292
 293                    let requesting_window = match create_new_window {
 294                        false => window_handle,
 295                        true => None,
 296                    };
 297
 298                    let open_options = workspace::OpenOptions {
 299                        requesting_window,
 300                        ..Default::default()
 301                    };
 302
 303                    open_remote_project(connection_options, vec![path.into()], app_state, open_options, cx).await.log_err();
 304                    return;
 305                }
 306
 307                let paths = paths
 308                    .into_iter()
 309                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
 310                    .collect::<Vec<_>>();
 311
 312                if paths.is_empty() {
 313                    let message = indoc::indoc! { r#"
 314                        Invalid path specified when trying to open a folder inside WSL.
 315
 316                        Please note that Zed currently does not support opening network share folders inside wsl.
 317                    "#};
 318
 319                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
 320                    return;
 321                }
 322
 323                workspace.update_in(cx, |workspace, window, cx| {
 324                    workspace.toggle_modal(window, cx, |window, cx| {
 325                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
 326                    });
 327                }).log_err();
 328            })
 329            .detach();
 330        });
 331    });
 332
 333    #[cfg(target_os = "windows")]
 334    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
 335        let create_new_window = open_wsl.create_new_window;
 336        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 337            let handle = cx.entity().downgrade();
 338            let fs = workspace.project().read(cx).fs().clone();
 339            workspace.toggle_modal(window, cx, |window, cx| {
 340                RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
 341            });
 342        });
 343    });
 344
 345    #[cfg(target_os = "windows")]
 346    cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
 347        let open_wsl = open_wsl.clone();
 348        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 349            let fs = workspace.project().read(cx).fs().clone();
 350            add_wsl_distro(fs, &open_wsl.distro, cx);
 351            let open_options = OpenOptions {
 352                requesting_window: window.window_handle().downcast::<MultiWorkspace>(),
 353                ..Default::default()
 354            };
 355
 356            let app_state = workspace.app_state().clone();
 357
 358            cx.spawn_in(window, async move |_, cx| {
 359                open_remote_project(
 360                    RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
 361                    open_wsl.paths,
 362                    app_state,
 363                    open_options,
 364                    cx,
 365                )
 366                .await
 367            })
 368            .detach();
 369        });
 370    });
 371
 372    cx.on_action(|open_recent: &OpenRecent, cx| {
 373        let create_new_window = open_recent.create_new_window;
 374
 375        match cx
 376            .active_window()
 377            .and_then(|w| w.downcast::<MultiWorkspace>())
 378        {
 379            Some(multi_workspace) => {
 380                cx.defer(move |cx| {
 381                    multi_workspace
 382                        .update(cx, |multi_workspace, window, cx| {
 383                            let window_project_groups: Vec<ProjectGroupKey> =
 384                                multi_workspace.project_group_keys();
 385
 386                            let workspace = multi_workspace.workspace().clone();
 387                            workspace.update(cx, |workspace, cx| {
 388                                let Some(recent_projects) =
 389                                    workspace.active_modal::<RecentProjects>(cx)
 390                                else {
 391                                    let focus_handle = workspace.focus_handle(cx);
 392                                    RecentProjects::open(
 393                                        workspace,
 394                                        create_new_window,
 395                                        window_project_groups,
 396                                        window,
 397                                        focus_handle,
 398                                        cx,
 399                                    );
 400                                    return;
 401                                };
 402
 403                                recent_projects.update(cx, |recent_projects, cx| {
 404                                    recent_projects
 405                                        .picker
 406                                        .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 407                                });
 408                            });
 409                        })
 410                        .log_err();
 411                });
 412            }
 413            None => {
 414                with_active_or_new_workspace(cx, move |workspace, window, cx| {
 415                    let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
 416                        let focus_handle = workspace.focus_handle(cx);
 417                        RecentProjects::open(
 418                            workspace,
 419                            create_new_window,
 420                            Vec::new(),
 421                            window,
 422                            focus_handle,
 423                            cx,
 424                        );
 425                        return;
 426                    };
 427
 428                    recent_projects.update(cx, |recent_projects, cx| {
 429                        recent_projects
 430                            .picker
 431                            .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 432                    });
 433                });
 434            }
 435        }
 436    });
 437    cx.on_action(|open_remote: &OpenRemote, cx| {
 438        let from_existing_connection = open_remote.from_existing_connection;
 439        let create_new_window = open_remote.create_new_window;
 440        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 441            if from_existing_connection {
 442                cx.propagate();
 443                return;
 444            }
 445            let handle = cx.entity().downgrade();
 446            let fs = workspace.project().read(cx).fs().clone();
 447            workspace.toggle_modal(window, cx, |window, cx| {
 448                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
 449            })
 450        });
 451    });
 452
 453    cx.observe_new(DisconnectedOverlay::register).detach();
 454
 455    cx.on_action(|_: &OpenDevContainer, cx| {
 456        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 457            if !workspace.project().read(cx).is_local() {
 458                cx.spawn_in(window, async move |_, cx| {
 459                    cx.prompt(
 460                        gpui::PromptLevel::Critical,
 461                        "Cannot open Dev Container from remote project",
 462                        None,
 463                        &["Ok"],
 464                    )
 465                    .await
 466                    .ok();
 467                })
 468                .detach();
 469                return;
 470            }
 471
 472            let fs = workspace.project().read(cx).fs().clone();
 473            let configs = find_devcontainer_configs(workspace, cx);
 474            let app_state = workspace.app_state().clone();
 475            let dev_container_context = DevContainerContext::from_workspace(workspace, cx);
 476            let handle = cx.entity().downgrade();
 477            workspace.toggle_modal(window, cx, |window, cx| {
 478                RemoteServerProjects::new_dev_container(
 479                    fs,
 480                    configs,
 481                    app_state,
 482                    dev_container_context,
 483                    window,
 484                    handle,
 485                    cx,
 486                )
 487            });
 488        });
 489    });
 490
 491    // Subscribe to worktree additions to suggest opening the project in a dev container
 492    cx.observe_new(
 493        |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
 494            let Some(window) = window else {
 495                return;
 496            };
 497            cx.subscribe_in(
 498                workspace.project(),
 499                window,
 500                move |workspace, project, event, window, cx| {
 501                    if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
 502                        event
 503                    {
 504                        dev_container_suggest::suggest_on_worktree_updated(
 505                            workspace,
 506                            *worktree_id,
 507                            updated_entries,
 508                            project,
 509                            window,
 510                            cx,
 511                        );
 512                    }
 513                },
 514            )
 515            .detach();
 516        },
 517    )
 518    .detach();
 519}
 520
 521#[cfg(target_os = "windows")]
 522pub fn add_wsl_distro(
 523    fs: Arc<dyn project::Fs>,
 524    connection_options: &remote::WslConnectionOptions,
 525    cx: &App,
 526) {
 527    use gpui::ReadGlobal;
 528    use settings::SettingsStore;
 529
 530    let distro_name = connection_options.distro_name.clone();
 531    let user = connection_options.user.clone();
 532    SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
 533        let connections = setting
 534            .remote
 535            .wsl_connections
 536            .get_or_insert(Default::default());
 537
 538        if !connections
 539            .iter()
 540            .any(|conn| conn.distro_name == distro_name && conn.user == user)
 541        {
 542            use std::collections::BTreeSet;
 543
 544            connections.push(settings::WslConnection {
 545                distro_name,
 546                user,
 547                projects: BTreeSet::new(),
 548            })
 549        }
 550    });
 551}
 552
 553pub struct RecentProjects {
 554    pub picker: Entity<Picker<RecentProjectsDelegate>>,
 555    rem_width: f32,
 556    _subscriptions: Vec<Subscription>,
 557}
 558
 559impl ModalView for RecentProjects {
 560    fn on_before_dismiss(
 561        &mut self,
 562        window: &mut Window,
 563        cx: &mut Context<Self>,
 564    ) -> workspace::DismissDecision {
 565        let submenu_focused = self.picker.update(cx, |picker, cx| {
 566            picker.delegate.actions_menu_handle.is_focused(window, cx)
 567        });
 568        workspace::DismissDecision::Dismiss(!submenu_focused)
 569    }
 570}
 571
 572impl RecentProjects {
 573    fn new(
 574        delegate: RecentProjectsDelegate,
 575        fs: Option<Arc<dyn Fs>>,
 576        rem_width: f32,
 577        window: &mut Window,
 578        cx: &mut Context<Self>,
 579    ) -> Self {
 580        let style = delegate.style;
 581        let picker = cx.new(|cx| {
 582            Picker::list(delegate, window, cx)
 583                .list_measure_all()
 584                .show_scrollbar(true)
 585        });
 586
 587        let picker_focus_handle = picker.focus_handle(cx);
 588        picker.update(cx, |picker, _| {
 589            picker.delegate.focus_handle = picker_focus_handle;
 590        });
 591
 592        let mut subscriptions = vec![cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent))];
 593
 594        if style == ProjectPickerStyle::Popover {
 595            let picker_focus = picker.focus_handle(cx);
 596            subscriptions.push(
 597                cx.on_focus_out(&picker_focus, window, |this, _, window, cx| {
 598                    let submenu_focused = this.picker.update(cx, |picker, cx| {
 599                        picker.delegate.actions_menu_handle.is_focused(window, cx)
 600                    });
 601                    if !submenu_focused {
 602                        cx.emit(DismissEvent);
 603                    }
 604                }),
 605            );
 606        }
 607        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
 608        // out workspace locations once the future runs to completion.
 609        let db = WorkspaceDb::global(cx);
 610        cx.spawn_in(window, async move |this, cx| {
 611            let Some(fs) = fs else { return };
 612            let workspaces = db
 613                .recent_workspaces_on_disk(fs.as_ref())
 614                .await
 615                .log_err()
 616                .unwrap_or_default();
 617            let workspaces = workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
 618            this.update_in(cx, move |this, window, cx| {
 619                this.picker.update(cx, move |picker, cx| {
 620                    picker.delegate.set_workspaces(workspaces);
 621                    picker.update_matches(picker.query(cx), window, cx)
 622                })
 623            })
 624            .ok();
 625        })
 626        .detach();
 627        Self {
 628            picker,
 629            rem_width,
 630            _subscriptions: subscriptions,
 631        }
 632    }
 633
 634    pub fn open(
 635        workspace: &mut Workspace,
 636        create_new_window: bool,
 637        window_project_groups: Vec<ProjectGroupKey>,
 638        window: &mut Window,
 639        focus_handle: FocusHandle,
 640        cx: &mut Context<Workspace>,
 641    ) {
 642        let weak = cx.entity().downgrade();
 643        let open_folders = get_open_folders(workspace, cx);
 644        let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
 645        let fs = Some(workspace.app_state().fs.clone());
 646
 647        workspace.toggle_modal(window, cx, |window, cx| {
 648            let delegate = RecentProjectsDelegate::new(
 649                weak,
 650                create_new_window,
 651                focus_handle,
 652                open_folders,
 653                window_project_groups,
 654                project_connection_options,
 655                ProjectPickerStyle::Modal,
 656            );
 657
 658            Self::new(delegate, fs, 34., window, cx)
 659        })
 660    }
 661
 662    pub fn popover(
 663        workspace: WeakEntity<Workspace>,
 664        window_project_groups: Vec<ProjectGroupKey>,
 665        create_new_window: bool,
 666        focus_handle: FocusHandle,
 667        window: &mut Window,
 668        cx: &mut App,
 669    ) -> Entity<Self> {
 670        let (open_folders, project_connection_options, fs) = workspace
 671            .upgrade()
 672            .map(|workspace| {
 673                let workspace = workspace.read(cx);
 674                (
 675                    get_open_folders(workspace, cx),
 676                    workspace.project().read(cx).remote_connection_options(cx),
 677                    Some(workspace.app_state().fs.clone()),
 678                )
 679            })
 680            .unwrap_or_else(|| (Vec::new(), None, None));
 681
 682        cx.new(|cx| {
 683            let delegate = RecentProjectsDelegate::new(
 684                workspace,
 685                create_new_window,
 686                focus_handle,
 687                open_folders,
 688                window_project_groups,
 689                project_connection_options,
 690                ProjectPickerStyle::Popover,
 691            );
 692            let list = Self::new(delegate, fs, 20., window, cx);
 693            list.picker.focus_handle(cx).focus(window, cx);
 694            list
 695        })
 696    }
 697
 698    fn handle_toggle_open_menu(
 699        &mut self,
 700        _: &ToggleActionsMenu,
 701        window: &mut Window,
 702        cx: &mut Context<Self>,
 703    ) {
 704        self.picker.update(cx, |picker, cx| {
 705            let menu_handle = &picker.delegate.actions_menu_handle;
 706            if menu_handle.is_deployed() {
 707                menu_handle.hide(cx);
 708            } else {
 709                menu_handle.show(window, cx);
 710            }
 711        });
 712    }
 713
 714    fn handle_remove_selected(
 715        &mut self,
 716        _: &RemoveSelected,
 717        window: &mut Window,
 718        cx: &mut Context<Self>,
 719    ) {
 720        self.picker.update(cx, |picker, cx| {
 721            let ix = picker.delegate.selected_index;
 722
 723            match picker.delegate.filtered_entries.get(ix) {
 724                Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
 725                    if let Some(folder) = picker.delegate.open_folders.get(*index) {
 726                        let worktree_id = folder.worktree_id;
 727                        let Some(workspace) = picker.delegate.workspace.upgrade() else {
 728                            return;
 729                        };
 730                        workspace.update(cx, |workspace, cx| {
 731                            let project = workspace.project().clone();
 732                            project.update(cx, |project, cx| {
 733                                project.remove_worktree(worktree_id, cx);
 734                            });
 735                        });
 736                        picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
 737                        let query = picker.query(cx);
 738                        picker.update_matches(query, window, cx);
 739                    }
 740                }
 741                Some(ProjectPickerEntry::ProjectGroup(hit)) => {
 742                    if let Some(key) = picker
 743                        .delegate
 744                        .window_project_groups
 745                        .get(hit.candidate_id)
 746                        .cloned()
 747                    {
 748                        if picker.delegate.is_active_project_group(&key, cx) {
 749                            return;
 750                        }
 751                        picker.delegate.remove_project_group(key, window, cx);
 752                        let query = picker.query(cx);
 753                        picker.update_matches(query, window, cx);
 754                    }
 755                }
 756                Some(ProjectPickerEntry::RecentProject(_)) => {
 757                    picker.delegate.delete_recent_project(ix, window, cx);
 758                }
 759                _ => {}
 760            }
 761        });
 762    }
 763
 764    fn handle_add_to_workspace(
 765        &mut self,
 766        _: &AddToWorkspace,
 767        window: &mut Window,
 768        cx: &mut Context<Self>,
 769    ) {
 770        self.picker.update(cx, |picker, cx| {
 771            let ix = picker.delegate.selected_index;
 772
 773            if let Some(ProjectPickerEntry::RecentProject(hit)) =
 774                picker.delegate.filtered_entries.get(ix)
 775            {
 776                if let Some((_, location, paths, _)) =
 777                    picker.delegate.workspaces.get(hit.candidate_id)
 778                {
 779                    if matches!(location, SerializedWorkspaceLocation::Local) {
 780                        let paths_to_add = paths.paths().to_vec();
 781                        picker
 782                            .delegate
 783                            .add_project_to_workspace(paths_to_add, window, cx);
 784                    }
 785                }
 786            }
 787        });
 788    }
 789}
 790
 791impl EventEmitter<DismissEvent> for RecentProjects {}
 792
 793impl Focusable for RecentProjects {
 794    fn focus_handle(&self, cx: &App) -> FocusHandle {
 795        self.picker.focus_handle(cx)
 796    }
 797}
 798
 799impl Render for RecentProjects {
 800    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 801        v_flex()
 802            .key_context("RecentProjects")
 803            .on_action(cx.listener(Self::handle_toggle_open_menu))
 804            .on_action(cx.listener(Self::handle_remove_selected))
 805            .on_action(cx.listener(Self::handle_add_to_workspace))
 806            .w(rems(self.rem_width))
 807            .child(self.picker.clone())
 808    }
 809}
 810
 811pub struct RecentProjectsDelegate {
 812    workspace: WeakEntity<Workspace>,
 813    open_folders: Vec<OpenFolderEntry>,
 814    window_project_groups: Vec<ProjectGroupKey>,
 815    workspaces: Vec<(
 816        WorkspaceId,
 817        SerializedWorkspaceLocation,
 818        PathList,
 819        DateTime<Utc>,
 820    )>,
 821    filtered_entries: Vec<ProjectPickerEntry>,
 822    selected_index: usize,
 823    render_paths: bool,
 824    create_new_window: bool,
 825    // Flag to reset index when there is a new query vs not reset index when user delete an item
 826    reset_selected_match_index: bool,
 827    has_any_non_local_projects: bool,
 828    project_connection_options: Option<RemoteConnectionOptions>,
 829    focus_handle: FocusHandle,
 830    style: ProjectPickerStyle,
 831    actions_menu_handle: PopoverMenuHandle<ContextMenu>,
 832}
 833
 834impl RecentProjectsDelegate {
 835    fn new(
 836        workspace: WeakEntity<Workspace>,
 837        create_new_window: bool,
 838        focus_handle: FocusHandle,
 839        open_folders: Vec<OpenFolderEntry>,
 840        window_project_groups: Vec<ProjectGroupKey>,
 841        project_connection_options: Option<RemoteConnectionOptions>,
 842        style: ProjectPickerStyle,
 843    ) -> Self {
 844        let render_paths = style == ProjectPickerStyle::Modal;
 845        Self {
 846            workspace,
 847            open_folders,
 848            window_project_groups,
 849            workspaces: Vec::new(),
 850            filtered_entries: Vec::new(),
 851            selected_index: 0,
 852            create_new_window,
 853            render_paths,
 854            reset_selected_match_index: true,
 855            has_any_non_local_projects: project_connection_options.is_some(),
 856            project_connection_options,
 857            focus_handle,
 858            style,
 859            actions_menu_handle: PopoverMenuHandle::default(),
 860        }
 861    }
 862
 863    pub fn set_workspaces(
 864        &mut self,
 865        workspaces: Vec<(
 866            WorkspaceId,
 867            SerializedWorkspaceLocation,
 868            PathList,
 869            DateTime<Utc>,
 870        )>,
 871    ) {
 872        self.workspaces = workspaces;
 873        let has_non_local_recent = !self
 874            .workspaces
 875            .iter()
 876            .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
 877        self.has_any_non_local_projects =
 878            self.project_connection_options.is_some() || has_non_local_recent;
 879    }
 880}
 881impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
 882impl PickerDelegate for RecentProjectsDelegate {
 883    type ListItem = AnyElement;
 884
 885    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 886        "Search projects…".into()
 887    }
 888
 889    fn render_editor(
 890        &self,
 891        editor: &Arc<dyn ErasedEditor>,
 892        window: &mut Window,
 893        cx: &mut Context<Picker<Self>>,
 894    ) -> Div {
 895        h_flex()
 896            .flex_none()
 897            .h_9()
 898            .px_2p5()
 899            .justify_between()
 900            .border_b_1()
 901            .border_color(cx.theme().colors().border_variant)
 902            .child(editor.render(window, cx))
 903    }
 904
 905    fn match_count(&self) -> usize {
 906        self.filtered_entries.len()
 907    }
 908
 909    fn selected_index(&self) -> usize {
 910        self.selected_index
 911    }
 912
 913    fn set_selected_index(
 914        &mut self,
 915        ix: usize,
 916        _window: &mut Window,
 917        _cx: &mut Context<Picker<Self>>,
 918    ) {
 919        self.selected_index = ix;
 920    }
 921
 922    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
 923        matches!(
 924            self.filtered_entries.get(ix),
 925            Some(
 926                ProjectPickerEntry::OpenFolder { .. }
 927                    | ProjectPickerEntry::ProjectGroup(_)
 928                    | ProjectPickerEntry::RecentProject(_)
 929            )
 930        )
 931    }
 932
 933    fn update_matches(
 934        &mut self,
 935        query: String,
 936        _: &mut Window,
 937        cx: &mut Context<Picker<Self>>,
 938    ) -> gpui::Task<()> {
 939        let query = query.trim_start();
 940        let smart_case = query.chars().any(|c| c.is_uppercase());
 941        let is_empty_query = query.is_empty();
 942
 943        let folder_matches = if self.open_folders.is_empty() {
 944            Vec::new()
 945        } else {
 946            let candidates: Vec<_> = self
 947                .open_folders
 948                .iter()
 949                .enumerate()
 950                .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
 951                .collect();
 952
 953            smol::block_on(fuzzy::match_strings(
 954                &candidates,
 955                query,
 956                smart_case,
 957                true,
 958                100,
 959                &Default::default(),
 960                cx.background_executor().clone(),
 961            ))
 962        };
 963
 964        let project_group_candidates: Vec<_> = self
 965            .window_project_groups
 966            .iter()
 967            .enumerate()
 968            .map(|(id, key)| {
 969                let combined_string = key
 970                    .path_list()
 971                    .ordered_paths()
 972                    .map(|path| path.compact().to_string_lossy().into_owned())
 973                    .collect::<Vec<_>>()
 974                    .join("");
 975                StringMatchCandidate::new(id, &combined_string)
 976            })
 977            .collect();
 978
 979        let mut project_group_matches = smol::block_on(fuzzy::match_strings(
 980            &project_group_candidates,
 981            query,
 982            smart_case,
 983            true,
 984            100,
 985            &Default::default(),
 986            cx.background_executor().clone(),
 987        ));
 988        project_group_matches.sort_unstable_by(|a, b| {
 989            b.score
 990                .partial_cmp(&a.score)
 991                .unwrap_or(std::cmp::Ordering::Equal)
 992                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
 993        });
 994
 995        // Build candidates for recent projects (not current, not sibling, not open folder)
 996        let recent_candidates: Vec<_> = self
 997            .workspaces
 998            .iter()
 999            .enumerate()
1000            .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
1001            .map(|(id, (_, _, paths, _))| {
1002                let combined_string = paths
1003                    .ordered_paths()
1004                    .map(|path| path.compact().to_string_lossy().into_owned())
1005                    .collect::<Vec<_>>()
1006                    .join("");
1007                StringMatchCandidate::new(id, &combined_string)
1008            })
1009            .collect();
1010
1011        let mut recent_matches = smol::block_on(fuzzy::match_strings(
1012            &recent_candidates,
1013            query,
1014            smart_case,
1015            true,
1016            100,
1017            &Default::default(),
1018            cx.background_executor().clone(),
1019        ));
1020        recent_matches.sort_unstable_by(|a, b| {
1021            b.score
1022                .partial_cmp(&a.score)
1023                .unwrap_or(std::cmp::Ordering::Equal)
1024                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1025        });
1026
1027        let mut entries = Vec::new();
1028
1029        if !self.open_folders.is_empty() {
1030            let matched_folders: Vec<_> = if is_empty_query {
1031                (0..self.open_folders.len())
1032                    .map(|i| (i, Vec::new()))
1033                    .collect()
1034            } else {
1035                folder_matches
1036                    .iter()
1037                    .map(|m| (m.candidate_id, m.positions.clone()))
1038                    .collect()
1039            };
1040
1041            for (index, positions) in matched_folders {
1042                entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1043            }
1044        }
1045
1046        let has_projects_to_show = if is_empty_query {
1047            !project_group_candidates.is_empty()
1048        } else {
1049            !project_group_matches.is_empty()
1050        };
1051
1052        if has_projects_to_show {
1053            entries.push(ProjectPickerEntry::Header("This Window".into()));
1054
1055            if is_empty_query {
1056                for id in 0..self.window_project_groups.len() {
1057                    entries.push(ProjectPickerEntry::ProjectGroup(StringMatch {
1058                        candidate_id: id,
1059                        score: 0.0,
1060                        positions: Vec::new(),
1061                        string: String::new(),
1062                    }));
1063                }
1064            } else {
1065                for m in project_group_matches {
1066                    entries.push(ProjectPickerEntry::ProjectGroup(m));
1067                }
1068            }
1069        }
1070
1071        let has_recent_to_show = if is_empty_query {
1072            !recent_candidates.is_empty()
1073        } else {
1074            !recent_matches.is_empty()
1075        };
1076
1077        if has_recent_to_show {
1078            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1079
1080            if is_empty_query {
1081                for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1082                    if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1083                        entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1084                            candidate_id: id,
1085                            score: 0.0,
1086                            positions: Vec::new(),
1087                            string: String::new(),
1088                        }));
1089                    }
1090                }
1091            } else {
1092                for m in recent_matches {
1093                    entries.push(ProjectPickerEntry::RecentProject(m));
1094                }
1095            }
1096        }
1097
1098        self.filtered_entries = entries;
1099
1100        if self.reset_selected_match_index {
1101            self.selected_index = self
1102                .filtered_entries
1103                .iter()
1104                .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1105                .unwrap_or(0);
1106        }
1107        self.reset_selected_match_index = true;
1108        Task::ready(())
1109    }
1110
1111    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1112        match self.filtered_entries.get(self.selected_index) {
1113            Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1114                let Some(folder) = self.open_folders.get(*index) else {
1115                    return;
1116                };
1117                let worktree_id = folder.worktree_id;
1118                if let Some(workspace) = self.workspace.upgrade() {
1119                    workspace.update(cx, |workspace, cx| {
1120                        let git_store = workspace.project().read(cx).git_store().clone();
1121                        git_store.update(cx, |git_store, cx| {
1122                            git_store.set_active_repo_for_worktree(worktree_id, cx);
1123                        });
1124                    });
1125                }
1126                cx.emit(DismissEvent);
1127            }
1128            Some(ProjectPickerEntry::ProjectGroup(selected_match)) => {
1129                let Some(key) = self.window_project_groups.get(selected_match.candidate_id) else {
1130                    return;
1131                };
1132
1133                let key = key.clone();
1134                let path_list = key.path_list().clone();
1135                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1136                    cx.defer(move |cx| {
1137                        if let Some(task) = handle
1138                            .update(cx, |multi_workspace, window, cx| {
1139                                multi_workspace.find_or_create_local_workspace(
1140                                    path_list,
1141                                    Some(key.clone()),
1142                                    &[],
1143                                    None,
1144                                    OpenMode::Activate,
1145                                    window,
1146                                    cx,
1147                                )
1148                            })
1149                            .log_err()
1150                        {
1151                            task.detach_and_log_err(cx);
1152                        }
1153                    });
1154                }
1155                cx.emit(DismissEvent);
1156            }
1157            Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1158                let Some(workspace) = self.workspace.upgrade() else {
1159                    return;
1160                };
1161                let Some((
1162                    candidate_workspace_id,
1163                    candidate_workspace_location,
1164                    candidate_workspace_paths,
1165                    _,
1166                )) = self.workspaces.get(selected_match.candidate_id)
1167                else {
1168                    return;
1169                };
1170
1171                let replace_current_window = self.create_new_window == secondary;
1172                let candidate_workspace_id = *candidate_workspace_id;
1173                let candidate_workspace_location = candidate_workspace_location.clone();
1174                let candidate_workspace_paths = candidate_workspace_paths.clone();
1175
1176                workspace.update(cx, |workspace, cx| {
1177                    if workspace.database_id() == Some(candidate_workspace_id) {
1178                        return;
1179                    }
1180                    match candidate_workspace_location {
1181                        SerializedWorkspaceLocation::Local => {
1182                            let paths = candidate_workspace_paths.paths().to_vec();
1183                            if replace_current_window {
1184                                if let Some(handle) =
1185                                    window.window_handle().downcast::<MultiWorkspace>()
1186                                {
1187                                    cx.defer(move |cx| {
1188                                        if let Some(task) = handle
1189                                            .update(cx, |multi_workspace, window, cx| {
1190                                                multi_workspace.open_project(
1191                                                    paths,
1192                                                    OpenMode::Activate,
1193                                                    window,
1194                                                    cx,
1195                                                )
1196                                            })
1197                                            .log_err()
1198                                        {
1199                                            task.detach_and_log_err(cx);
1200                                        }
1201                                    });
1202                                }
1203                                return;
1204                            } else {
1205                                workspace
1206                                    .open_workspace_for_paths(
1207                                        OpenMode::NewWindow,
1208                                        paths,
1209                                        window,
1210                                        cx,
1211                                    )
1212                                    .detach_and_prompt_err(
1213                                        "Failed to open project",
1214                                        window,
1215                                        cx,
1216                                        |_, _, _| None,
1217                                    );
1218                            }
1219                        }
1220                        SerializedWorkspaceLocation::Remote(mut connection) => {
1221                            let app_state = workspace.app_state().clone();
1222                            let replace_window = if replace_current_window {
1223                                window.window_handle().downcast::<MultiWorkspace>()
1224                            } else {
1225                                None
1226                            };
1227                            let open_options = OpenOptions {
1228                                requesting_window: replace_window,
1229                                ..Default::default()
1230                            };
1231                            if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1232                                RemoteSettings::get_global(cx)
1233                                    .fill_connection_options_from_settings(connection);
1234                            };
1235                            let paths = candidate_workspace_paths.paths().to_vec();
1236                            cx.spawn_in(window, async move |_, cx| {
1237                                open_remote_project(
1238                                    connection.clone(),
1239                                    paths,
1240                                    app_state,
1241                                    open_options,
1242                                    cx,
1243                                )
1244                                .await
1245                            })
1246                            .detach_and_prompt_err(
1247                                "Failed to open project",
1248                                window,
1249                                cx,
1250                                |_, _, _| None,
1251                            );
1252                        }
1253                    }
1254                });
1255                cx.emit(DismissEvent);
1256            }
1257            _ => {}
1258        }
1259    }
1260
1261    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1262
1263    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1264        let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1265            "Recently opened projects will show up here".into()
1266        } else {
1267            "No matches".into()
1268        };
1269        Some(text)
1270    }
1271
1272    fn render_match(
1273        &self,
1274        ix: usize,
1275        selected: bool,
1276        window: &mut Window,
1277        cx: &mut Context<Picker<Self>>,
1278    ) -> Option<Self::ListItem> {
1279        match self.filtered_entries.get(ix)? {
1280            ProjectPickerEntry::Header(title) => Some(
1281                v_flex()
1282                    .w_full()
1283                    .gap_1()
1284                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1285                    .child(ListSubHeader::new(title.clone()).inset(true))
1286                    .into_any_element(),
1287            ),
1288            ProjectPickerEntry::OpenFolder { index, positions } => {
1289                let folder = self.open_folders.get(*index)?;
1290                let name = folder.name.clone();
1291                let path = folder.path.compact();
1292                let branch = folder.branch.clone();
1293                let is_active = folder.is_active;
1294                let worktree_id = folder.worktree_id;
1295                let positions = positions.clone();
1296                let show_path = self.style == ProjectPickerStyle::Modal;
1297
1298                let secondary_actions = h_flex()
1299                    .gap_1()
1300                    .child(
1301                        IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1302                            .icon_size(IconSize::Small)
1303                            .tooltip(Tooltip::text("Remove Folder from Workspace"))
1304                            .on_click(cx.listener(move |picker, _, window, cx| {
1305                                let Some(workspace) = picker.delegate.workspace.upgrade() else {
1306                                    return;
1307                                };
1308                                workspace.update(cx, |workspace, cx| {
1309                                    let project = workspace.project().clone();
1310                                    project.update(cx, |project, cx| {
1311                                        project.remove_worktree(worktree_id, cx);
1312                                    });
1313                                });
1314                                picker.delegate.open_folders =
1315                                    get_open_folders(workspace.read(cx), cx);
1316                                let query = picker.query(cx);
1317                                picker.update_matches(query, window, cx);
1318                            })),
1319                    )
1320                    .into_any_element();
1321
1322                let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1323
1324                let tooltip_path: SharedString = path.to_string_lossy().to_string().into();
1325                let tooltip_branch = branch.clone();
1326
1327                Some(
1328                    ListItem::new(ix)
1329                        .toggle_state(selected)
1330                        .inset(true)
1331                        .spacing(ListItemSpacing::Sparse)
1332                        .child(
1333                            h_flex()
1334                                .id("open_folder_item")
1335                                .gap_3()
1336                                .w_full()
1337                                .overflow_hidden()
1338                                .when(self.has_any_non_local_projects, |this| {
1339                                    this.child(Icon::new(icon).color(Color::Muted))
1340                                })
1341                                .child(
1342                                    v_flex()
1343                                        .flex_1()
1344                                        .child(
1345                                            h_flex()
1346                                                .min_w_0()
1347                                                .gap_1()
1348                                                .child(HighlightedLabel::new(
1349                                                    name.to_string(),
1350                                                    positions,
1351                                                ))
1352                                                .when_some(branch, |this, branch| {
1353                                                    this.child(
1354                                                        Label::new(branch)
1355                                                            .color(Color::Muted)
1356                                                            .truncate()
1357                                                            .flex_1(),
1358                                                    )
1359                                                })
1360                                                .when(is_active, |this| {
1361                                                    this.child(
1362                                                        Icon::new(IconName::Check)
1363                                                            .size(IconSize::Small)
1364                                                            .color(Color::Accent),
1365                                                    )
1366                                                }),
1367                                        )
1368                                        .when(show_path, |this| {
1369                                            this.child(
1370                                                Label::new(path.to_string_lossy().to_string())
1371                                                    .size(LabelSize::Small)
1372                                                    .color(Color::Muted),
1373                                            )
1374                                        }),
1375                                )
1376                                .when(!show_path, |this| {
1377                                    this.tooltip(move |_, cx| {
1378                                        if let Some(branch) = tooltip_branch.clone() {
1379                                            Tooltip::with_meta(
1380                                                branch,
1381                                                None,
1382                                                tooltip_path.clone(),
1383                                                cx,
1384                                            )
1385                                        } else {
1386                                            Tooltip::simple(tooltip_path.clone(), cx)
1387                                        }
1388                                    })
1389                                }),
1390                        )
1391                        .end_slot(secondary_actions)
1392                        .show_end_slot_on_hover()
1393                        .into_any_element(),
1394                )
1395            }
1396            ProjectPickerEntry::ProjectGroup(hit) => {
1397                let key = self.window_project_groups.get(hit.candidate_id)?;
1398                let is_active = self.is_active_project_group(key, cx);
1399                let paths = key.path_list();
1400                let ordered_paths: Vec<_> = paths
1401                    .ordered_paths()
1402                    .map(|p| p.compact().to_string_lossy().to_string())
1403                    .collect();
1404                let tooltip_path: SharedString = ordered_paths.join("\n").into();
1405
1406                let mut path_start_offset = 0;
1407                let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1408                    .ordered_paths()
1409                    .map(|p| p.compact())
1410                    .map(|path| {
1411                        let highlighted_text =
1412                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1413                        path_start_offset += highlighted_text.1.text.len();
1414                        highlighted_text
1415                    })
1416                    .unzip();
1417
1418                let highlighted_match = HighlightedMatchWithPaths {
1419                    prefix: None,
1420                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1421                    paths: path_highlights,
1422                    active: is_active,
1423                };
1424
1425                let project_group_key = key.clone();
1426                let secondary_actions = h_flex()
1427                    .gap_1()
1428                    .when(!is_active, |this| {
1429                        this.child(
1430                            IconButton::new("remove_open_project", IconName::Close)
1431                                .icon_size(IconSize::Small)
1432                                .tooltip(Tooltip::text("Remove Project from Window"))
1433                                .on_click({
1434                                    let project_group_key = project_group_key.clone();
1435                                    cx.listener(move |picker, _, window, cx| {
1436                                        cx.stop_propagation();
1437                                        window.prevent_default();
1438                                        picker.delegate.remove_project_group(
1439                                            project_group_key.clone(),
1440                                            window,
1441                                            cx,
1442                                        );
1443                                        let query = picker.query(cx);
1444                                        picker.update_matches(query, window, cx);
1445                                    })
1446                                }),
1447                        )
1448                    })
1449                    .into_any_element();
1450
1451                Some(
1452                    ListItem::new(ix)
1453                        .toggle_state(selected)
1454                        .inset(true)
1455                        .spacing(ListItemSpacing::Sparse)
1456                        .child(
1457                            h_flex()
1458                                .id("open_project_info_container")
1459                                .gap_3()
1460                                .child({
1461                                    let mut highlighted = highlighted_match;
1462                                    if !self.render_paths {
1463                                        highlighted.paths.clear();
1464                                    }
1465                                    highlighted.render(window, cx)
1466                                })
1467                                .tooltip(Tooltip::text(tooltip_path)),
1468                        )
1469                        .end_slot(secondary_actions)
1470                        .show_end_slot_on_hover()
1471                        .into_any_element(),
1472                )
1473            }
1474            ProjectPickerEntry::RecentProject(hit) => {
1475                let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1476                let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1477                let paths_to_add = paths.paths().to_vec();
1478                let ordered_paths: Vec<_> = paths
1479                    .ordered_paths()
1480                    .map(|p| p.compact().to_string_lossy().to_string())
1481                    .collect();
1482                let tooltip_path: SharedString = match &location {
1483                    SerializedWorkspaceLocation::Remote(options) => {
1484                        let host = options.display_name();
1485                        if ordered_paths.len() == 1 {
1486                            format!("{} ({})", ordered_paths[0], host).into()
1487                        } else {
1488                            format!("{}\n({})", ordered_paths.join("\n"), host).into()
1489                        }
1490                    }
1491                    _ => ordered_paths.join("\n").into(),
1492                };
1493
1494                let mut path_start_offset = 0;
1495                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1496                    .ordered_paths()
1497                    .map(|p| p.compact())
1498                    .map(|path| {
1499                        let highlighted_text =
1500                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1501                        path_start_offset += highlighted_text.1.text.len();
1502                        highlighted_text
1503                    })
1504                    .unzip();
1505
1506                let prefix = match &location {
1507                    SerializedWorkspaceLocation::Remote(options) => {
1508                        Some(SharedString::from(options.display_name()))
1509                    }
1510                    _ => None,
1511                };
1512
1513                let highlighted_match = HighlightedMatchWithPaths {
1514                    prefix,
1515                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1516                    paths,
1517                    active: false,
1518                };
1519
1520                let focus_handle = self.focus_handle.clone();
1521
1522                let secondary_actions = h_flex()
1523                    .gap_px()
1524                    .when(is_local, |this| {
1525                        this.child(
1526                            IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1527                                .icon_size(IconSize::Small)
1528                                .tooltip(move |_, cx| {
1529                                    Tooltip::with_meta(
1530                                        "Add Project to this Workspace",
1531                                        None,
1532                                        "As a multi-root folder project",
1533                                        cx,
1534                                    )
1535                                })
1536                                .on_click({
1537                                    let paths_to_add = paths_to_add.clone();
1538                                    cx.listener(move |picker, _event, window, cx| {
1539                                        cx.stop_propagation();
1540                                        window.prevent_default();
1541                                        picker.delegate.add_project_to_workspace(
1542                                            paths_to_add.clone(),
1543                                            window,
1544                                            cx,
1545                                        );
1546                                    })
1547                                }),
1548                        )
1549                    })
1550                    .child(
1551                        IconButton::new("open_new_window", IconName::OpenNewWindow)
1552                            .icon_size(IconSize::Small)
1553                            .tooltip({
1554                                move |_, cx| {
1555                                    Tooltip::for_action_in(
1556                                        "Open Project in New Window",
1557                                        &menu::SecondaryConfirm,
1558                                        &focus_handle,
1559                                        cx,
1560                                    )
1561                                }
1562                            })
1563                            .on_click(cx.listener(move |this, _event, window, cx| {
1564                                cx.stop_propagation();
1565                                window.prevent_default();
1566                                this.delegate.set_selected_index(ix, window, cx);
1567                                this.delegate.confirm(true, window, cx);
1568                            })),
1569                    )
1570                    .child(
1571                        IconButton::new("delete", IconName::Close)
1572                            .icon_size(IconSize::Small)
1573                            .tooltip(Tooltip::text("Delete from Recent Projects"))
1574                            .on_click(cx.listener(move |this, _event, window, cx| {
1575                                cx.stop_propagation();
1576                                window.prevent_default();
1577                                this.delegate.delete_recent_project(ix, window, cx)
1578                            })),
1579                    )
1580                    .into_any_element();
1581
1582                let icon = icon_for_remote_connection(match location {
1583                    SerializedWorkspaceLocation::Local => None,
1584                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1585                });
1586
1587                Some(
1588                    ListItem::new(ix)
1589                        .toggle_state(selected)
1590                        .inset(true)
1591                        .spacing(ListItemSpacing::Sparse)
1592                        .child(
1593                            h_flex()
1594                                .id("project_info_container")
1595                                .gap_3()
1596                                .flex_grow()
1597                                .when(self.has_any_non_local_projects, |this| {
1598                                    this.child(Icon::new(icon).color(Color::Muted))
1599                                })
1600                                .child({
1601                                    let mut highlighted = highlighted_match;
1602                                    if !self.render_paths {
1603                                        highlighted.paths.clear();
1604                                    }
1605                                    highlighted.render(window, cx)
1606                                })
1607                                .tooltip(move |_, cx| {
1608                                    Tooltip::with_meta(
1609                                        "Open Project in This Window",
1610                                        None,
1611                                        tooltip_path.clone(),
1612                                        cx,
1613                                    )
1614                                }),
1615                        )
1616                        .end_slot(secondary_actions)
1617                        .show_end_slot_on_hover()
1618                        .into_any_element(),
1619                )
1620            }
1621        }
1622    }
1623
1624    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1625        let focus_handle = self.focus_handle.clone();
1626        let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1627        let is_already_open_entry = matches!(
1628            self.filtered_entries.get(self.selected_index),
1629            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1630        );
1631
1632        if popover_style {
1633            return Some(
1634                v_flex()
1635                    .flex_1()
1636                    .p_1p5()
1637                    .gap_1()
1638                    .border_t_1()
1639                    .border_color(cx.theme().colors().border_variant)
1640                    .child({
1641                        let open_action = workspace::Open {
1642                            create_new_window: self.create_new_window,
1643                        };
1644                        Button::new("open_local_folder", "Open Local Folders")
1645                            .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1646                            .on_click({
1647                                let workspace = self.workspace.clone();
1648                                let create_new_window = self.create_new_window;
1649                                move |_, window, cx| {
1650                                    open_local_project(
1651                                        workspace.clone(),
1652                                        create_new_window,
1653                                        window,
1654                                        cx,
1655                                    );
1656                                }
1657                            })
1658                    })
1659                    .child(
1660                        Button::new("open_remote_folder", "Open Remote Folder")
1661                            .key_binding(KeyBinding::for_action(
1662                                &OpenRemote {
1663                                    from_existing_connection: false,
1664                                    create_new_window: false,
1665                                },
1666                                cx,
1667                            ))
1668                            .on_click(|_, window, cx| {
1669                                window.dispatch_action(
1670                                    OpenRemote {
1671                                        from_existing_connection: false,
1672                                        create_new_window: false,
1673                                    }
1674                                    .boxed_clone(),
1675                                    cx,
1676                                )
1677                            }),
1678                    )
1679                    .into_any(),
1680            );
1681        }
1682
1683        let selected_entry = self.filtered_entries.get(self.selected_index);
1684
1685        let is_current_workspace_entry =
1686            if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1687                self.window_project_groups
1688                    .get(hit.candidate_id)
1689                    .is_some_and(|key| self.is_active_project_group(key, cx))
1690            } else {
1691                false
1692            };
1693
1694        let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1695            Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1696                Button::new("remove_selected", "Remove Folder")
1697                    .key_binding(KeyBinding::for_action_in(
1698                        &RemoveSelected,
1699                        &focus_handle,
1700                        cx,
1701                    ))
1702                    .on_click(|_, window, cx| {
1703                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1704                    })
1705                    .into_any_element(),
1706            ),
1707            Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1708                Button::new("remove_selected", "Remove from Window")
1709                    .key_binding(KeyBinding::for_action_in(
1710                        &RemoveSelected,
1711                        &focus_handle,
1712                        cx,
1713                    ))
1714                    .on_click(|_, window, cx| {
1715                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1716                    })
1717                    .into_any_element(),
1718            ),
1719            Some(ProjectPickerEntry::RecentProject(_)) => Some(
1720                Button::new("delete_recent", "Delete")
1721                    .key_binding(KeyBinding::for_action_in(
1722                        &RemoveSelected,
1723                        &focus_handle,
1724                        cx,
1725                    ))
1726                    .on_click(|_, window, cx| {
1727                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1728                    })
1729                    .into_any_element(),
1730            ),
1731            _ => None,
1732        };
1733
1734        Some(
1735            h_flex()
1736                .flex_1()
1737                .p_1p5()
1738                .gap_1()
1739                .justify_end()
1740                .border_t_1()
1741                .border_color(cx.theme().colors().border_variant)
1742                .when_some(secondary_footer_actions, |this, actions| {
1743                    this.child(actions)
1744                })
1745                .map(|this| {
1746                    if is_already_open_entry {
1747                        this.child(
1748                            Button::new("activate", "Activate")
1749                                .key_binding(KeyBinding::for_action_in(
1750                                    &menu::Confirm,
1751                                    &focus_handle,
1752                                    cx,
1753                                ))
1754                                .on_click(|_, window, cx| {
1755                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1756                                }),
1757                        )
1758                    } else {
1759                        this.child(
1760                            Button::new("open_new_window", "New Window")
1761                                .key_binding(KeyBinding::for_action_in(
1762                                    &menu::SecondaryConfirm,
1763                                    &focus_handle,
1764                                    cx,
1765                                ))
1766                                .on_click(|_, window, cx| {
1767                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1768                                }),
1769                        )
1770                        .child(
1771                            Button::new("open_here", "Open")
1772                                .key_binding(KeyBinding::for_action_in(
1773                                    &menu::Confirm,
1774                                    &focus_handle,
1775                                    cx,
1776                                ))
1777                                .on_click(|_, window, cx| {
1778                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1779                                }),
1780                        )
1781                    }
1782                })
1783                .child(Divider::vertical())
1784                .child(
1785                    PopoverMenu::new("actions-menu-popover")
1786                        .with_handle(self.actions_menu_handle.clone())
1787                        .anchor(gpui::Corner::BottomRight)
1788                        .offset(gpui::Point {
1789                            x: px(0.0),
1790                            y: px(-2.0),
1791                        })
1792                        .trigger(
1793                            Button::new("actions-trigger", "Actions")
1794                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1795                                .key_binding(KeyBinding::for_action_in(
1796                                    &ToggleActionsMenu,
1797                                    &focus_handle,
1798                                    cx,
1799                                )),
1800                        )
1801                        .menu({
1802                            let focus_handle = focus_handle.clone();
1803                            let workspace_handle = self.workspace.clone();
1804                            let create_new_window = self.create_new_window;
1805                            let open_action = workspace::Open { create_new_window };
1806                            let show_add_to_workspace = match selected_entry {
1807                                Some(ProjectPickerEntry::RecentProject(hit)) => self
1808                                    .workspaces
1809                                    .get(hit.candidate_id)
1810                                    .map(|(_, loc, ..)| {
1811                                        matches!(loc, SerializedWorkspaceLocation::Local)
1812                                    })
1813                                    .unwrap_or(false),
1814                                _ => false,
1815                            };
1816
1817                            move |window, cx| {
1818                                Some(ContextMenu::build(window, cx, {
1819                                    let focus_handle = focus_handle.clone();
1820                                    let workspace_handle = workspace_handle.clone();
1821                                    let open_action = open_action.clone();
1822                                    move |menu, _, _| {
1823                                        menu.context(focus_handle)
1824                                            .when(show_add_to_workspace, |menu| {
1825                                                menu.action(
1826                                                    "Add to this Workspace",
1827                                                    AddToWorkspace.boxed_clone(),
1828                                                )
1829                                                .separator()
1830                                            })
1831                                            .entry(
1832                                                "Open Local Folders",
1833                                                Some(open_action.boxed_clone()),
1834                                                {
1835                                                    let workspace_handle = workspace_handle.clone();
1836                                                    move |window, cx| {
1837                                                        open_local_project(
1838                                                            workspace_handle.clone(),
1839                                                            create_new_window,
1840                                                            window,
1841                                                            cx,
1842                                                        );
1843                                                    }
1844                                                },
1845                                            )
1846                                            .action(
1847                                                "Open Remote Folder",
1848                                                OpenRemote {
1849                                                    from_existing_connection: false,
1850                                                    create_new_window: false,
1851                                                }
1852                                                .boxed_clone(),
1853                                            )
1854                                    }
1855                                }))
1856                            }
1857                        }),
1858                )
1859                .into_any(),
1860        )
1861    }
1862}
1863
1864pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1865    match options {
1866        None => IconName::Screen,
1867        Some(options) => match options {
1868            RemoteConnectionOptions::Ssh(_) => IconName::Server,
1869            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1870            RemoteConnectionOptions::Docker(_) => IconName::Box,
1871            #[cfg(any(test, feature = "test-support"))]
1872            RemoteConnectionOptions::Mock(_) => IconName::Server,
1873        },
1874    }
1875}
1876
1877// Compute the highlighted text for the name and path
1878pub(crate) fn highlights_for_path(
1879    path: &Path,
1880    match_positions: &Vec<usize>,
1881    path_start_offset: usize,
1882) -> (Option<HighlightedMatch>, HighlightedMatch) {
1883    let path_string = path.to_string_lossy();
1884    let path_text = path_string.to_string();
1885    let path_byte_len = path_text.len();
1886    // Get the subset of match highlight positions that line up with the given path.
1887    // Also adjusts them to start at the path start
1888    let path_positions = match_positions
1889        .iter()
1890        .copied()
1891        .skip_while(|position| *position < path_start_offset)
1892        .take_while(|position| *position < path_start_offset + path_byte_len)
1893        .map(|position| position - path_start_offset)
1894        .collect::<Vec<_>>();
1895
1896    // Again subset the highlight positions to just those that line up with the file_name
1897    // again adjusted to the start of the file_name
1898    let file_name_text_and_positions = path.file_name().map(|file_name| {
1899        let file_name_text = file_name.to_string_lossy().into_owned();
1900        let file_name_start_byte = path_byte_len - file_name_text.len();
1901        let highlight_positions = path_positions
1902            .iter()
1903            .copied()
1904            .skip_while(|position| *position < file_name_start_byte)
1905            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1906            .map(|position| position - file_name_start_byte)
1907            .collect::<Vec<_>>();
1908        HighlightedMatch {
1909            text: file_name_text,
1910            highlight_positions,
1911            color: Color::Default,
1912        }
1913    });
1914
1915    (
1916        file_name_text_and_positions,
1917        HighlightedMatch {
1918            text: path_text,
1919            highlight_positions: path_positions,
1920            color: Color::Default,
1921        },
1922    )
1923}
1924fn open_local_project(
1925    workspace: WeakEntity<Workspace>,
1926    create_new_window: bool,
1927    window: &mut Window,
1928    cx: &mut App,
1929) {
1930    use gpui::PathPromptOptions;
1931    use project::DirectoryLister;
1932
1933    let Some(workspace) = workspace.upgrade() else {
1934        return;
1935    };
1936
1937    let paths = workspace.update(cx, |workspace, cx| {
1938        workspace.prompt_for_open_path(
1939            PathPromptOptions {
1940                files: true,
1941                directories: true,
1942                multiple: true,
1943                prompt: None,
1944            },
1945            DirectoryLister::Local(
1946                workspace.project().clone(),
1947                workspace.app_state().fs.clone(),
1948            ),
1949            window,
1950            cx,
1951        )
1952    });
1953
1954    let multi_workspace_handle = window.window_handle().downcast::<MultiWorkspace>();
1955    window
1956        .spawn(cx, async move |cx| {
1957            let Some(paths) = paths.await.log_err().flatten() else {
1958                return;
1959            };
1960            if !create_new_window {
1961                if let Some(handle) = multi_workspace_handle {
1962                    if let Some(task) = handle
1963                        .update(cx, |multi_workspace, window, cx| {
1964                            multi_workspace.open_project(paths, OpenMode::Activate, window, cx)
1965                        })
1966                        .log_err()
1967                    {
1968                        task.await.log_err();
1969                    }
1970                    return;
1971                }
1972            }
1973            if let Some(task) = workspace
1974                .update_in(cx, |workspace, window, cx| {
1975                    workspace.open_workspace_for_paths(OpenMode::NewWindow, paths, window, cx)
1976                })
1977                .log_err()
1978            {
1979                task.await.log_err();
1980            }
1981        })
1982        .detach();
1983}
1984
1985impl RecentProjectsDelegate {
1986    fn add_project_to_workspace(
1987        &mut self,
1988        paths: Vec<PathBuf>,
1989        window: &mut Window,
1990        cx: &mut Context<Picker<Self>>,
1991    ) {
1992        let Some(workspace) = self.workspace.upgrade() else {
1993            return;
1994        };
1995        let open_paths_task = workspace.update(cx, |workspace, cx| {
1996            workspace.open_paths(
1997                paths,
1998                OpenOptions {
1999                    visible: Some(OpenVisible::All),
2000                    ..Default::default()
2001                },
2002                None,
2003                window,
2004                cx,
2005            )
2006        });
2007        cx.spawn_in(window, async move |picker, cx| {
2008            let _result = open_paths_task.await;
2009            picker
2010                .update_in(cx, |picker, window, cx| {
2011                    let Some(workspace) = picker.delegate.workspace.upgrade() else {
2012                        return;
2013                    };
2014                    picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
2015                    let query = picker.query(cx);
2016                    picker.update_matches(query, window, cx);
2017                })
2018                .ok();
2019        })
2020        .detach();
2021    }
2022
2023    fn delete_recent_project(
2024        &self,
2025        ix: usize,
2026        window: &mut Window,
2027        cx: &mut Context<Picker<Self>>,
2028    ) {
2029        if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
2030            self.filtered_entries.get(ix)
2031        {
2032            let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
2033            let workspace_id = *workspace_id;
2034            let fs = self
2035                .workspace
2036                .upgrade()
2037                .map(|ws| ws.read(cx).app_state().fs.clone());
2038            let db = WorkspaceDb::global(cx);
2039            cx.spawn_in(window, async move |this, cx| {
2040                db.delete_workspace_by_id(workspace_id).await.log_err();
2041                let Some(fs) = fs else { return };
2042                let workspaces = db
2043                    .recent_workspaces_on_disk(fs.as_ref())
2044                    .await
2045                    .unwrap_or_default();
2046                let workspaces =
2047                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
2048                this.update_in(cx, move |picker, window, cx| {
2049                    picker.delegate.set_workspaces(workspaces);
2050                    picker
2051                        .delegate
2052                        .set_selected_index(ix.saturating_sub(1), window, cx);
2053                    picker.delegate.reset_selected_match_index = false;
2054                    picker.update_matches(picker.query(cx), window, cx);
2055                    // After deleting a project, we want to update the history manager to reflect the change.
2056                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
2057                    if let Some(history_manager) = HistoryManager::global(cx) {
2058                        history_manager
2059                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
2060                    }
2061                })
2062                .ok();
2063            })
2064            .detach();
2065        }
2066    }
2067
2068    fn remove_project_group(
2069        &mut self,
2070        key: ProjectGroupKey,
2071        window: &mut Window,
2072        cx: &mut Context<Picker<Self>>,
2073    ) {
2074        if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
2075            let key_for_remove = key.clone();
2076            cx.defer(move |cx| {
2077                handle
2078                    .update(cx, |multi_workspace, window, cx| {
2079                        multi_workspace
2080                            .remove_project_group(&key_for_remove, window, cx)
2081                            .detach_and_log_err(cx);
2082                    })
2083                    .log_err();
2084            });
2085        }
2086
2087        self.window_project_groups.retain(|k| k != &key);
2088    }
2089
2090    fn is_current_workspace(
2091        &self,
2092        workspace_id: WorkspaceId,
2093        cx: &mut Context<Picker<Self>>,
2094    ) -> bool {
2095        if let Some(workspace) = self.workspace.upgrade() {
2096            let workspace = workspace.read(cx);
2097            if Some(workspace_id) == workspace.database_id() {
2098                return true;
2099            }
2100        }
2101
2102        false
2103    }
2104
2105    fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
2106        if let Some(workspace) = self.workspace.upgrade() {
2107            return workspace.read(cx).project_group_key(cx) == *key;
2108        }
2109        false
2110    }
2111
2112    fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
2113        self.window_project_groups
2114            .iter()
2115            .any(|key| key.path_list() == paths)
2116    }
2117
2118    fn is_open_folder(&self, paths: &PathList) -> bool {
2119        if self.open_folders.is_empty() {
2120            return false;
2121        }
2122
2123        for workspace_path in paths.paths() {
2124            for open_folder in &self.open_folders {
2125                if workspace_path == &open_folder.path {
2126                    return true;
2127                }
2128            }
2129        }
2130
2131        false
2132    }
2133
2134    fn is_valid_recent_candidate(
2135        &self,
2136        workspace_id: WorkspaceId,
2137        paths: &PathList,
2138        cx: &mut Context<Picker<Self>>,
2139    ) -> bool {
2140        !self.is_current_workspace(workspace_id, cx)
2141            && !self.is_in_current_window_groups(paths)
2142            && !self.is_open_folder(paths)
2143    }
2144}
2145
2146#[cfg(test)]
2147mod tests {
2148    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
2149
2150    use serde_json::json;
2151    use settings::SettingsStore;
2152    use util::path;
2153    use workspace::{AppState, open_paths};
2154
2155    use super::*;
2156
2157    #[gpui::test]
2158    async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2159        let app_state = init_test(cx);
2160
2161        app_state
2162            .fs
2163            .as_fake()
2164            .insert_tree(
2165                path!("/project"),
2166                json!({
2167                    ".devcontainer": {
2168                        "devcontainer.json": "{}"
2169                    },
2170                    "src": {
2171                        "main.rs": "fn main() {}"
2172                    }
2173                }),
2174            )
2175            .await;
2176
2177        // Open a file path (not a directory) so that the worktree root is a
2178        // file. This means `active_project_directory` returns `None`, which
2179        // causes `DevContainerContext::from_workspace` to return `None`,
2180        // preventing `open_dev_container` from spawning real I/O (docker
2181        // commands, shell environment loading) that is incompatible with the
2182        // test scheduler. The modal is still created and the re-entrancy
2183        // guard that this test validates is still exercised.
2184        cx.update(|cx| {
2185            open_paths(
2186                &[PathBuf::from(path!("/project/src/main.rs"))],
2187                app_state,
2188                workspace::OpenOptions::default(),
2189                cx,
2190            )
2191        })
2192        .await
2193        .unwrap();
2194
2195        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2196        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2197
2198        cx.run_until_parked();
2199
2200        // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2201        // -> Workspace::update -> toggle_modal -> new_dev_container.
2202        // Before the fix, this panicked with "cannot read workspace::Workspace while
2203        // it is already being updated" because new_dev_container and open_dev_container
2204        // tried to read the Workspace entity through a WeakEntity handle while it was
2205        // already leased by the outer update.
2206        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2207
2208        multi_workspace
2209            .update(cx, |multi_workspace, _, cx| {
2210                let modal = multi_workspace
2211                    .workspace()
2212                    .read(cx)
2213                    .active_modal::<RemoteServerProjects>(cx);
2214                assert!(
2215                    modal.is_some(),
2216                    "Dev container modal should be open after dispatching OpenDevContainer"
2217                );
2218            })
2219            .unwrap();
2220    }
2221
2222    #[gpui::test]
2223    async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2224        let app_state = init_test(cx);
2225
2226        app_state
2227            .fs
2228            .as_fake()
2229            .insert_tree(
2230                path!("/project"),
2231                json!({
2232                    ".devcontainer": {
2233                        "devcontainer.json": "{}"
2234                    },
2235                    "src": {
2236                        "main.rs": "fn main() {}"
2237                    }
2238                }),
2239            )
2240            .await;
2241
2242        cx.update(|cx| {
2243            open_paths(
2244                &[PathBuf::from(path!("/project"))],
2245                app_state,
2246                workspace::OpenOptions::default(),
2247                cx,
2248            )
2249        })
2250        .await
2251        .unwrap();
2252
2253        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2254        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2255
2256        cx.run_until_parked();
2257
2258        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2259
2260        multi_workspace
2261            .update(cx, |multi_workspace, _, cx| {
2262                assert!(
2263                    multi_workspace
2264                        .active_modal::<RemoteServerProjects>(cx)
2265                        .is_some(),
2266                    "Dev container modal should be open"
2267                );
2268            })
2269            .unwrap();
2270
2271        // Click outside the modal (on the backdrop) to try to dismiss it
2272        let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2273        vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2274
2275        multi_workspace
2276            .update(cx, |multi_workspace, _, cx| {
2277                assert!(
2278                    multi_workspace
2279                        .active_modal::<RemoteServerProjects>(cx)
2280                        .is_some(),
2281                    "Dev container modal should remain open during creation"
2282                );
2283            })
2284            .unwrap();
2285    }
2286
2287    #[gpui::test]
2288    async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2289        let app_state = init_test(cx);
2290
2291        app_state
2292            .fs
2293            .as_fake()
2294            .insert_tree(
2295                path!("/project"),
2296                json!({
2297                    ".devcontainer": {
2298                        "rust": {
2299                            "devcontainer.json": "{}"
2300                        },
2301                        "python": {
2302                            "devcontainer.json": "{}"
2303                        }
2304                    },
2305                    "src": {
2306                        "main.rs": "fn main() {}"
2307                    }
2308                }),
2309            )
2310            .await;
2311
2312        cx.update(|cx| {
2313            open_paths(
2314                &[PathBuf::from(path!("/project"))],
2315                app_state,
2316                workspace::OpenOptions::default(),
2317                cx,
2318            )
2319        })
2320        .await
2321        .unwrap();
2322
2323        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2324        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2325
2326        cx.run_until_parked();
2327
2328        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2329
2330        multi_workspace
2331            .update(cx, |multi_workspace, _, cx| {
2332                let modal = multi_workspace
2333                    .workspace()
2334                    .read(cx)
2335                    .active_modal::<RemoteServerProjects>(cx);
2336                assert!(
2337                    modal.is_some(),
2338                    "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2339                );
2340            })
2341            .unwrap();
2342    }
2343
2344    #[gpui::test]
2345    async fn test_open_local_project_reuses_multi_workspace_window(cx: &mut TestAppContext) {
2346        let app_state = init_test(cx);
2347
2348        // Disable system path prompts so the injected mock is used.
2349        cx.update(|cx| {
2350            SettingsStore::update_global(cx, |store, cx| {
2351                store.update_user_settings(cx, |settings| {
2352                    settings.workspace.use_system_path_prompts = Some(false);
2353                });
2354            });
2355        });
2356
2357        app_state
2358            .fs
2359            .as_fake()
2360            .insert_tree(
2361                path!("/initial-project"),
2362                json!({ "src": { "main.rs": "" } }),
2363            )
2364            .await;
2365        app_state
2366            .fs
2367            .as_fake()
2368            .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
2369            .await;
2370
2371        cx.update(|cx| {
2372            open_paths(
2373                &[PathBuf::from(path!("/initial-project"))],
2374                app_state.clone(),
2375                workspace::OpenOptions::default(),
2376                cx,
2377            )
2378        })
2379        .await
2380        .unwrap();
2381
2382        let initial_window_count = cx.update(|cx| cx.windows().len());
2383        assert_eq!(initial_window_count, 1);
2384
2385        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2386        cx.run_until_parked();
2387
2388        let workspace = multi_workspace
2389            .read_with(cx, |mw, _| mw.workspace().clone())
2390            .unwrap();
2391
2392        // Set up the prompt mock to return the new project path.
2393        workspace.update(cx, |workspace, _cx| {
2394            workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
2395                let (tx, rx) = futures::channel::oneshot::channel();
2396                tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
2397                    .ok();
2398                rx
2399            }));
2400        });
2401
2402        // Call open_local_project with create_new_window: false.
2403        let weak_workspace = workspace.downgrade();
2404        multi_workspace
2405            .update(cx, |_, window, cx| {
2406                open_local_project(weak_workspace, false, window, cx);
2407            })
2408            .unwrap();
2409
2410        cx.run_until_parked();
2411
2412        // Should NOT have opened a new window.
2413        let final_window_count = cx.update(|cx| cx.windows().len());
2414        assert_eq!(
2415            final_window_count, initial_window_count,
2416            "open_local_project with create_new_window=false should reuse the current multi-workspace window"
2417        );
2418    }
2419
2420    #[gpui::test]
2421    async fn test_open_local_project_new_window_creates_new_window(cx: &mut TestAppContext) {
2422        let app_state = init_test(cx);
2423
2424        // Disable system path prompts so the injected mock is used.
2425        cx.update(|cx| {
2426            SettingsStore::update_global(cx, |store, cx| {
2427                store.update_user_settings(cx, |settings| {
2428                    settings.workspace.use_system_path_prompts = Some(false);
2429                });
2430            });
2431        });
2432
2433        app_state
2434            .fs
2435            .as_fake()
2436            .insert_tree(
2437                path!("/initial-project"),
2438                json!({ "src": { "main.rs": "" } }),
2439            )
2440            .await;
2441        app_state
2442            .fs
2443            .as_fake()
2444            .insert_tree(path!("/new-project"), json!({ "lib": { "mod.rs": "" } }))
2445            .await;
2446
2447        cx.update(|cx| {
2448            open_paths(
2449                &[PathBuf::from(path!("/initial-project"))],
2450                app_state.clone(),
2451                workspace::OpenOptions::default(),
2452                cx,
2453            )
2454        })
2455        .await
2456        .unwrap();
2457
2458        let initial_window_count = cx.update(|cx| cx.windows().len());
2459        assert_eq!(initial_window_count, 1);
2460
2461        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2462        cx.run_until_parked();
2463
2464        let workspace = multi_workspace
2465            .read_with(cx, |mw, _| mw.workspace().clone())
2466            .unwrap();
2467
2468        // Set up the prompt mock to return the new project path.
2469        workspace.update(cx, |workspace, _cx| {
2470            workspace.set_prompt_for_open_path(Box::new(|_, _, _, _| {
2471                let (tx, rx) = futures::channel::oneshot::channel();
2472                tx.send(Some(vec![PathBuf::from(path!("/new-project"))]))
2473                    .ok();
2474                rx
2475            }));
2476        });
2477
2478        // Call open_local_project with create_new_window: true.
2479        let weak_workspace = workspace.downgrade();
2480        multi_workspace
2481            .update(cx, |_, window, cx| {
2482                open_local_project(weak_workspace, true, window, cx);
2483            })
2484            .unwrap();
2485
2486        cx.run_until_parked();
2487
2488        // Should have opened a new window.
2489        let final_window_count = cx.update(|cx| cx.windows().len());
2490        assert_eq!(
2491            final_window_count,
2492            initial_window_count + 1,
2493            "open_local_project with create_new_window=true should open a new window"
2494        );
2495    }
2496
2497    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2498        cx.update(|cx| {
2499            let state = AppState::test(cx);
2500            crate::init(cx);
2501            editor::init(cx);
2502            state
2503        })
2504    }
2505}