recent_projects.rs

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