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