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.find_or_create_local_workspace(
1137                                    path_list,
1138                                    &[],
1139                                    window,
1140                                    cx,
1141                                )
1142                            })
1143                            .log_err()
1144                        {
1145                            task.detach_and_log_err(cx);
1146                        }
1147                    });
1148                }
1149                cx.emit(DismissEvent);
1150            }
1151            Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1152                let Some(workspace) = self.workspace.upgrade() else {
1153                    return;
1154                };
1155                let Some((
1156                    candidate_workspace_id,
1157                    candidate_workspace_location,
1158                    candidate_workspace_paths,
1159                    _,
1160                )) = self.workspaces.get(selected_match.candidate_id)
1161                else {
1162                    return;
1163                };
1164
1165                let replace_current_window = self.create_new_window == secondary;
1166                let candidate_workspace_id = *candidate_workspace_id;
1167                let candidate_workspace_location = candidate_workspace_location.clone();
1168                let candidate_workspace_paths = candidate_workspace_paths.clone();
1169
1170                workspace.update(cx, |workspace, cx| {
1171                    if workspace.database_id() == Some(candidate_workspace_id) {
1172                        return;
1173                    }
1174                    match candidate_workspace_location {
1175                        SerializedWorkspaceLocation::Local => {
1176                            let paths = candidate_workspace_paths.paths().to_vec();
1177                            if replace_current_window {
1178                                if let Some(handle) =
1179                                    window.window_handle().downcast::<MultiWorkspace>()
1180                                {
1181                                    cx.defer(move |cx| {
1182                                        if let Some(task) = handle
1183                                            .update(cx, |multi_workspace, window, cx| {
1184                                                multi_workspace.open_project(
1185                                                    paths,
1186                                                    OpenMode::Activate,
1187                                                    window,
1188                                                    cx,
1189                                                )
1190                                            })
1191                                            .log_err()
1192                                        {
1193                                            task.detach_and_log_err(cx);
1194                                        }
1195                                    });
1196                                }
1197                                return;
1198                            } else {
1199                                workspace
1200                                    .open_workspace_for_paths(
1201                                        OpenMode::NewWindow,
1202                                        paths,
1203                                        window,
1204                                        cx,
1205                                    )
1206                                    .detach_and_prompt_err(
1207                                        "Failed to open project",
1208                                        window,
1209                                        cx,
1210                                        |_, _, _| None,
1211                                    );
1212                            }
1213                        }
1214                        SerializedWorkspaceLocation::Remote(mut connection) => {
1215                            let app_state = workspace.app_state().clone();
1216                            let replace_window = if replace_current_window {
1217                                window.window_handle().downcast::<MultiWorkspace>()
1218                            } else {
1219                                None
1220                            };
1221                            let open_options = OpenOptions {
1222                                requesting_window: replace_window,
1223                                ..Default::default()
1224                            };
1225                            if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1226                                RemoteSettings::get_global(cx)
1227                                    .fill_connection_options_from_settings(connection);
1228                            };
1229                            let paths = candidate_workspace_paths.paths().to_vec();
1230                            cx.spawn_in(window, async move |_, cx| {
1231                                open_remote_project(
1232                                    connection.clone(),
1233                                    paths,
1234                                    app_state,
1235                                    open_options,
1236                                    cx,
1237                                )
1238                                .await
1239                            })
1240                            .detach_and_prompt_err(
1241                                "Failed to open project",
1242                                window,
1243                                cx,
1244                                |_, _, _| None,
1245                            );
1246                        }
1247                    }
1248                });
1249                cx.emit(DismissEvent);
1250            }
1251            _ => {}
1252        }
1253    }
1254
1255    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1256
1257    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1258        let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1259            "Recently opened projects will show up here".into()
1260        } else {
1261            "No matches".into()
1262        };
1263        Some(text)
1264    }
1265
1266    fn render_match(
1267        &self,
1268        ix: usize,
1269        selected: bool,
1270        window: &mut Window,
1271        cx: &mut Context<Picker<Self>>,
1272    ) -> Option<Self::ListItem> {
1273        match self.filtered_entries.get(ix)? {
1274            ProjectPickerEntry::Header(title) => Some(
1275                v_flex()
1276                    .w_full()
1277                    .gap_1()
1278                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1279                    .child(ListSubHeader::new(title.clone()).inset(true))
1280                    .into_any_element(),
1281            ),
1282            ProjectPickerEntry::OpenFolder { index, positions } => {
1283                let folder = self.open_folders.get(*index)?;
1284                let name = folder.name.clone();
1285                let path = folder.path.compact();
1286                let branch = folder.branch.clone();
1287                let is_active = folder.is_active;
1288                let worktree_id = folder.worktree_id;
1289                let positions = positions.clone();
1290                let show_path = self.style == ProjectPickerStyle::Modal;
1291
1292                let secondary_actions = h_flex()
1293                    .gap_1()
1294                    .child(
1295                        IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1296                            .icon_size(IconSize::Small)
1297                            .tooltip(Tooltip::text("Remove Folder from Workspace"))
1298                            .on_click(cx.listener(move |picker, _, window, cx| {
1299                                let Some(workspace) = picker.delegate.workspace.upgrade() else {
1300                                    return;
1301                                };
1302                                workspace.update(cx, |workspace, cx| {
1303                                    let project = workspace.project().clone();
1304                                    project.update(cx, |project, cx| {
1305                                        project.remove_worktree(worktree_id, cx);
1306                                    });
1307                                });
1308                                picker.delegate.open_folders =
1309                                    get_open_folders(workspace.read(cx), cx);
1310                                let query = picker.query(cx);
1311                                picker.update_matches(query, window, cx);
1312                            })),
1313                    )
1314                    .into_any_element();
1315
1316                let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1317
1318                Some(
1319                    ListItem::new(ix)
1320                        .toggle_state(selected)
1321                        .inset(true)
1322                        .spacing(ListItemSpacing::Sparse)
1323                        .child(
1324                            h_flex()
1325                                .id("open_folder_item")
1326                                .gap_3()
1327                                .flex_grow()
1328                                .when(self.has_any_non_local_projects, |this| {
1329                                    this.child(Icon::new(icon).color(Color::Muted))
1330                                })
1331                                .child(
1332                                    v_flex()
1333                                        .child(
1334                                            h_flex()
1335                                                .gap_1()
1336                                                .child({
1337                                                    let highlighted = HighlightedMatch {
1338                                                        text: name.to_string(),
1339                                                        highlight_positions: positions,
1340                                                        color: Color::Default,
1341                                                    };
1342                                                    highlighted.render(window, cx)
1343                                                })
1344                                                .when_some(branch, |this, branch| {
1345                                                    this.child(
1346                                                        Label::new(branch).color(Color::Muted),
1347                                                    )
1348                                                })
1349                                                .when(is_active, |this| {
1350                                                    this.child(
1351                                                        Icon::new(IconName::Check)
1352                                                            .size(IconSize::Small)
1353                                                            .color(Color::Accent),
1354                                                    )
1355                                                }),
1356                                        )
1357                                        .when(show_path, |this| {
1358                                            this.child(
1359                                                Label::new(path.to_string_lossy().to_string())
1360                                                    .size(LabelSize::Small)
1361                                                    .color(Color::Muted),
1362                                            )
1363                                        }),
1364                                )
1365                                .when(!show_path, |this| {
1366                                    this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1367                                }),
1368                        )
1369                        .end_slot(secondary_actions)
1370                        .show_end_slot_on_hover()
1371                        .into_any_element(),
1372                )
1373            }
1374            ProjectPickerEntry::ProjectGroup(hit) => {
1375                let key = self.window_project_groups.get(hit.candidate_id)?;
1376                let is_active = self.is_active_project_group(key, cx);
1377                let paths = key.path_list();
1378                let ordered_paths: Vec<_> = paths
1379                    .ordered_paths()
1380                    .map(|p| p.compact().to_string_lossy().to_string())
1381                    .collect();
1382                let tooltip_path: SharedString = ordered_paths.join("\n").into();
1383
1384                let mut path_start_offset = 0;
1385                let (match_labels, path_highlights): (Vec<_>, Vec<_>) = paths
1386                    .ordered_paths()
1387                    .map(|p| p.compact())
1388                    .map(|path| {
1389                        let highlighted_text =
1390                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1391                        path_start_offset += highlighted_text.1.text.len();
1392                        highlighted_text
1393                    })
1394                    .unzip();
1395
1396                let highlighted_match = HighlightedMatchWithPaths {
1397                    prefix: None,
1398                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1399                    paths: path_highlights,
1400                    active: is_active,
1401                };
1402
1403                let project_group_key = key.clone();
1404                let secondary_actions = h_flex()
1405                    .gap_1()
1406                    .when(!is_active, |this| {
1407                        this.child(
1408                            IconButton::new("remove_open_project", IconName::Close)
1409                                .icon_size(IconSize::Small)
1410                                .tooltip(Tooltip::text("Remove Project from Window"))
1411                                .on_click({
1412                                    let project_group_key = project_group_key.clone();
1413                                    cx.listener(move |picker, _, window, cx| {
1414                                        cx.stop_propagation();
1415                                        window.prevent_default();
1416                                        picker.delegate.remove_project_group(
1417                                            project_group_key.clone(),
1418                                            window,
1419                                            cx,
1420                                        );
1421                                        let query = picker.query(cx);
1422                                        picker.update_matches(query, window, cx);
1423                                    })
1424                                }),
1425                        )
1426                    })
1427                    .into_any_element();
1428
1429                Some(
1430                    ListItem::new(ix)
1431                        .toggle_state(selected)
1432                        .inset(true)
1433                        .spacing(ListItemSpacing::Sparse)
1434                        .child(
1435                            h_flex()
1436                                .id("open_project_info_container")
1437                                .gap_3()
1438                                .child({
1439                                    let mut highlighted = highlighted_match;
1440                                    if !self.render_paths {
1441                                        highlighted.paths.clear();
1442                                    }
1443                                    highlighted.render(window, cx)
1444                                })
1445                                .tooltip(Tooltip::text(tooltip_path)),
1446                        )
1447                        .end_slot(secondary_actions)
1448                        .show_end_slot_on_hover()
1449                        .into_any_element(),
1450                )
1451            }
1452            ProjectPickerEntry::RecentProject(hit) => {
1453                let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1454                let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1455                let paths_to_add = paths.paths().to_vec();
1456                let ordered_paths: Vec<_> = paths
1457                    .ordered_paths()
1458                    .map(|p| p.compact().to_string_lossy().to_string())
1459                    .collect();
1460                let tooltip_path: SharedString = match &location {
1461                    SerializedWorkspaceLocation::Remote(options) => {
1462                        let host = options.display_name();
1463                        if ordered_paths.len() == 1 {
1464                            format!("{} ({})", ordered_paths[0], host).into()
1465                        } else {
1466                            format!("{}\n({})", ordered_paths.join("\n"), host).into()
1467                        }
1468                    }
1469                    _ => ordered_paths.join("\n").into(),
1470                };
1471
1472                let mut path_start_offset = 0;
1473                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1474                    .ordered_paths()
1475                    .map(|p| p.compact())
1476                    .map(|path| {
1477                        let highlighted_text =
1478                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1479                        path_start_offset += highlighted_text.1.text.len();
1480                        highlighted_text
1481                    })
1482                    .unzip();
1483
1484                let prefix = match &location {
1485                    SerializedWorkspaceLocation::Remote(options) => {
1486                        Some(SharedString::from(options.display_name()))
1487                    }
1488                    _ => None,
1489                };
1490
1491                let highlighted_match = HighlightedMatchWithPaths {
1492                    prefix,
1493                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1494                    paths,
1495                    active: false,
1496                };
1497
1498                let focus_handle = self.focus_handle.clone();
1499
1500                let secondary_actions = h_flex()
1501                    .gap_px()
1502                    .when(is_local, |this| {
1503                        this.child(
1504                            IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1505                                .icon_size(IconSize::Small)
1506                                .tooltip(move |_, cx| {
1507                                    Tooltip::with_meta(
1508                                        "Add Project to this Workspace",
1509                                        None,
1510                                        "As a multi-root folder project",
1511                                        cx,
1512                                    )
1513                                })
1514                                .on_click({
1515                                    let paths_to_add = paths_to_add.clone();
1516                                    cx.listener(move |picker, _event, window, cx| {
1517                                        cx.stop_propagation();
1518                                        window.prevent_default();
1519                                        picker.delegate.add_project_to_workspace(
1520                                            paths_to_add.clone(),
1521                                            window,
1522                                            cx,
1523                                        );
1524                                    })
1525                                }),
1526                        )
1527                    })
1528                    .child(
1529                        IconButton::new("open_new_window", IconName::OpenNewWindow)
1530                            .icon_size(IconSize::Small)
1531                            .tooltip({
1532                                move |_, cx| {
1533                                    Tooltip::for_action_in(
1534                                        "Open Project in New Window",
1535                                        &menu::SecondaryConfirm,
1536                                        &focus_handle,
1537                                        cx,
1538                                    )
1539                                }
1540                            })
1541                            .on_click(cx.listener(move |this, _event, window, cx| {
1542                                cx.stop_propagation();
1543                                window.prevent_default();
1544                                this.delegate.set_selected_index(ix, window, cx);
1545                                this.delegate.confirm(true, window, cx);
1546                            })),
1547                    )
1548                    .child(
1549                        IconButton::new("delete", IconName::Close)
1550                            .icon_size(IconSize::Small)
1551                            .tooltip(Tooltip::text("Delete from Recent Projects"))
1552                            .on_click(cx.listener(move |this, _event, window, cx| {
1553                                cx.stop_propagation();
1554                                window.prevent_default();
1555                                this.delegate.delete_recent_project(ix, window, cx)
1556                            })),
1557                    )
1558                    .into_any_element();
1559
1560                let icon = icon_for_remote_connection(match location {
1561                    SerializedWorkspaceLocation::Local => None,
1562                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1563                });
1564
1565                Some(
1566                    ListItem::new(ix)
1567                        .toggle_state(selected)
1568                        .inset(true)
1569                        .spacing(ListItemSpacing::Sparse)
1570                        .child(
1571                            h_flex()
1572                                .id("project_info_container")
1573                                .gap_3()
1574                                .flex_grow()
1575                                .when(self.has_any_non_local_projects, |this| {
1576                                    this.child(Icon::new(icon).color(Color::Muted))
1577                                })
1578                                .child({
1579                                    let mut highlighted = highlighted_match;
1580                                    if !self.render_paths {
1581                                        highlighted.paths.clear();
1582                                    }
1583                                    highlighted.render(window, cx)
1584                                })
1585                                .tooltip(move |_, cx| {
1586                                    Tooltip::with_meta(
1587                                        "Open Project in This Window",
1588                                        None,
1589                                        tooltip_path.clone(),
1590                                        cx,
1591                                    )
1592                                }),
1593                        )
1594                        .end_slot(secondary_actions)
1595                        .show_end_slot_on_hover()
1596                        .into_any_element(),
1597                )
1598            }
1599        }
1600    }
1601
1602    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1603        let focus_handle = self.focus_handle.clone();
1604        let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1605        let is_already_open_entry = matches!(
1606            self.filtered_entries.get(self.selected_index),
1607            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::ProjectGroup(_))
1608        );
1609
1610        if popover_style {
1611            return Some(
1612                v_flex()
1613                    .flex_1()
1614                    .p_1p5()
1615                    .gap_1()
1616                    .border_t_1()
1617                    .border_color(cx.theme().colors().border_variant)
1618                    .child({
1619                        let open_action = workspace::Open::default();
1620                        Button::new("open_local_folder", "Open Local Project")
1621                            .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1622                            .on_click(move |_, window, cx| {
1623                                window.dispatch_action(open_action.boxed_clone(), cx)
1624                            })
1625                    })
1626                    .child(
1627                        Button::new("open_remote_folder", "Open Remote Project")
1628                            .key_binding(KeyBinding::for_action(
1629                                &OpenRemote {
1630                                    from_existing_connection: false,
1631                                    create_new_window: false,
1632                                },
1633                                cx,
1634                            ))
1635                            .on_click(|_, window, cx| {
1636                                window.dispatch_action(
1637                                    OpenRemote {
1638                                        from_existing_connection: false,
1639                                        create_new_window: false,
1640                                    }
1641                                    .boxed_clone(),
1642                                    cx,
1643                                )
1644                            }),
1645                    )
1646                    .into_any(),
1647            );
1648        }
1649
1650        let selected_entry = self.filtered_entries.get(self.selected_index);
1651
1652        let is_current_workspace_entry =
1653            if let Some(ProjectPickerEntry::ProjectGroup(hit)) = selected_entry {
1654                self.window_project_groups
1655                    .get(hit.candidate_id)
1656                    .is_some_and(|key| self.is_active_project_group(key, cx))
1657            } else {
1658                false
1659            };
1660
1661        let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1662            Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1663                Button::new("remove_selected", "Remove Folder")
1664                    .key_binding(KeyBinding::for_action_in(
1665                        &RemoveSelected,
1666                        &focus_handle,
1667                        cx,
1668                    ))
1669                    .on_click(|_, window, cx| {
1670                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1671                    })
1672                    .into_any_element(),
1673            ),
1674            Some(ProjectPickerEntry::ProjectGroup(_)) if !is_current_workspace_entry => Some(
1675                Button::new("remove_selected", "Remove from Window")
1676                    .key_binding(KeyBinding::for_action_in(
1677                        &RemoveSelected,
1678                        &focus_handle,
1679                        cx,
1680                    ))
1681                    .on_click(|_, window, cx| {
1682                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1683                    })
1684                    .into_any_element(),
1685            ),
1686            Some(ProjectPickerEntry::RecentProject(_)) => Some(
1687                Button::new("delete_recent", "Delete")
1688                    .key_binding(KeyBinding::for_action_in(
1689                        &RemoveSelected,
1690                        &focus_handle,
1691                        cx,
1692                    ))
1693                    .on_click(|_, window, cx| {
1694                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1695                    })
1696                    .into_any_element(),
1697            ),
1698            _ => None,
1699        };
1700
1701        Some(
1702            h_flex()
1703                .flex_1()
1704                .p_1p5()
1705                .gap_1()
1706                .justify_end()
1707                .border_t_1()
1708                .border_color(cx.theme().colors().border_variant)
1709                .when_some(secondary_footer_actions, |this, actions| {
1710                    this.child(actions)
1711                })
1712                .map(|this| {
1713                    if is_already_open_entry {
1714                        this.child(
1715                            Button::new("activate", "Activate")
1716                                .key_binding(KeyBinding::for_action_in(
1717                                    &menu::Confirm,
1718                                    &focus_handle,
1719                                    cx,
1720                                ))
1721                                .on_click(|_, window, cx| {
1722                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1723                                }),
1724                        )
1725                    } else {
1726                        this.child(
1727                            Button::new("open_new_window", "New Window")
1728                                .key_binding(KeyBinding::for_action_in(
1729                                    &menu::SecondaryConfirm,
1730                                    &focus_handle,
1731                                    cx,
1732                                ))
1733                                .on_click(|_, window, cx| {
1734                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1735                                }),
1736                        )
1737                        .child(
1738                            Button::new("open_here", "Open")
1739                                .key_binding(KeyBinding::for_action_in(
1740                                    &menu::Confirm,
1741                                    &focus_handle,
1742                                    cx,
1743                                ))
1744                                .on_click(|_, window, cx| {
1745                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1746                                }),
1747                        )
1748                    }
1749                })
1750                .child(Divider::vertical())
1751                .child(
1752                    PopoverMenu::new("actions-menu-popover")
1753                        .with_handle(self.actions_menu_handle.clone())
1754                        .anchor(gpui::Corner::BottomRight)
1755                        .offset(gpui::Point {
1756                            x: px(0.0),
1757                            y: px(-2.0),
1758                        })
1759                        .trigger(
1760                            Button::new("actions-trigger", "Actions")
1761                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1762                                .key_binding(KeyBinding::for_action_in(
1763                                    &ToggleActionsMenu,
1764                                    &focus_handle,
1765                                    cx,
1766                                )),
1767                        )
1768                        .menu({
1769                            let focus_handle = focus_handle.clone();
1770                            let show_add_to_workspace = match selected_entry {
1771                                Some(ProjectPickerEntry::RecentProject(hit)) => self
1772                                    .workspaces
1773                                    .get(hit.candidate_id)
1774                                    .map(|(_, loc, ..)| {
1775                                        matches!(loc, SerializedWorkspaceLocation::Local)
1776                                    })
1777                                    .unwrap_or(false),
1778                                _ => false,
1779                            };
1780
1781                            move |window, cx| {
1782                                Some(ContextMenu::build(window, cx, {
1783                                    let focus_handle = focus_handle.clone();
1784                                    move |menu, _, _| {
1785                                        menu.context(focus_handle)
1786                                            .when(show_add_to_workspace, |menu| {
1787                                                menu.action(
1788                                                    "Add to this Workspace",
1789                                                    AddToWorkspace.boxed_clone(),
1790                                                )
1791                                                .separator()
1792                                            })
1793                                            .action(
1794                                                "Open Local Project",
1795                                                workspace::Open::default().boxed_clone(),
1796                                            )
1797                                            .action(
1798                                                "Open Remote Project",
1799                                                OpenRemote {
1800                                                    from_existing_connection: false,
1801                                                    create_new_window: false,
1802                                                }
1803                                                .boxed_clone(),
1804                                            )
1805                                    }
1806                                }))
1807                            }
1808                        }),
1809                )
1810                .into_any(),
1811        )
1812    }
1813}
1814
1815pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1816    match options {
1817        None => IconName::Screen,
1818        Some(options) => match options {
1819            RemoteConnectionOptions::Ssh(_) => IconName::Server,
1820            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1821            RemoteConnectionOptions::Docker(_) => IconName::Box,
1822            #[cfg(any(test, feature = "test-support"))]
1823            RemoteConnectionOptions::Mock(_) => IconName::Server,
1824        },
1825    }
1826}
1827
1828// Compute the highlighted text for the name and path
1829pub(crate) fn highlights_for_path(
1830    path: &Path,
1831    match_positions: &Vec<usize>,
1832    path_start_offset: usize,
1833) -> (Option<HighlightedMatch>, HighlightedMatch) {
1834    let path_string = path.to_string_lossy();
1835    let path_text = path_string.to_string();
1836    let path_byte_len = path_text.len();
1837    // Get the subset of match highlight positions that line up with the given path.
1838    // Also adjusts them to start at the path start
1839    let path_positions = match_positions
1840        .iter()
1841        .copied()
1842        .skip_while(|position| *position < path_start_offset)
1843        .take_while(|position| *position < path_start_offset + path_byte_len)
1844        .map(|position| position - path_start_offset)
1845        .collect::<Vec<_>>();
1846
1847    // Again subset the highlight positions to just those that line up with the file_name
1848    // again adjusted to the start of the file_name
1849    let file_name_text_and_positions = path.file_name().map(|file_name| {
1850        let file_name_text = file_name.to_string_lossy().into_owned();
1851        let file_name_start_byte = path_byte_len - file_name_text.len();
1852        let highlight_positions = path_positions
1853            .iter()
1854            .copied()
1855            .skip_while(|position| *position < file_name_start_byte)
1856            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1857            .map(|position| position - file_name_start_byte)
1858            .collect::<Vec<_>>();
1859        HighlightedMatch {
1860            text: file_name_text,
1861            highlight_positions,
1862            color: Color::Default,
1863        }
1864    });
1865
1866    (
1867        file_name_text_and_positions,
1868        HighlightedMatch {
1869            text: path_text,
1870            highlight_positions: path_positions,
1871            color: Color::Default,
1872        },
1873    )
1874}
1875impl RecentProjectsDelegate {
1876    fn add_project_to_workspace(
1877        &mut self,
1878        paths: Vec<PathBuf>,
1879        window: &mut Window,
1880        cx: &mut Context<Picker<Self>>,
1881    ) {
1882        let Some(workspace) = self.workspace.upgrade() else {
1883            return;
1884        };
1885        let open_paths_task = workspace.update(cx, |workspace, cx| {
1886            workspace.open_paths(
1887                paths,
1888                OpenOptions {
1889                    visible: Some(OpenVisible::All),
1890                    ..Default::default()
1891                },
1892                None,
1893                window,
1894                cx,
1895            )
1896        });
1897        cx.spawn_in(window, async move |picker, cx| {
1898            let _result = open_paths_task.await;
1899            picker
1900                .update_in(cx, |picker, window, cx| {
1901                    let Some(workspace) = picker.delegate.workspace.upgrade() else {
1902                        return;
1903                    };
1904                    picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1905                    let query = picker.query(cx);
1906                    picker.update_matches(query, window, cx);
1907                })
1908                .ok();
1909        })
1910        .detach();
1911    }
1912
1913    fn delete_recent_project(
1914        &self,
1915        ix: usize,
1916        window: &mut Window,
1917        cx: &mut Context<Picker<Self>>,
1918    ) {
1919        if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1920            self.filtered_entries.get(ix)
1921        {
1922            let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1923            let workspace_id = *workspace_id;
1924            let fs = self
1925                .workspace
1926                .upgrade()
1927                .map(|ws| ws.read(cx).app_state().fs.clone());
1928            let db = WorkspaceDb::global(cx);
1929            cx.spawn_in(window, async move |this, cx| {
1930                db.delete_workspace_by_id(workspace_id).await.log_err();
1931                let Some(fs) = fs else { return };
1932                let workspaces = db
1933                    .recent_workspaces_on_disk(fs.as_ref())
1934                    .await
1935                    .unwrap_or_default();
1936                let workspaces =
1937                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1938                this.update_in(cx, move |picker, window, cx| {
1939                    picker.delegate.set_workspaces(workspaces);
1940                    picker
1941                        .delegate
1942                        .set_selected_index(ix.saturating_sub(1), window, cx);
1943                    picker.delegate.reset_selected_match_index = false;
1944                    picker.update_matches(picker.query(cx), window, cx);
1945                    // After deleting a project, we want to update the history manager to reflect the change.
1946                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1947                    if let Some(history_manager) = HistoryManager::global(cx) {
1948                        history_manager
1949                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1950                    }
1951                })
1952                .ok();
1953            })
1954            .detach();
1955        }
1956    }
1957
1958    fn remove_project_group(
1959        &mut self,
1960        key: ProjectGroupKey,
1961        window: &mut Window,
1962        cx: &mut Context<Picker<Self>>,
1963    ) {
1964        if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1965            let key_for_remove = key.clone();
1966            cx.defer(move |cx| {
1967                handle
1968                    .update(cx, |multi_workspace, window, cx| {
1969                        multi_workspace
1970                            .remove_project_group(&key_for_remove, window, cx)
1971                            .detach_and_log_err(cx);
1972                    })
1973                    .log_err();
1974            });
1975        }
1976
1977        self.window_project_groups.retain(|k| k != &key);
1978    }
1979
1980    fn is_current_workspace(
1981        &self,
1982        workspace_id: WorkspaceId,
1983        cx: &mut Context<Picker<Self>>,
1984    ) -> bool {
1985        if let Some(workspace) = self.workspace.upgrade() {
1986            let workspace = workspace.read(cx);
1987            if Some(workspace_id) == workspace.database_id() {
1988                return true;
1989            }
1990        }
1991
1992        false
1993    }
1994
1995    fn is_active_project_group(&self, key: &ProjectGroupKey, cx: &App) -> bool {
1996        if let Some(workspace) = self.workspace.upgrade() {
1997            return workspace.read(cx).project_group_key(cx) == *key;
1998        }
1999        false
2000    }
2001
2002    fn is_in_current_window_groups(&self, paths: &PathList) -> bool {
2003        self.window_project_groups
2004            .iter()
2005            .any(|key| key.path_list() == paths)
2006    }
2007
2008    fn is_open_folder(&self, paths: &PathList) -> bool {
2009        if self.open_folders.is_empty() {
2010            return false;
2011        }
2012
2013        for workspace_path in paths.paths() {
2014            for open_folder in &self.open_folders {
2015                if workspace_path == &open_folder.path {
2016                    return true;
2017                }
2018            }
2019        }
2020
2021        false
2022    }
2023
2024    fn is_valid_recent_candidate(
2025        &self,
2026        workspace_id: WorkspaceId,
2027        paths: &PathList,
2028        cx: &mut Context<Picker<Self>>,
2029    ) -> bool {
2030        !self.is_current_workspace(workspace_id, cx)
2031            && !self.is_in_current_window_groups(paths)
2032            && !self.is_open_folder(paths)
2033    }
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038    use gpui::{TestAppContext, VisualTestContext};
2039
2040    use serde_json::json;
2041    use util::path;
2042    use workspace::{AppState, open_paths};
2043
2044    use super::*;
2045
2046    #[gpui::test]
2047    async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2048        let app_state = init_test(cx);
2049
2050        app_state
2051            .fs
2052            .as_fake()
2053            .insert_tree(
2054                path!("/project"),
2055                json!({
2056                    ".devcontainer": {
2057                        "devcontainer.json": "{}"
2058                    },
2059                    "src": {
2060                        "main.rs": "fn main() {}"
2061                    }
2062                }),
2063            )
2064            .await;
2065
2066        // Open a file path (not a directory) so that the worktree root is a
2067        // file. This means `active_project_directory` returns `None`, which
2068        // causes `DevContainerContext::from_workspace` to return `None`,
2069        // preventing `open_dev_container` from spawning real I/O (docker
2070        // commands, shell environment loading) that is incompatible with the
2071        // test scheduler. The modal is still created and the re-entrancy
2072        // guard that this test validates is still exercised.
2073        cx.update(|cx| {
2074            open_paths(
2075                &[PathBuf::from(path!("/project/src/main.rs"))],
2076                app_state,
2077                workspace::OpenOptions::default(),
2078                cx,
2079            )
2080        })
2081        .await
2082        .unwrap();
2083
2084        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2085        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2086
2087        cx.run_until_parked();
2088
2089        // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2090        // -> Workspace::update -> toggle_modal -> new_dev_container.
2091        // Before the fix, this panicked with "cannot read workspace::Workspace while
2092        // it is already being updated" because new_dev_container and open_dev_container
2093        // tried to read the Workspace entity through a WeakEntity handle while it was
2094        // already leased by the outer update.
2095        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2096
2097        multi_workspace
2098            .update(cx, |multi_workspace, _, cx| {
2099                let modal = multi_workspace
2100                    .workspace()
2101                    .read(cx)
2102                    .active_modal::<RemoteServerProjects>(cx);
2103                assert!(
2104                    modal.is_some(),
2105                    "Dev container modal should be open after dispatching OpenDevContainer"
2106                );
2107            })
2108            .unwrap();
2109    }
2110
2111    #[gpui::test]
2112    async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2113        let app_state = init_test(cx);
2114
2115        app_state
2116            .fs
2117            .as_fake()
2118            .insert_tree(
2119                path!("/project"),
2120                json!({
2121                    ".devcontainer": {
2122                        "devcontainer.json": "{}"
2123                    },
2124                    "src": {
2125                        "main.rs": "fn main() {}"
2126                    }
2127                }),
2128            )
2129            .await;
2130
2131        cx.update(|cx| {
2132            open_paths(
2133                &[PathBuf::from(path!("/project"))],
2134                app_state,
2135                workspace::OpenOptions::default(),
2136                cx,
2137            )
2138        })
2139        .await
2140        .unwrap();
2141
2142        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2143        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2144
2145        cx.run_until_parked();
2146
2147        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2148
2149        multi_workspace
2150            .update(cx, |multi_workspace, _, cx| {
2151                assert!(
2152                    multi_workspace
2153                        .active_modal::<RemoteServerProjects>(cx)
2154                        .is_some(),
2155                    "Dev container modal should be open"
2156                );
2157            })
2158            .unwrap();
2159
2160        // Click outside the modal (on the backdrop) to try to dismiss it
2161        let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2162        vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2163
2164        multi_workspace
2165            .update(cx, |multi_workspace, _, cx| {
2166                assert!(
2167                    multi_workspace
2168                        .active_modal::<RemoteServerProjects>(cx)
2169                        .is_some(),
2170                    "Dev container modal should remain open during creation"
2171                );
2172            })
2173            .unwrap();
2174    }
2175
2176    #[gpui::test]
2177    async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2178        let app_state = init_test(cx);
2179
2180        app_state
2181            .fs
2182            .as_fake()
2183            .insert_tree(
2184                path!("/project"),
2185                json!({
2186                    ".devcontainer": {
2187                        "rust": {
2188                            "devcontainer.json": "{}"
2189                        },
2190                        "python": {
2191                            "devcontainer.json": "{}"
2192                        }
2193                    },
2194                    "src": {
2195                        "main.rs": "fn main() {}"
2196                    }
2197                }),
2198            )
2199            .await;
2200
2201        cx.update(|cx| {
2202            open_paths(
2203                &[PathBuf::from(path!("/project"))],
2204                app_state,
2205                workspace::OpenOptions::default(),
2206                cx,
2207            )
2208        })
2209        .await
2210        .unwrap();
2211
2212        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2213        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2214
2215        cx.run_until_parked();
2216
2217        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2218
2219        multi_workspace
2220            .update(cx, |multi_workspace, _, cx| {
2221                let modal = multi_workspace
2222                    .workspace()
2223                    .read(cx)
2224                    .active_modal::<RemoteServerProjects>(cx);
2225                assert!(
2226                    modal.is_some(),
2227                    "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2228                );
2229            })
2230            .unwrap();
2231    }
2232
2233    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2234        cx.update(|cx| {
2235            let state = AppState::test(cx);
2236            crate::init(cx);
2237            editor::init(cx);
2238            state
2239        })
2240    }
2241}