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