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