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