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                        if picker.delegate.is_current_workspace(workspace_id, cx) {
 724                            return;
 725                        }
 726                        picker
 727                            .delegate
 728                            .remove_sibling_workspace(workspace_id, window, cx);
 729                        let query = picker.query(cx);
 730                        picker.update_matches(query, window, cx);
 731                    }
 732                }
 733                Some(ProjectPickerEntry::RecentProject(_)) => {
 734                    picker.delegate.delete_recent_project(ix, window, cx);
 735                }
 736                _ => {}
 737            }
 738        });
 739    }
 740
 741    fn handle_add_to_workspace(
 742        &mut self,
 743        _: &AddToWorkspace,
 744        window: &mut Window,
 745        cx: &mut Context<Self>,
 746    ) {
 747        self.picker.update(cx, |picker, cx| {
 748            let ix = picker.delegate.selected_index;
 749
 750            if let Some(ProjectPickerEntry::RecentProject(hit)) =
 751                picker.delegate.filtered_entries.get(ix)
 752            {
 753                if let Some((_, location, paths, _)) =
 754                    picker.delegate.workspaces.get(hit.candidate_id)
 755                {
 756                    if matches!(location, SerializedWorkspaceLocation::Local) {
 757                        let paths_to_add = paths.paths().to_vec();
 758                        picker
 759                            .delegate
 760                            .add_project_to_workspace(paths_to_add, window, cx);
 761                    }
 762                }
 763            }
 764        });
 765    }
 766}
 767
 768impl EventEmitter<DismissEvent> for RecentProjects {}
 769
 770impl Focusable for RecentProjects {
 771    fn focus_handle(&self, cx: &App) -> FocusHandle {
 772        self.picker.focus_handle(cx)
 773    }
 774}
 775
 776impl Render for RecentProjects {
 777    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 778        v_flex()
 779            .key_context("RecentProjects")
 780            .on_action(cx.listener(Self::handle_toggle_open_menu))
 781            .on_action(cx.listener(Self::handle_remove_selected))
 782            .on_action(cx.listener(Self::handle_add_to_workspace))
 783            .w(rems(self.rem_width))
 784            .child(self.picker.clone())
 785    }
 786}
 787
 788pub struct RecentProjectsDelegate {
 789    workspace: WeakEntity<Workspace>,
 790    open_folders: Vec<OpenFolderEntry>,
 791    sibling_workspace_ids: HashSet<WorkspaceId>,
 792    workspaces: Vec<(
 793        WorkspaceId,
 794        SerializedWorkspaceLocation,
 795        PathList,
 796        DateTime<Utc>,
 797    )>,
 798    filtered_entries: Vec<ProjectPickerEntry>,
 799    selected_index: usize,
 800    render_paths: bool,
 801    create_new_window: bool,
 802    // Flag to reset index when there is a new query vs not reset index when user delete an item
 803    reset_selected_match_index: bool,
 804    has_any_non_local_projects: bool,
 805    project_connection_options: Option<RemoteConnectionOptions>,
 806    focus_handle: FocusHandle,
 807    style: ProjectPickerStyle,
 808    actions_menu_handle: PopoverMenuHandle<ContextMenu>,
 809}
 810
 811impl RecentProjectsDelegate {
 812    fn new(
 813        workspace: WeakEntity<Workspace>,
 814        create_new_window: bool,
 815        focus_handle: FocusHandle,
 816        open_folders: Vec<OpenFolderEntry>,
 817        sibling_workspace_ids: HashSet<WorkspaceId>,
 818        project_connection_options: Option<RemoteConnectionOptions>,
 819        style: ProjectPickerStyle,
 820    ) -> Self {
 821        let render_paths = style == ProjectPickerStyle::Modal;
 822        Self {
 823            workspace,
 824            open_folders,
 825            sibling_workspace_ids,
 826            workspaces: Vec::new(),
 827            filtered_entries: Vec::new(),
 828            selected_index: 0,
 829            create_new_window,
 830            render_paths,
 831            reset_selected_match_index: true,
 832            has_any_non_local_projects: project_connection_options.is_some(),
 833            project_connection_options,
 834            focus_handle,
 835            style,
 836            actions_menu_handle: PopoverMenuHandle::default(),
 837        }
 838    }
 839
 840    pub fn set_workspaces(
 841        &mut self,
 842        workspaces: Vec<(
 843            WorkspaceId,
 844            SerializedWorkspaceLocation,
 845            PathList,
 846            DateTime<Utc>,
 847        )>,
 848    ) {
 849        self.workspaces = workspaces;
 850        let has_non_local_recent = !self
 851            .workspaces
 852            .iter()
 853            .all(|(_, location, _, _)| matches!(location, SerializedWorkspaceLocation::Local));
 854        self.has_any_non_local_projects =
 855            self.project_connection_options.is_some() || has_non_local_recent;
 856    }
 857}
 858impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
 859impl PickerDelegate for RecentProjectsDelegate {
 860    type ListItem = AnyElement;
 861
 862    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 863        "Search projects…".into()
 864    }
 865
 866    fn render_editor(
 867        &self,
 868        editor: &Arc<dyn ErasedEditor>,
 869        window: &mut Window,
 870        cx: &mut Context<Picker<Self>>,
 871    ) -> Div {
 872        h_flex()
 873            .flex_none()
 874            .h_9()
 875            .px_2p5()
 876            .justify_between()
 877            .border_b_1()
 878            .border_color(cx.theme().colors().border_variant)
 879            .child(editor.render(window, cx))
 880    }
 881
 882    fn match_count(&self) -> usize {
 883        self.filtered_entries.len()
 884    }
 885
 886    fn selected_index(&self) -> usize {
 887        self.selected_index
 888    }
 889
 890    fn set_selected_index(
 891        &mut self,
 892        ix: usize,
 893        _window: &mut Window,
 894        _cx: &mut Context<Picker<Self>>,
 895    ) {
 896        self.selected_index = ix;
 897    }
 898
 899    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
 900        matches!(
 901            self.filtered_entries.get(ix),
 902            Some(
 903                ProjectPickerEntry::OpenFolder { .. }
 904                    | ProjectPickerEntry::OpenProject(_)
 905                    | ProjectPickerEntry::RecentProject(_)
 906            )
 907        )
 908    }
 909
 910    fn update_matches(
 911        &mut self,
 912        query: String,
 913        _: &mut Window,
 914        cx: &mut Context<Picker<Self>>,
 915    ) -> gpui::Task<()> {
 916        let query = query.trim_start();
 917        let smart_case = query.chars().any(|c| c.is_uppercase());
 918        let is_empty_query = query.is_empty();
 919
 920        let folder_matches = if self.open_folders.is_empty() {
 921            Vec::new()
 922        } else {
 923            let candidates: Vec<_> = self
 924                .open_folders
 925                .iter()
 926                .enumerate()
 927                .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
 928                .collect();
 929
 930            smol::block_on(fuzzy::match_strings(
 931                &candidates,
 932                query,
 933                smart_case,
 934                true,
 935                100,
 936                &Default::default(),
 937                cx.background_executor().clone(),
 938            ))
 939        };
 940
 941        let sibling_candidates: Vec<_> = self
 942            .workspaces
 943            .iter()
 944            .enumerate()
 945            .filter(|(_, (id, _, _, _))| self.sibling_workspace_ids.contains(id))
 946            .map(|(id, (_, _, paths, _))| {
 947                let combined_string = paths
 948                    .ordered_paths()
 949                    .map(|path| path.compact().to_string_lossy().into_owned())
 950                    .collect::<Vec<_>>()
 951                    .join("");
 952                StringMatchCandidate::new(id, &combined_string)
 953            })
 954            .collect();
 955
 956        let mut sibling_matches = smol::block_on(fuzzy::match_strings(
 957            &sibling_candidates,
 958            query,
 959            smart_case,
 960            true,
 961            100,
 962            &Default::default(),
 963            cx.background_executor().clone(),
 964        ));
 965        sibling_matches.sort_unstable_by(|a, b| {
 966            b.score
 967                .partial_cmp(&a.score)
 968                .unwrap_or(std::cmp::Ordering::Equal)
 969                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
 970        });
 971
 972        // Build candidates for recent projects (not current, not sibling, not open folder)
 973        let recent_candidates: Vec<_> = self
 974            .workspaces
 975            .iter()
 976            .enumerate()
 977            .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
 978            .map(|(id, (_, _, paths, _))| {
 979                let combined_string = paths
 980                    .ordered_paths()
 981                    .map(|path| path.compact().to_string_lossy().into_owned())
 982                    .collect::<Vec<_>>()
 983                    .join("");
 984                StringMatchCandidate::new(id, &combined_string)
 985            })
 986            .collect();
 987
 988        let mut recent_matches = smol::block_on(fuzzy::match_strings(
 989            &recent_candidates,
 990            query,
 991            smart_case,
 992            true,
 993            100,
 994            &Default::default(),
 995            cx.background_executor().clone(),
 996        ));
 997        recent_matches.sort_unstable_by(|a, b| {
 998            b.score
 999                .partial_cmp(&a.score)
1000                .unwrap_or(std::cmp::Ordering::Equal)
1001                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
1002        });
1003
1004        let mut entries = Vec::new();
1005
1006        if !self.open_folders.is_empty() {
1007            let matched_folders: Vec<_> = if is_empty_query {
1008                (0..self.open_folders.len())
1009                    .map(|i| (i, Vec::new()))
1010                    .collect()
1011            } else {
1012                folder_matches
1013                    .iter()
1014                    .map(|m| (m.candidate_id, m.positions.clone()))
1015                    .collect()
1016            };
1017
1018            for (index, positions) in matched_folders {
1019                entries.push(ProjectPickerEntry::OpenFolder { index, positions });
1020            }
1021        }
1022
1023        let has_siblings_to_show = if is_empty_query {
1024            !sibling_candidates.is_empty()
1025        } else {
1026            !sibling_matches.is_empty()
1027        };
1028
1029        if has_siblings_to_show {
1030            entries.push(ProjectPickerEntry::Header("This Window".into()));
1031
1032            if is_empty_query {
1033                for (id, (workspace_id, _, _, _)) in self.workspaces.iter().enumerate() {
1034                    if self.sibling_workspace_ids.contains(workspace_id) {
1035                        entries.push(ProjectPickerEntry::OpenProject(StringMatch {
1036                            candidate_id: id,
1037                            score: 0.0,
1038                            positions: Vec::new(),
1039                            string: String::new(),
1040                        }));
1041                    }
1042                }
1043            } else {
1044                for m in sibling_matches {
1045                    entries.push(ProjectPickerEntry::OpenProject(m));
1046                }
1047            }
1048        }
1049
1050        let has_recent_to_show = if is_empty_query {
1051            !recent_candidates.is_empty()
1052        } else {
1053            !recent_matches.is_empty()
1054        };
1055
1056        if has_recent_to_show {
1057            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
1058
1059            if is_empty_query {
1060                for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
1061                    if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
1062                        entries.push(ProjectPickerEntry::RecentProject(StringMatch {
1063                            candidate_id: id,
1064                            score: 0.0,
1065                            positions: Vec::new(),
1066                            string: String::new(),
1067                        }));
1068                    }
1069                }
1070            } else {
1071                for m in recent_matches {
1072                    entries.push(ProjectPickerEntry::RecentProject(m));
1073                }
1074            }
1075        }
1076
1077        self.filtered_entries = entries;
1078
1079        if self.reset_selected_match_index {
1080            self.selected_index = self
1081                .filtered_entries
1082                .iter()
1083                .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
1084                .unwrap_or(0);
1085        }
1086        self.reset_selected_match_index = true;
1087        Task::ready(())
1088    }
1089
1090    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
1091        match self.filtered_entries.get(self.selected_index) {
1092            Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
1093                let Some(folder) = self.open_folders.get(*index) else {
1094                    return;
1095                };
1096                let worktree_id = folder.worktree_id;
1097                if let Some(workspace) = self.workspace.upgrade() {
1098                    workspace.update(cx, |workspace, cx| {
1099                        workspace.set_active_worktree_override(Some(worktree_id), cx);
1100                    });
1101                }
1102                cx.emit(DismissEvent);
1103            }
1104            Some(ProjectPickerEntry::OpenProject(selected_match)) => {
1105                let Some((workspace_id, _, _, _)) =
1106                    self.workspaces.get(selected_match.candidate_id)
1107                else {
1108                    return;
1109                };
1110                let workspace_id = *workspace_id;
1111
1112                if self.is_current_workspace(workspace_id, cx) {
1113                    cx.emit(DismissEvent);
1114                    return;
1115                }
1116
1117                if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1118                    cx.defer(move |cx| {
1119                        handle
1120                            .update(cx, |multi_workspace, window, cx| {
1121                                let workspace = multi_workspace
1122                                    .workspaces()
1123                                    .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1124                                    .cloned();
1125                                if let Some(workspace) = workspace {
1126                                    multi_workspace.activate(workspace, window, cx);
1127                                }
1128                            })
1129                            .log_err();
1130                    });
1131                }
1132                cx.emit(DismissEvent);
1133            }
1134            Some(ProjectPickerEntry::RecentProject(selected_match)) => {
1135                let Some(workspace) = self.workspace.upgrade() else {
1136                    return;
1137                };
1138                let Some((
1139                    candidate_workspace_id,
1140                    candidate_workspace_location,
1141                    candidate_workspace_paths,
1142                    _,
1143                )) = self.workspaces.get(selected_match.candidate_id)
1144                else {
1145                    return;
1146                };
1147
1148                let replace_current_window = self.create_new_window == secondary;
1149                let candidate_workspace_id = *candidate_workspace_id;
1150                let candidate_workspace_location = candidate_workspace_location.clone();
1151                let candidate_workspace_paths = candidate_workspace_paths.clone();
1152
1153                workspace.update(cx, |workspace, cx| {
1154                    if workspace.database_id() == Some(candidate_workspace_id) {
1155                        return;
1156                    }
1157                    match candidate_workspace_location {
1158                        SerializedWorkspaceLocation::Local => {
1159                            let paths = candidate_workspace_paths.paths().to_vec();
1160                            if replace_current_window {
1161                                if let Some(handle) =
1162                                    window.window_handle().downcast::<MultiWorkspace>()
1163                                {
1164                                    cx.defer(move |cx| {
1165                                        if let Some(task) = handle
1166                                            .update(cx, |multi_workspace, window, cx| {
1167                                                multi_workspace.open_project(
1168                                                    paths,
1169                                                    OpenMode::Activate,
1170                                                    window,
1171                                                    cx,
1172                                                )
1173                                            })
1174                                            .log_err()
1175                                        {
1176                                            task.detach_and_log_err(cx);
1177                                        }
1178                                    });
1179                                }
1180                                return;
1181                            } else {
1182                                workspace
1183                                    .open_workspace_for_paths(
1184                                        OpenMode::NewWindow,
1185                                        paths,
1186                                        window,
1187                                        cx,
1188                                    )
1189                                    .detach_and_prompt_err(
1190                                        "Failed to open project",
1191                                        window,
1192                                        cx,
1193                                        |_, _, _| None,
1194                                    );
1195                            }
1196                        }
1197                        SerializedWorkspaceLocation::Remote(mut connection) => {
1198                            let app_state = workspace.app_state().clone();
1199                            let replace_window = if replace_current_window {
1200                                window.window_handle().downcast::<MultiWorkspace>()
1201                            } else {
1202                                None
1203                            };
1204                            let open_options = OpenOptions {
1205                                requesting_window: replace_window,
1206                                ..Default::default()
1207                            };
1208                            if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
1209                                RemoteSettings::get_global(cx)
1210                                    .fill_connection_options_from_settings(connection);
1211                            };
1212                            let paths = candidate_workspace_paths.paths().to_vec();
1213                            cx.spawn_in(window, async move |_, cx| {
1214                                open_remote_project(
1215                                    connection.clone(),
1216                                    paths,
1217                                    app_state,
1218                                    open_options,
1219                                    cx,
1220                                )
1221                                .await
1222                            })
1223                            .detach_and_prompt_err(
1224                                "Failed to open project",
1225                                window,
1226                                cx,
1227                                |_, _, _| None,
1228                            );
1229                        }
1230                    }
1231                });
1232                cx.emit(DismissEvent);
1233            }
1234            _ => {}
1235        }
1236    }
1237
1238    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
1239
1240    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
1241        let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
1242            "Recently opened projects will show up here".into()
1243        } else {
1244            "No matches".into()
1245        };
1246        Some(text)
1247    }
1248
1249    fn render_match(
1250        &self,
1251        ix: usize,
1252        selected: bool,
1253        window: &mut Window,
1254        cx: &mut Context<Picker<Self>>,
1255    ) -> Option<Self::ListItem> {
1256        match self.filtered_entries.get(ix)? {
1257            ProjectPickerEntry::Header(title) => Some(
1258                v_flex()
1259                    .w_full()
1260                    .gap_1()
1261                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1262                    .child(ListSubHeader::new(title.clone()).inset(true))
1263                    .into_any_element(),
1264            ),
1265            ProjectPickerEntry::OpenFolder { index, positions } => {
1266                let folder = self.open_folders.get(*index)?;
1267                let name = folder.name.clone();
1268                let path = folder.path.compact();
1269                let branch = folder.branch.clone();
1270                let is_active = folder.is_active;
1271                let worktree_id = folder.worktree_id;
1272                let positions = positions.clone();
1273                let show_path = self.style == ProjectPickerStyle::Modal;
1274
1275                let secondary_actions = h_flex()
1276                    .gap_1()
1277                    .child(
1278                        IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1279                            .icon_size(IconSize::Small)
1280                            .tooltip(Tooltip::text("Remove Folder from Workspace"))
1281                            .on_click(cx.listener(move |picker, _, window, cx| {
1282                                let Some(workspace) = picker.delegate.workspace.upgrade() else {
1283                                    return;
1284                                };
1285                                workspace.update(cx, |workspace, cx| {
1286                                    let project = workspace.project().clone();
1287                                    project.update(cx, |project, cx| {
1288                                        project.remove_worktree(worktree_id, cx);
1289                                    });
1290                                });
1291                                picker.delegate.open_folders =
1292                                    get_open_folders(workspace.read(cx), cx);
1293                                let query = picker.query(cx);
1294                                picker.update_matches(query, window, cx);
1295                            })),
1296                    )
1297                    .into_any_element();
1298
1299                let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1300
1301                Some(
1302                    ListItem::new(ix)
1303                        .toggle_state(selected)
1304                        .inset(true)
1305                        .spacing(ListItemSpacing::Sparse)
1306                        .child(
1307                            h_flex()
1308                                .id("open_folder_item")
1309                                .gap_3()
1310                                .flex_grow()
1311                                .when(self.has_any_non_local_projects, |this| {
1312                                    this.child(Icon::new(icon).color(Color::Muted))
1313                                })
1314                                .child(
1315                                    v_flex()
1316                                        .child(
1317                                            h_flex()
1318                                                .gap_1()
1319                                                .child({
1320                                                    let highlighted = HighlightedMatch {
1321                                                        text: name.to_string(),
1322                                                        highlight_positions: positions,
1323                                                        color: Color::Default,
1324                                                    };
1325                                                    highlighted.render(window, cx)
1326                                                })
1327                                                .when_some(branch, |this, branch| {
1328                                                    this.child(
1329                                                        Label::new(branch).color(Color::Muted),
1330                                                    )
1331                                                })
1332                                                .when(is_active, |this| {
1333                                                    this.child(
1334                                                        Icon::new(IconName::Check)
1335                                                            .size(IconSize::Small)
1336                                                            .color(Color::Accent),
1337                                                    )
1338                                                }),
1339                                        )
1340                                        .when(show_path, |this| {
1341                                            this.child(
1342                                                Label::new(path.to_string_lossy().to_string())
1343                                                    .size(LabelSize::Small)
1344                                                    .color(Color::Muted),
1345                                            )
1346                                        }),
1347                                )
1348                                .when(!show_path, |this| {
1349                                    this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1350                                }),
1351                        )
1352                        .end_slot(secondary_actions)
1353                        .show_end_slot_on_hover()
1354                        .into_any_element(),
1355                )
1356            }
1357            ProjectPickerEntry::OpenProject(hit) => {
1358                let (workspace_id, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1359                let workspace_id = *workspace_id;
1360                let is_current = self.is_current_workspace(workspace_id, cx);
1361                let ordered_paths: Vec<_> = paths
1362                    .ordered_paths()
1363                    .map(|p| p.compact().to_string_lossy().to_string())
1364                    .collect();
1365                let tooltip_path: SharedString = match &location {
1366                    SerializedWorkspaceLocation::Remote(options) => {
1367                        let host = options.display_name();
1368                        if ordered_paths.len() == 1 {
1369                            format!("{} ({})", ordered_paths[0], host).into()
1370                        } else {
1371                            format!("{}\n({})", ordered_paths.join("\n"), host).into()
1372                        }
1373                    }
1374                    _ => ordered_paths.join("\n").into(),
1375                };
1376
1377                let mut path_start_offset = 0;
1378                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1379                    .ordered_paths()
1380                    .map(|p| p.compact())
1381                    .map(|path| {
1382                        let highlighted_text =
1383                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1384                        path_start_offset += highlighted_text.1.text.len();
1385                        highlighted_text
1386                    })
1387                    .unzip();
1388
1389                let prefix = match &location {
1390                    SerializedWorkspaceLocation::Remote(options) => {
1391                        Some(SharedString::from(options.display_name()))
1392                    }
1393                    _ => None,
1394                };
1395
1396                let highlighted_match = HighlightedMatchWithPaths {
1397                    prefix,
1398                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1399                    paths,
1400                    active: is_current,
1401                };
1402
1403                let icon = icon_for_remote_connection(match location {
1404                    SerializedWorkspaceLocation::Local => None,
1405                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1406                });
1407
1408                let secondary_actions = h_flex()
1409                    .gap_1()
1410                    .when(!is_current, |this| {
1411                        this.child(
1412                            IconButton::new("remove_open_project", IconName::Close)
1413                                .icon_size(IconSize::Small)
1414                                .tooltip(Tooltip::text("Remove Project from Window"))
1415                                .on_click(cx.listener(move |picker, _, window, cx| {
1416                                    cx.stop_propagation();
1417                                    window.prevent_default();
1418                                    picker.delegate.remove_sibling_workspace(
1419                                        workspace_id,
1420                                        window,
1421                                        cx,
1422                                    );
1423                                    let query = picker.query(cx);
1424                                    picker.update_matches(query, window, cx);
1425                                })),
1426                        )
1427                    })
1428                    .into_any_element();
1429
1430                Some(
1431                    ListItem::new(ix)
1432                        .toggle_state(selected)
1433                        .inset(true)
1434                        .spacing(ListItemSpacing::Sparse)
1435                        .child(
1436                            h_flex()
1437                                .id("open_project_info_container")
1438                                .gap_3()
1439                                .flex_grow()
1440                                .when(self.has_any_non_local_projects, |this| {
1441                                    this.child(Icon::new(icon).color(Color::Muted))
1442                                })
1443                                .child({
1444                                    let mut highlighted = highlighted_match;
1445                                    if !self.render_paths {
1446                                        highlighted.paths.clear();
1447                                    }
1448                                    highlighted.render(window, cx)
1449                                })
1450                                .tooltip(Tooltip::text(tooltip_path)),
1451                        )
1452                        .end_slot(secondary_actions)
1453                        .show_end_slot_on_hover()
1454                        .into_any_element(),
1455                )
1456            }
1457            ProjectPickerEntry::RecentProject(hit) => {
1458                let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1459                let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1460                let paths_to_add = paths.paths().to_vec();
1461                let ordered_paths: Vec<_> = paths
1462                    .ordered_paths()
1463                    .map(|p| p.compact().to_string_lossy().to_string())
1464                    .collect();
1465                let tooltip_path: SharedString = match &location {
1466                    SerializedWorkspaceLocation::Remote(options) => {
1467                        let host = options.display_name();
1468                        if ordered_paths.len() == 1 {
1469                            format!("{} ({})", ordered_paths[0], host).into()
1470                        } else {
1471                            format!("{}\n({})", ordered_paths.join("\n"), host).into()
1472                        }
1473                    }
1474                    _ => ordered_paths.join("\n").into(),
1475                };
1476
1477                let mut path_start_offset = 0;
1478                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1479                    .ordered_paths()
1480                    .map(|p| p.compact())
1481                    .map(|path| {
1482                        let highlighted_text =
1483                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1484                        path_start_offset += highlighted_text.1.text.len();
1485                        highlighted_text
1486                    })
1487                    .unzip();
1488
1489                let prefix = match &location {
1490                    SerializedWorkspaceLocation::Remote(options) => {
1491                        Some(SharedString::from(options.display_name()))
1492                    }
1493                    _ => None,
1494                };
1495
1496                let highlighted_match = HighlightedMatchWithPaths {
1497                    prefix,
1498                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1499                    paths,
1500                    active: false,
1501                };
1502
1503                let focus_handle = self.focus_handle.clone();
1504
1505                let secondary_actions = h_flex()
1506                    .gap_px()
1507                    .when(is_local, |this| {
1508                        this.child(
1509                            IconButton::new("add_to_workspace", IconName::FolderOpenAdd)
1510                                .icon_size(IconSize::Small)
1511                                .tooltip(move |_, cx| {
1512                                    Tooltip::with_meta(
1513                                        "Add Project to this Workspace",
1514                                        None,
1515                                        "As a multi-root folder project",
1516                                        cx,
1517                                    )
1518                                })
1519                                .on_click({
1520                                    let paths_to_add = paths_to_add.clone();
1521                                    cx.listener(move |picker, _event, window, cx| {
1522                                        cx.stop_propagation();
1523                                        window.prevent_default();
1524                                        picker.delegate.add_project_to_workspace(
1525                                            paths_to_add.clone(),
1526                                            window,
1527                                            cx,
1528                                        );
1529                                    })
1530                                }),
1531                        )
1532                    })
1533                    .child(
1534                        IconButton::new("open_new_window", IconName::OpenNewWindow)
1535                            .icon_size(IconSize::Small)
1536                            .tooltip({
1537                                move |_, cx| {
1538                                    Tooltip::for_action_in(
1539                                        "Open Project in New Window",
1540                                        &menu::SecondaryConfirm,
1541                                        &focus_handle,
1542                                        cx,
1543                                    )
1544                                }
1545                            })
1546                            .on_click(cx.listener(move |this, _event, window, cx| {
1547                                cx.stop_propagation();
1548                                window.prevent_default();
1549                                this.delegate.set_selected_index(ix, window, cx);
1550                                this.delegate.confirm(true, window, cx);
1551                            })),
1552                    )
1553                    .child(
1554                        IconButton::new("delete", IconName::Close)
1555                            .icon_size(IconSize::Small)
1556                            .tooltip(Tooltip::text("Delete from Recent Projects"))
1557                            .on_click(cx.listener(move |this, _event, window, cx| {
1558                                cx.stop_propagation();
1559                                window.prevent_default();
1560                                this.delegate.delete_recent_project(ix, window, cx)
1561                            })),
1562                    )
1563                    .into_any_element();
1564
1565                let icon = icon_for_remote_connection(match location {
1566                    SerializedWorkspaceLocation::Local => None,
1567                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1568                });
1569
1570                Some(
1571                    ListItem::new(ix)
1572                        .toggle_state(selected)
1573                        .inset(true)
1574                        .spacing(ListItemSpacing::Sparse)
1575                        .child(
1576                            h_flex()
1577                                .id("project_info_container")
1578                                .gap_3()
1579                                .flex_grow()
1580                                .when(self.has_any_non_local_projects, |this| {
1581                                    this.child(Icon::new(icon).color(Color::Muted))
1582                                })
1583                                .child({
1584                                    let mut highlighted = highlighted_match;
1585                                    if !self.render_paths {
1586                                        highlighted.paths.clear();
1587                                    }
1588                                    highlighted.render(window, cx)
1589                                })
1590                                .tooltip(move |_, cx| {
1591                                    Tooltip::with_meta(
1592                                        "Open Project in This Window",
1593                                        None,
1594                                        tooltip_path.clone(),
1595                                        cx,
1596                                    )
1597                                }),
1598                        )
1599                        .end_slot(secondary_actions)
1600                        .show_end_slot_on_hover()
1601                        .into_any_element(),
1602                )
1603            }
1604        }
1605    }
1606
1607    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1608        let focus_handle = self.focus_handle.clone();
1609        let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1610        let is_already_open_entry = matches!(
1611            self.filtered_entries.get(self.selected_index),
1612            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::OpenProject(_))
1613        );
1614
1615        if popover_style {
1616            return Some(
1617                v_flex()
1618                    .flex_1()
1619                    .p_1p5()
1620                    .gap_1()
1621                    .border_t_1()
1622                    .border_color(cx.theme().colors().border_variant)
1623                    .child({
1624                        let open_action = workspace::Open::default();
1625                        Button::new("open_local_folder", "Open Local Project")
1626                            .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1627                            .on_click(move |_, window, cx| {
1628                                window.dispatch_action(open_action.boxed_clone(), cx)
1629                            })
1630                    })
1631                    .child(
1632                        Button::new("open_remote_folder", "Open Remote Project")
1633                            .key_binding(KeyBinding::for_action(
1634                                &OpenRemote {
1635                                    from_existing_connection: false,
1636                                    create_new_window: false,
1637                                },
1638                                cx,
1639                            ))
1640                            .on_click(|_, window, cx| {
1641                                window.dispatch_action(
1642                                    OpenRemote {
1643                                        from_existing_connection: false,
1644                                        create_new_window: false,
1645                                    }
1646                                    .boxed_clone(),
1647                                    cx,
1648                                )
1649                            }),
1650                    )
1651                    .into_any(),
1652            );
1653        }
1654
1655        let selected_entry = self.filtered_entries.get(self.selected_index);
1656
1657        let is_current_workspace_entry =
1658            if let Some(ProjectPickerEntry::OpenProject(hit)) = selected_entry {
1659                self.workspaces
1660                    .get(hit.candidate_id)
1661                    .map(|(id, ..)| self.is_current_workspace(*id, cx))
1662                    .unwrap_or(false)
1663            } else {
1664                false
1665            };
1666
1667        let secondary_footer_actions: Option<AnyElement> = match selected_entry {
1668            Some(ProjectPickerEntry::OpenFolder { .. }) => Some(
1669                Button::new("remove_selected", "Remove Folder")
1670                    .key_binding(KeyBinding::for_action_in(
1671                        &RemoveSelected,
1672                        &focus_handle,
1673                        cx,
1674                    ))
1675                    .on_click(|_, window, cx| {
1676                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1677                    })
1678                    .into_any_element(),
1679            ),
1680            Some(ProjectPickerEntry::OpenProject(_)) if !is_current_workspace_entry => Some(
1681                Button::new("remove_selected", "Remove from Window")
1682                    .key_binding(KeyBinding::for_action_in(
1683                        &RemoveSelected,
1684                        &focus_handle,
1685                        cx,
1686                    ))
1687                    .on_click(|_, window, cx| {
1688                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1689                    })
1690                    .into_any_element(),
1691            ),
1692            Some(ProjectPickerEntry::RecentProject(_)) => Some(
1693                Button::new("delete_recent", "Delete")
1694                    .key_binding(KeyBinding::for_action_in(
1695                        &RemoveSelected,
1696                        &focus_handle,
1697                        cx,
1698                    ))
1699                    .on_click(|_, window, cx| {
1700                        window.dispatch_action(RemoveSelected.boxed_clone(), cx)
1701                    })
1702                    .into_any_element(),
1703            ),
1704            _ => None,
1705        };
1706
1707        Some(
1708            h_flex()
1709                .flex_1()
1710                .p_1p5()
1711                .gap_1()
1712                .justify_end()
1713                .border_t_1()
1714                .border_color(cx.theme().colors().border_variant)
1715                .when_some(secondary_footer_actions, |this, actions| {
1716                    this.child(actions)
1717                })
1718                .map(|this| {
1719                    if is_already_open_entry {
1720                        this.child(
1721                            Button::new("activate", "Activate")
1722                                .key_binding(KeyBinding::for_action_in(
1723                                    &menu::Confirm,
1724                                    &focus_handle,
1725                                    cx,
1726                                ))
1727                                .on_click(|_, window, cx| {
1728                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1729                                }),
1730                        )
1731                    } else {
1732                        this.child(
1733                            Button::new("open_new_window", "New Window")
1734                                .key_binding(KeyBinding::for_action_in(
1735                                    &menu::SecondaryConfirm,
1736                                    &focus_handle,
1737                                    cx,
1738                                ))
1739                                .on_click(|_, window, cx| {
1740                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1741                                }),
1742                        )
1743                        .child(
1744                            Button::new("open_here", "Open")
1745                                .key_binding(KeyBinding::for_action_in(
1746                                    &menu::Confirm,
1747                                    &focus_handle,
1748                                    cx,
1749                                ))
1750                                .on_click(|_, window, cx| {
1751                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1752                                }),
1753                        )
1754                    }
1755                })
1756                .child(Divider::vertical())
1757                .child(
1758                    PopoverMenu::new("actions-menu-popover")
1759                        .with_handle(self.actions_menu_handle.clone())
1760                        .anchor(gpui::Corner::BottomRight)
1761                        .offset(gpui::Point {
1762                            x: px(0.0),
1763                            y: px(-2.0),
1764                        })
1765                        .trigger(
1766                            Button::new("actions-trigger", "Actions")
1767                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1768                                .key_binding(KeyBinding::for_action_in(
1769                                    &ToggleActionsMenu,
1770                                    &focus_handle,
1771                                    cx,
1772                                )),
1773                        )
1774                        .menu({
1775                            let focus_handle = focus_handle.clone();
1776                            let show_add_to_workspace = match selected_entry {
1777                                Some(ProjectPickerEntry::RecentProject(hit)) => self
1778                                    .workspaces
1779                                    .get(hit.candidate_id)
1780                                    .map(|(_, loc, ..)| {
1781                                        matches!(loc, SerializedWorkspaceLocation::Local)
1782                                    })
1783                                    .unwrap_or(false),
1784                                _ => false,
1785                            };
1786
1787                            move |window, cx| {
1788                                Some(ContextMenu::build(window, cx, {
1789                                    let focus_handle = focus_handle.clone();
1790                                    move |menu, _, _| {
1791                                        menu.context(focus_handle)
1792                                            .when(show_add_to_workspace, |menu| {
1793                                                menu.action(
1794                                                    "Add to this Workspace",
1795                                                    AddToWorkspace.boxed_clone(),
1796                                                )
1797                                                .separator()
1798                                            })
1799                                            .action(
1800                                                "Open Local Project",
1801                                                workspace::Open::default().boxed_clone(),
1802                                            )
1803                                            .action(
1804                                                "Open Remote Project",
1805                                                OpenRemote {
1806                                                    from_existing_connection: false,
1807                                                    create_new_window: false,
1808                                                }
1809                                                .boxed_clone(),
1810                                            )
1811                                    }
1812                                }))
1813                            }
1814                        }),
1815                )
1816                .into_any(),
1817        )
1818    }
1819}
1820
1821pub(crate) fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1822    match options {
1823        None => IconName::Screen,
1824        Some(options) => match options {
1825            RemoteConnectionOptions::Ssh(_) => IconName::Server,
1826            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1827            RemoteConnectionOptions::Docker(_) => IconName::Box,
1828            #[cfg(any(test, feature = "test-support"))]
1829            RemoteConnectionOptions::Mock(_) => IconName::Server,
1830        },
1831    }
1832}
1833
1834// Compute the highlighted text for the name and path
1835pub(crate) fn highlights_for_path(
1836    path: &Path,
1837    match_positions: &Vec<usize>,
1838    path_start_offset: usize,
1839) -> (Option<HighlightedMatch>, HighlightedMatch) {
1840    let path_string = path.to_string_lossy();
1841    let path_text = path_string.to_string();
1842    let path_byte_len = path_text.len();
1843    // Get the subset of match highlight positions that line up with the given path.
1844    // Also adjusts them to start at the path start
1845    let path_positions = match_positions
1846        .iter()
1847        .copied()
1848        .skip_while(|position| *position < path_start_offset)
1849        .take_while(|position| *position < path_start_offset + path_byte_len)
1850        .map(|position| position - path_start_offset)
1851        .collect::<Vec<_>>();
1852
1853    // Again subset the highlight positions to just those that line up with the file_name
1854    // again adjusted to the start of the file_name
1855    let file_name_text_and_positions = path.file_name().map(|file_name| {
1856        let file_name_text = file_name.to_string_lossy().into_owned();
1857        let file_name_start_byte = path_byte_len - file_name_text.len();
1858        let highlight_positions = path_positions
1859            .iter()
1860            .copied()
1861            .skip_while(|position| *position < file_name_start_byte)
1862            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1863            .map(|position| position - file_name_start_byte)
1864            .collect::<Vec<_>>();
1865        HighlightedMatch {
1866            text: file_name_text,
1867            highlight_positions,
1868            color: Color::Default,
1869        }
1870    });
1871
1872    (
1873        file_name_text_and_positions,
1874        HighlightedMatch {
1875            text: path_text,
1876            highlight_positions: path_positions,
1877            color: Color::Default,
1878        },
1879    )
1880}
1881impl RecentProjectsDelegate {
1882    fn add_project_to_workspace(
1883        &mut self,
1884        paths: Vec<PathBuf>,
1885        window: &mut Window,
1886        cx: &mut Context<Picker<Self>>,
1887    ) {
1888        let Some(workspace) = self.workspace.upgrade() else {
1889            return;
1890        };
1891        let open_paths_task = workspace.update(cx, |workspace, cx| {
1892            workspace.open_paths(
1893                paths,
1894                OpenOptions {
1895                    visible: Some(OpenVisible::All),
1896                    ..Default::default()
1897                },
1898                None,
1899                window,
1900                cx,
1901            )
1902        });
1903        cx.spawn_in(window, async move |picker, cx| {
1904            let _result = open_paths_task.await;
1905            picker
1906                .update_in(cx, |picker, window, cx| {
1907                    let Some(workspace) = picker.delegate.workspace.upgrade() else {
1908                        return;
1909                    };
1910                    picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1911                    let query = picker.query(cx);
1912                    picker.update_matches(query, window, cx);
1913                })
1914                .ok();
1915        })
1916        .detach();
1917    }
1918
1919    fn delete_recent_project(
1920        &self,
1921        ix: usize,
1922        window: &mut Window,
1923        cx: &mut Context<Picker<Self>>,
1924    ) {
1925        if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1926            self.filtered_entries.get(ix)
1927        {
1928            let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1929            let workspace_id = *workspace_id;
1930            let fs = self
1931                .workspace
1932                .upgrade()
1933                .map(|ws| ws.read(cx).app_state().fs.clone());
1934            let db = WorkspaceDb::global(cx);
1935            cx.spawn_in(window, async move |this, cx| {
1936                db.delete_workspace_by_id(workspace_id).await.log_err();
1937                let Some(fs) = fs else { return };
1938                let workspaces = db
1939                    .recent_workspaces_on_disk(fs.as_ref())
1940                    .await
1941                    .unwrap_or_default();
1942                let workspaces =
1943                    workspace::resolve_worktree_workspaces(workspaces, fs.as_ref()).await;
1944                this.update_in(cx, move |picker, window, cx| {
1945                    picker.delegate.set_workspaces(workspaces);
1946                    picker
1947                        .delegate
1948                        .set_selected_index(ix.saturating_sub(1), window, cx);
1949                    picker.delegate.reset_selected_match_index = false;
1950                    picker.update_matches(picker.query(cx), window, cx);
1951                    // After deleting a project, we want to update the history manager to reflect the change.
1952                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1953                    if let Some(history_manager) = HistoryManager::global(cx) {
1954                        history_manager
1955                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1956                    }
1957                })
1958                .ok();
1959            })
1960            .detach();
1961        }
1962    }
1963
1964    fn remove_sibling_workspace(
1965        &mut self,
1966        workspace_id: WorkspaceId,
1967        window: &mut Window,
1968        cx: &mut Context<Picker<Self>>,
1969    ) {
1970        if let Some(handle) = window.window_handle().downcast::<MultiWorkspace>() {
1971            cx.defer(move |cx| {
1972                handle
1973                    .update(cx, |multi_workspace, window, cx| {
1974                        let workspace = multi_workspace
1975                            .workspaces()
1976                            .find(|ws| ws.read(cx).database_id() == Some(workspace_id))
1977                            .cloned();
1978                        if let Some(workspace) = workspace {
1979                            multi_workspace.remove(&workspace, window, cx);
1980                        }
1981                    })
1982                    .log_err();
1983            });
1984        }
1985
1986        self.sibling_workspace_ids.remove(&workspace_id);
1987    }
1988
1989    fn is_current_workspace(
1990        &self,
1991        workspace_id: WorkspaceId,
1992        cx: &mut Context<Picker<Self>>,
1993    ) -> bool {
1994        if let Some(workspace) = self.workspace.upgrade() {
1995            let workspace = workspace.read(cx);
1996            if Some(workspace_id) == workspace.database_id() {
1997                return true;
1998            }
1999        }
2000
2001        false
2002    }
2003
2004    fn is_sibling_workspace(
2005        &self,
2006        workspace_id: WorkspaceId,
2007        cx: &mut Context<Picker<Self>>,
2008    ) -> bool {
2009        self.sibling_workspace_ids.contains(&workspace_id)
2010            && !self.is_current_workspace(workspace_id, cx)
2011    }
2012
2013    fn is_open_folder(&self, paths: &PathList) -> bool {
2014        if self.open_folders.is_empty() {
2015            return false;
2016        }
2017
2018        for workspace_path in paths.paths() {
2019            for open_folder in &self.open_folders {
2020                if workspace_path == &open_folder.path {
2021                    return true;
2022                }
2023            }
2024        }
2025
2026        false
2027    }
2028
2029    fn is_valid_recent_candidate(
2030        &self,
2031        workspace_id: WorkspaceId,
2032        paths: &PathList,
2033        cx: &mut Context<Picker<Self>>,
2034    ) -> bool {
2035        !self.is_current_workspace(workspace_id, cx)
2036            && !self.is_sibling_workspace(workspace_id, cx)
2037            && !self.is_open_folder(paths)
2038    }
2039}
2040
2041#[cfg(test)]
2042mod tests {
2043    use std::path::PathBuf;
2044
2045    use editor::Editor;
2046    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle};
2047
2048    use serde_json::json;
2049    use settings::SettingsStore;
2050    use util::path;
2051    use workspace::{AppState, open_paths};
2052
2053    use super::*;
2054
2055    #[gpui::test]
2056    async fn test_dirty_workspace_replaced_when_opening_recent_project(cx: &mut TestAppContext) {
2057        let app_state = init_test(cx);
2058
2059        cx.update(|cx| {
2060            SettingsStore::update_global(cx, |store, cx| {
2061                store.update_user_settings(cx, |settings| {
2062                    settings
2063                        .session
2064                        .get_or_insert_default()
2065                        .restore_unsaved_buffers = Some(false)
2066                });
2067            });
2068        });
2069
2070        app_state
2071            .fs
2072            .as_fake()
2073            .insert_tree(
2074                path!("/dir"),
2075                json!({
2076                    "main.ts": "a"
2077                }),
2078            )
2079            .await;
2080        app_state
2081            .fs
2082            .as_fake()
2083            .insert_tree(path!("/test/path"), json!({}))
2084            .await;
2085        cx.update(|cx| {
2086            open_paths(
2087                &[PathBuf::from(path!("/dir/main.ts"))],
2088                app_state,
2089                workspace::OpenOptions::default(),
2090                cx,
2091            )
2092        })
2093        .await
2094        .unwrap();
2095        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2096
2097        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2098        multi_workspace
2099            .update(cx, |multi_workspace, _, cx| {
2100                multi_workspace.open_sidebar(cx);
2101            })
2102            .unwrap();
2103        multi_workspace
2104            .update(cx, |multi_workspace, _, cx| {
2105                assert!(!multi_workspace.workspace().read(cx).is_edited())
2106            })
2107            .unwrap();
2108
2109        let editor = multi_workspace
2110            .read_with(cx, |multi_workspace, cx| {
2111                multi_workspace
2112                    .workspace()
2113                    .read(cx)
2114                    .active_item(cx)
2115                    .unwrap()
2116                    .downcast::<Editor>()
2117                    .unwrap()
2118            })
2119            .unwrap();
2120        multi_workspace
2121            .update(cx, |_, window, cx| {
2122                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
2123            })
2124            .unwrap();
2125        multi_workspace
2126            .update(cx, |multi_workspace, _, cx| {
2127                assert!(
2128                    multi_workspace.workspace().read(cx).is_edited(),
2129                    "After inserting more text into the editor without saving, we should have a dirty project"
2130                )
2131            })
2132            .unwrap();
2133
2134        let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
2135        multi_workspace
2136            .update(cx, |_, _, cx| {
2137                recent_projects_picker.update(cx, |picker, cx| {
2138                    assert_eq!(picker.query(cx), "");
2139                    let delegate = &mut picker.delegate;
2140                    delegate.set_workspaces(vec![(
2141                        WorkspaceId::default(),
2142                        SerializedWorkspaceLocation::Local,
2143                        PathList::new(&[path!("/test/path")]),
2144                        Utc::now(),
2145                    )]);
2146                    delegate.filtered_entries =
2147                        vec![ProjectPickerEntry::RecentProject(StringMatch {
2148                            candidate_id: 0,
2149                            score: 1.0,
2150                            positions: Vec::new(),
2151                            string: "fake candidate".to_string(),
2152                        })];
2153                });
2154            })
2155            .unwrap();
2156
2157        assert!(
2158            !cx.has_pending_prompt(),
2159            "Should have no pending prompt on dirty project before opening the new recent project"
2160        );
2161        let dirty_workspace = multi_workspace
2162            .read_with(cx, |multi_workspace, _cx| {
2163                multi_workspace.workspace().clone()
2164            })
2165            .unwrap();
2166
2167        cx.dispatch_action(*multi_workspace, menu::Confirm);
2168        cx.run_until_parked();
2169
2170        // In multi-workspace mode, the dirty workspace is kept and a new one is
2171        // opened alongside it — no save prompt needed.
2172        assert!(
2173            !cx.has_pending_prompt(),
2174            "Should not prompt in multi-workspace mode — dirty workspace is kept"
2175        );
2176
2177        multi_workspace
2178            .update(cx, |multi_workspace, _, cx| {
2179                assert!(
2180                    multi_workspace
2181                        .workspace()
2182                        .read(cx)
2183                        .active_modal::<RecentProjects>(cx)
2184                        .is_none(),
2185                    "Should remove the modal after selecting new recent project"
2186                );
2187
2188                assert!(
2189                    multi_workspace.workspaces().any(|w| w == &dirty_workspace),
2190                    "The dirty workspace should still be present in multi-workspace mode"
2191                );
2192
2193                assert!(
2194                    !multi_workspace.workspace().read(cx).is_edited(),
2195                    "The active workspace should be the freshly opened one, not dirty"
2196                );
2197            })
2198            .unwrap();
2199    }
2200
2201    fn open_recent_projects(
2202        multi_workspace: &WindowHandle<MultiWorkspace>,
2203        cx: &mut TestAppContext,
2204    ) -> Entity<Picker<RecentProjectsDelegate>> {
2205        cx.dispatch_action(
2206            (*multi_workspace).into(),
2207            OpenRecent {
2208                create_new_window: false,
2209            },
2210        );
2211        multi_workspace
2212            .update(cx, |multi_workspace, _, cx| {
2213                multi_workspace
2214                    .workspace()
2215                    .read(cx)
2216                    .active_modal::<RecentProjects>(cx)
2217                    .unwrap()
2218                    .read(cx)
2219                    .picker
2220                    .clone()
2221            })
2222            .unwrap()
2223    }
2224
2225    #[gpui::test]
2226    async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
2227        let app_state = init_test(cx);
2228
2229        app_state
2230            .fs
2231            .as_fake()
2232            .insert_tree(
2233                path!("/project"),
2234                json!({
2235                    ".devcontainer": {
2236                        "devcontainer.json": "{}"
2237                    },
2238                    "src": {
2239                        "main.rs": "fn main() {}"
2240                    }
2241                }),
2242            )
2243            .await;
2244
2245        // Open a file path (not a directory) so that the worktree root is a
2246        // file. This means `active_project_directory` returns `None`, which
2247        // causes `DevContainerContext::from_workspace` to return `None`,
2248        // preventing `open_dev_container` from spawning real I/O (docker
2249        // commands, shell environment loading) that is incompatible with the
2250        // test scheduler. The modal is still created and the re-entrancy
2251        // guard that this test validates is still exercised.
2252        cx.update(|cx| {
2253            open_paths(
2254                &[PathBuf::from(path!("/project/src/main.rs"))],
2255                app_state,
2256                workspace::OpenOptions::default(),
2257                cx,
2258            )
2259        })
2260        .await
2261        .unwrap();
2262
2263        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2264        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2265
2266        cx.run_until_parked();
2267
2268        // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
2269        // -> Workspace::update -> toggle_modal -> new_dev_container.
2270        // Before the fix, this panicked with "cannot read workspace::Workspace while
2271        // it is already being updated" because new_dev_container and open_dev_container
2272        // tried to read the Workspace entity through a WeakEntity handle while it was
2273        // already leased by the outer update.
2274        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2275
2276        multi_workspace
2277            .update(cx, |multi_workspace, _, cx| {
2278                let modal = multi_workspace
2279                    .workspace()
2280                    .read(cx)
2281                    .active_modal::<RemoteServerProjects>(cx);
2282                assert!(
2283                    modal.is_some(),
2284                    "Dev container modal should be open after dispatching OpenDevContainer"
2285                );
2286            })
2287            .unwrap();
2288    }
2289
2290    #[gpui::test]
2291    async fn test_dev_container_modal_not_dismissed_on_backdrop_click(cx: &mut TestAppContext) {
2292        let app_state = init_test(cx);
2293
2294        app_state
2295            .fs
2296            .as_fake()
2297            .insert_tree(
2298                path!("/project"),
2299                json!({
2300                    ".devcontainer": {
2301                        "devcontainer.json": "{}"
2302                    },
2303                    "src": {
2304                        "main.rs": "fn main() {}"
2305                    }
2306                }),
2307            )
2308            .await;
2309
2310        cx.update(|cx| {
2311            open_paths(
2312                &[PathBuf::from(path!("/project"))],
2313                app_state,
2314                workspace::OpenOptions::default(),
2315                cx,
2316            )
2317        })
2318        .await
2319        .unwrap();
2320
2321        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2322        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2323
2324        cx.run_until_parked();
2325
2326        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2327
2328        multi_workspace
2329            .update(cx, |multi_workspace, _, cx| {
2330                assert!(
2331                    multi_workspace
2332                        .active_modal::<RemoteServerProjects>(cx)
2333                        .is_some(),
2334                    "Dev container modal should be open"
2335                );
2336            })
2337            .unwrap();
2338
2339        // Click outside the modal (on the backdrop) to try to dismiss it
2340        let mut vcx = VisualTestContext::from_window(*multi_workspace, cx);
2341        vcx.simulate_click(gpui::point(px(1.0), px(1.0)), gpui::Modifiers::default());
2342
2343        multi_workspace
2344            .update(cx, |multi_workspace, _, cx| {
2345                assert!(
2346                    multi_workspace
2347                        .active_modal::<RemoteServerProjects>(cx)
2348                        .is_some(),
2349                    "Dev container modal should remain open during creation"
2350                );
2351            })
2352            .unwrap();
2353    }
2354
2355    #[gpui::test]
2356    async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
2357        let app_state = init_test(cx);
2358
2359        app_state
2360            .fs
2361            .as_fake()
2362            .insert_tree(
2363                path!("/project"),
2364                json!({
2365                    ".devcontainer": {
2366                        "rust": {
2367                            "devcontainer.json": "{}"
2368                        },
2369                        "python": {
2370                            "devcontainer.json": "{}"
2371                        }
2372                    },
2373                    "src": {
2374                        "main.rs": "fn main() {}"
2375                    }
2376                }),
2377            )
2378            .await;
2379
2380        cx.update(|cx| {
2381            open_paths(
2382                &[PathBuf::from(path!("/project"))],
2383                app_state,
2384                workspace::OpenOptions::default(),
2385                cx,
2386            )
2387        })
2388        .await
2389        .unwrap();
2390
2391        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
2392        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
2393
2394        cx.run_until_parked();
2395
2396        cx.dispatch_action(*multi_workspace, OpenDevContainer);
2397
2398        multi_workspace
2399            .update(cx, |multi_workspace, _, cx| {
2400                let modal = multi_workspace
2401                    .workspace()
2402                    .read(cx)
2403                    .active_modal::<RemoteServerProjects>(cx);
2404                assert!(
2405                    modal.is_some(),
2406                    "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
2407                );
2408            })
2409            .unwrap();
2410    }
2411
2412    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2413        cx.update(|cx| {
2414            let state = AppState::test(cx);
2415            crate::init(cx);
2416            editor::init(cx);
2417            state
2418        })
2419    }
2420}