recent_projects.rs

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