recent_projects.rs

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