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