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