recent_projects.rs

   1mod dev_container_suggest;
   2pub mod disconnected_overlay;
   3mod remote_connections;
   4mod remote_servers;
   5mod ssh_config;
   6
   7use std::{
   8    path::{Path, PathBuf},
   9    sync::Arc,
  10};
  11
  12use fs::Fs;
  13
  14#[cfg(target_os = "windows")]
  15mod wsl_picker;
  16
  17use remote::RemoteConnectionOptions;
  18pub use remote_connection::{RemoteConnectionModal, connect};
  19pub use remote_connections::open_remote_project;
  20
  21use disconnected_overlay::DisconnectedOverlay;
  22use fuzzy::{StringMatch, StringMatchCandidate};
  23use gpui::{
  24    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  25    Subscription, Task, WeakEntity, Window, actions, px,
  26};
  27
  28use picker::{
  29    Picker, PickerDelegate,
  30    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
  31};
  32use project::{Worktree, git_store::Repository};
  33pub use remote_connections::RemoteSettings;
  34pub use remote_servers::RemoteServerProjects;
  35use settings::{Settings, WorktreeId};
  36
  37use ui::{
  38    ContextMenu, Divider, KeyBinding, ListItem, ListItemSpacing, ListSubHeader, PopoverMenu,
  39    PopoverMenuHandle, TintColor, Tooltip, prelude::*,
  40};
  41use util::{ResultExt, paths::PathExt};
  42use workspace::{
  43    HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
  44    WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
  45    with_active_or_new_workspace,
  46};
  47use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
  48
  49actions!(recent_projects, [ToggleActionsMenu]);
  50
  51#[derive(Clone, Debug)]
  52pub struct RecentProjectEntry {
  53    pub name: SharedString,
  54    pub full_path: SharedString,
  55    pub paths: Vec<PathBuf>,
  56    pub workspace_id: WorkspaceId,
  57}
  58
  59#[derive(Clone, Debug)]
  60struct OpenFolderEntry {
  61    worktree_id: WorktreeId,
  62    name: SharedString,
  63    path: PathBuf,
  64    branch: Option<SharedString>,
  65    is_active: bool,
  66}
  67
  68#[derive(Clone, Debug)]
  69enum ProjectPickerEntry {
  70    Header(SharedString),
  71    OpenFolder { index: usize, positions: Vec<usize> },
  72    RecentProject(StringMatch),
  73}
  74
  75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
  76enum ProjectPickerStyle {
  77    Modal,
  78    Popover,
  79}
  80
  81pub async fn get_recent_projects(
  82    current_workspace_id: Option<WorkspaceId>,
  83    limit: Option<usize>,
  84    fs: Arc<dyn fs::Fs>,
  85) -> Vec<RecentProjectEntry> {
  86    let workspaces = WORKSPACE_DB
  87        .recent_workspaces_on_disk(fs.as_ref())
  88        .await
  89        .unwrap_or_default();
  90
  91    let entries: Vec<RecentProjectEntry> = workspaces
  92        .into_iter()
  93        .filter(|(id, _, _)| Some(*id) != current_workspace_id)
  94        .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
  95        .map(|(workspace_id, _, path_list)| {
  96            let paths: Vec<PathBuf> = path_list.paths().to_vec();
  97            let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
  98
  99            let name = if ordered_paths.len() == 1 {
 100                ordered_paths[0]
 101                    .file_name()
 102                    .map(|n| n.to_string_lossy().to_string())
 103                    .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
 104            } else {
 105                ordered_paths
 106                    .iter()
 107                    .filter_map(|p| p.file_name())
 108                    .map(|n| n.to_string_lossy().to_string())
 109                    .collect::<Vec<_>>()
 110                    .join(", ")
 111            };
 112
 113            let full_path = ordered_paths
 114                .iter()
 115                .map(|p| p.to_string_lossy().to_string())
 116                .collect::<Vec<_>>()
 117                .join("\n");
 118
 119            RecentProjectEntry {
 120                name: SharedString::from(name),
 121                full_path: SharedString::from(full_path),
 122                paths,
 123                workspace_id,
 124            }
 125        })
 126        .collect();
 127
 128    match limit {
 129        Some(n) => entries.into_iter().take(n).collect(),
 130        None => entries,
 131    }
 132}
 133
 134pub async fn delete_recent_project(workspace_id: WorkspaceId) {
 135    let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
 136}
 137
 138fn get_open_folders(workspace: &Workspace, cx: &App) -> Vec<OpenFolderEntry> {
 139    let project = workspace.project().read(cx);
 140    let visible_worktrees: Vec<_> = project.visible_worktrees(cx).collect();
 141
 142    if visible_worktrees.len() <= 1 {
 143        return Vec::new();
 144    }
 145
 146    let active_worktree_id = workspace.active_worktree_override().or_else(|| {
 147        if let Some(repo) = project.active_repository(cx) {
 148            let repo = repo.read(cx);
 149            let repo_path = &repo.work_directory_abs_path;
 150            for worktree in project.visible_worktrees(cx) {
 151                let worktree_path = worktree.read(cx).abs_path();
 152                if worktree_path == *repo_path || worktree_path.starts_with(repo_path.as_ref()) {
 153                    return Some(worktree.read(cx).id());
 154                }
 155            }
 156        }
 157        project
 158            .visible_worktrees(cx)
 159            .next()
 160            .map(|wt| wt.read(cx).id())
 161    });
 162
 163    let git_store = project.git_store().read(cx);
 164    let repositories: Vec<_> = git_store.repositories().values().cloned().collect();
 165
 166    let mut entries: Vec<OpenFolderEntry> = visible_worktrees
 167        .into_iter()
 168        .map(|worktree| {
 169            let worktree_ref = worktree.read(cx);
 170            let worktree_id = worktree_ref.id();
 171            let name = SharedString::from(worktree_ref.root_name().as_unix_str().to_string());
 172            let path = worktree_ref.abs_path().to_path_buf();
 173            let branch = get_branch_for_worktree(worktree_ref, &repositories, cx);
 174            let is_active = active_worktree_id == Some(worktree_id);
 175            OpenFolderEntry {
 176                worktree_id,
 177                name,
 178                path,
 179                branch,
 180                is_active,
 181            }
 182        })
 183        .collect();
 184
 185    entries.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
 186    entries
 187}
 188
 189fn get_branch_for_worktree(
 190    worktree: &Worktree,
 191    repositories: &[Entity<Repository>],
 192    cx: &App,
 193) -> Option<SharedString> {
 194    let worktree_abs_path = worktree.abs_path();
 195    for repo in repositories {
 196        let repo = repo.read(cx);
 197        if repo.work_directory_abs_path == worktree_abs_path
 198            || worktree_abs_path.starts_with(&*repo.work_directory_abs_path)
 199        {
 200            if let Some(branch) = &repo.branch {
 201                return Some(SharedString::from(branch.name().to_string()));
 202            }
 203        }
 204    }
 205    None
 206}
 207
 208pub fn init(cx: &mut App) {
 209    #[cfg(target_os = "windows")]
 210    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
 211        let create_new_window = open_wsl.create_new_window;
 212        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 213            use gpui::PathPromptOptions;
 214            use project::DirectoryLister;
 215
 216            let paths = workspace.prompt_for_open_path(
 217                PathPromptOptions {
 218                    files: true,
 219                    directories: true,
 220                    multiple: false,
 221                    prompt: None,
 222                },
 223                DirectoryLister::Local(
 224                    workspace.project().clone(),
 225                    workspace.app_state().fs.clone(),
 226                ),
 227                window,
 228                cx,
 229            );
 230
 231            cx.spawn_in(window, async move |workspace, cx| {
 232                use util::paths::SanitizedPath;
 233
 234                let Some(paths) = paths.await.log_err().flatten() else {
 235                    return;
 236                };
 237
 238                let paths = paths
 239                    .into_iter()
 240                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
 241                    .collect::<Vec<_>>();
 242
 243                if paths.is_empty() {
 244                    let message = indoc::indoc! { r#"
 245                        Invalid path specified when trying to open a folder inside WSL.
 246
 247                        Please note that Zed currently does not support opening network share folders inside wsl.
 248                    "#};
 249
 250                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
 251                    return;
 252                }
 253
 254                workspace.update_in(cx, |workspace, window, cx| {
 255                    workspace.toggle_modal(window, cx, |window, cx| {
 256                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
 257                    });
 258                }).log_err();
 259            })
 260            .detach();
 261        });
 262    });
 263
 264    #[cfg(target_os = "windows")]
 265    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
 266        let create_new_window = open_wsl.create_new_window;
 267        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 268            let handle = cx.entity().downgrade();
 269            let fs = workspace.project().read(cx).fs().clone();
 270            workspace.toggle_modal(window, cx, |window, cx| {
 271                RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
 272            });
 273        });
 274    });
 275
 276    #[cfg(target_os = "windows")]
 277    cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
 278        let open_wsl = open_wsl.clone();
 279        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 280            let fs = workspace.project().read(cx).fs().clone();
 281            add_wsl_distro(fs, &open_wsl.distro, cx);
 282            let open_options = OpenOptions {
 283                replace_window: window.window_handle().downcast::<MultiWorkspace>(),
 284                ..Default::default()
 285            };
 286
 287            let app_state = workspace.app_state().clone();
 288
 289            cx.spawn_in(window, async move |_, cx| {
 290                open_remote_project(
 291                    RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
 292                    open_wsl.paths,
 293                    app_state,
 294                    open_options,
 295                    cx,
 296                )
 297                .await
 298            })
 299            .detach();
 300        });
 301    });
 302
 303    cx.on_action(|open_recent: &OpenRecent, cx| {
 304        let create_new_window = open_recent.create_new_window;
 305        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 306            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
 307                let focus_handle = workspace.focus_handle(cx);
 308                RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
 309                return;
 310            };
 311
 312            recent_projects.update(cx, |recent_projects, cx| {
 313                recent_projects
 314                    .picker
 315                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 316            });
 317        });
 318    });
 319    cx.on_action(|open_remote: &OpenRemote, cx| {
 320        let from_existing_connection = open_remote.from_existing_connection;
 321        let create_new_window = open_remote.create_new_window;
 322        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 323            if from_existing_connection {
 324                cx.propagate();
 325                return;
 326            }
 327            let handle = cx.entity().downgrade();
 328            let fs = workspace.project().read(cx).fs().clone();
 329            workspace.toggle_modal(window, cx, |window, cx| {
 330                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
 331            })
 332        });
 333    });
 334
 335    cx.observe_new(DisconnectedOverlay::register).detach();
 336
 337    cx.on_action(|_: &OpenDevContainer, cx| {
 338        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 339            if !workspace.project().read(cx).is_local() {
 340                cx.spawn_in(window, async move |_, cx| {
 341                    cx.prompt(
 342                        gpui::PromptLevel::Critical,
 343                        "Cannot open Dev Container from remote project",
 344                        None,
 345                        &["Ok"],
 346                    )
 347                    .await
 348                    .ok();
 349                })
 350                .detach();
 351                return;
 352            }
 353
 354            let fs = workspace.project().read(cx).fs().clone();
 355            let handle = cx.entity().downgrade();
 356            workspace.toggle_modal(window, cx, |window, cx| {
 357                RemoteServerProjects::new_dev_container(fs, window, handle, cx)
 358            });
 359        });
 360    });
 361
 362    // Subscribe to worktree additions to suggest opening the project in a dev container
 363    cx.observe_new(
 364        |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
 365            let Some(window) = window else {
 366                return;
 367            };
 368            cx.subscribe_in(
 369                workspace.project(),
 370                window,
 371                move |_, project, event, window, cx| {
 372                    if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
 373                        event
 374                    {
 375                        dev_container_suggest::suggest_on_worktree_updated(
 376                            *worktree_id,
 377                            updated_entries,
 378                            project,
 379                            window,
 380                            cx,
 381                        );
 382                    }
 383                },
 384            )
 385            .detach();
 386        },
 387    )
 388    .detach();
 389}
 390
 391#[cfg(target_os = "windows")]
 392pub fn add_wsl_distro(
 393    fs: Arc<dyn project::Fs>,
 394    connection_options: &remote::WslConnectionOptions,
 395    cx: &App,
 396) {
 397    use gpui::ReadGlobal;
 398    use settings::SettingsStore;
 399
 400    let distro_name = connection_options.distro_name.clone();
 401    let user = connection_options.user.clone();
 402    SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
 403        let connections = setting
 404            .remote
 405            .wsl_connections
 406            .get_or_insert(Default::default());
 407
 408        if !connections
 409            .iter()
 410            .any(|conn| conn.distro_name == distro_name && conn.user == user)
 411        {
 412            use std::collections::BTreeSet;
 413
 414            connections.push(settings::WslConnection {
 415                distro_name,
 416                user,
 417                projects: BTreeSet::new(),
 418            })
 419        }
 420    });
 421}
 422
 423pub struct RecentProjects {
 424    pub picker: Entity<Picker<RecentProjectsDelegate>>,
 425    rem_width: f32,
 426    _subscription: Subscription,
 427}
 428
 429impl ModalView for RecentProjects {
 430    fn on_before_dismiss(
 431        &mut self,
 432        window: &mut Window,
 433        cx: &mut Context<Self>,
 434    ) -> workspace::DismissDecision {
 435        let submenu_focused = self.picker.update(cx, |picker, cx| {
 436            picker.delegate.actions_menu_handle.is_focused(window, cx)
 437        });
 438        workspace::DismissDecision::Dismiss(!submenu_focused)
 439    }
 440}
 441
 442impl RecentProjects {
 443    fn new(
 444        delegate: RecentProjectsDelegate,
 445        fs: Option<Arc<dyn Fs>>,
 446        rem_width: f32,
 447        window: &mut Window,
 448        cx: &mut Context<Self>,
 449    ) -> Self {
 450        let picker = cx.new(|cx| {
 451            Picker::list(delegate, window, cx)
 452                .list_measure_all()
 453                .show_scrollbar(true)
 454        });
 455
 456        let picker_focus_handle = picker.focus_handle(cx);
 457        picker.update(cx, |picker, _| {
 458            picker.delegate.focus_handle = picker_focus_handle;
 459        });
 460
 461        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
 462        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
 463        // out workspace locations once the future runs to completion.
 464        cx.spawn_in(window, async move |this, cx| {
 465            let Some(fs) = fs else { return };
 466            let workspaces = WORKSPACE_DB
 467                .recent_workspaces_on_disk(fs.as_ref())
 468                .await
 469                .log_err()
 470                .unwrap_or_default();
 471            this.update_in(cx, move |this, window, cx| {
 472                this.picker.update(cx, move |picker, cx| {
 473                    picker.delegate.set_workspaces(workspaces);
 474                    picker.update_matches(picker.query(cx), window, cx)
 475                })
 476            })
 477            .ok();
 478        })
 479        .detach();
 480        Self {
 481            picker,
 482            rem_width,
 483            _subscription,
 484        }
 485    }
 486
 487    pub fn open(
 488        workspace: &mut Workspace,
 489        create_new_window: bool,
 490        window: &mut Window,
 491        focus_handle: FocusHandle,
 492        cx: &mut Context<Workspace>,
 493    ) {
 494        let weak = cx.entity().downgrade();
 495        let open_folders = get_open_folders(workspace, cx);
 496        let project_connection_options = workspace.project().read(cx).remote_connection_options(cx);
 497        let fs = Some(workspace.app_state().fs.clone());
 498        workspace.toggle_modal(window, cx, |window, cx| {
 499            let delegate = RecentProjectsDelegate::new(
 500                weak,
 501                create_new_window,
 502                focus_handle,
 503                open_folders,
 504                project_connection_options,
 505                ProjectPickerStyle::Modal,
 506            );
 507
 508            Self::new(delegate, fs, 34., window, cx)
 509        })
 510    }
 511
 512    pub fn popover(
 513        workspace: WeakEntity<Workspace>,
 514        create_new_window: bool,
 515        focus_handle: FocusHandle,
 516        window: &mut Window,
 517        cx: &mut App,
 518    ) -> Entity<Self> {
 519        let (open_folders, project_connection_options, fs) = workspace
 520            .upgrade()
 521            .map(|workspace| {
 522                let workspace = workspace.read(cx);
 523                (
 524                    get_open_folders(workspace, cx),
 525                    workspace.project().read(cx).remote_connection_options(cx),
 526                    Some(workspace.app_state().fs.clone()),
 527                )
 528            })
 529            .unwrap_or_else(|| (Vec::new(), None, None));
 530
 531        cx.new(|cx| {
 532            let delegate = RecentProjectsDelegate::new(
 533                workspace,
 534                create_new_window,
 535                focus_handle,
 536                open_folders,
 537                project_connection_options,
 538                ProjectPickerStyle::Popover,
 539            );
 540            let list = Self::new(delegate, fs, 20., window, cx);
 541            list.picker.focus_handle(cx).focus(window, cx);
 542            list
 543        })
 544    }
 545
 546    fn handle_toggle_open_menu(
 547        &mut self,
 548        _: &ToggleActionsMenu,
 549        window: &mut Window,
 550        cx: &mut Context<Self>,
 551    ) {
 552        self.picker.update(cx, |picker, cx| {
 553            let menu_handle = &picker.delegate.actions_menu_handle;
 554            if menu_handle.is_deployed() {
 555                menu_handle.hide(cx);
 556            } else {
 557                menu_handle.show(window, cx);
 558            }
 559        });
 560    }
 561}
 562
 563impl EventEmitter<DismissEvent> for RecentProjects {}
 564
 565impl Focusable for RecentProjects {
 566    fn focus_handle(&self, cx: &App) -> FocusHandle {
 567        self.picker.focus_handle(cx)
 568    }
 569}
 570
 571impl Render for RecentProjects {
 572    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 573        v_flex()
 574            .key_context("RecentProjects")
 575            .on_action(cx.listener(Self::handle_toggle_open_menu))
 576            .w(rems(self.rem_width))
 577            .child(self.picker.clone())
 578    }
 579}
 580
 581pub struct RecentProjectsDelegate {
 582    workspace: WeakEntity<Workspace>,
 583    open_folders: Vec<OpenFolderEntry>,
 584    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
 585    filtered_entries: Vec<ProjectPickerEntry>,
 586    selected_index: usize,
 587    render_paths: bool,
 588    create_new_window: bool,
 589    // Flag to reset index when there is a new query vs not reset index when user delete an item
 590    reset_selected_match_index: bool,
 591    has_any_non_local_projects: bool,
 592    project_connection_options: Option<RemoteConnectionOptions>,
 593    focus_handle: FocusHandle,
 594    style: ProjectPickerStyle,
 595    actions_menu_handle: PopoverMenuHandle<ContextMenu>,
 596}
 597
 598impl RecentProjectsDelegate {
 599    fn new(
 600        workspace: WeakEntity<Workspace>,
 601        create_new_window: bool,
 602        focus_handle: FocusHandle,
 603        open_folders: Vec<OpenFolderEntry>,
 604        project_connection_options: Option<RemoteConnectionOptions>,
 605        style: ProjectPickerStyle,
 606    ) -> Self {
 607        let render_paths = style == ProjectPickerStyle::Modal;
 608        Self {
 609            workspace,
 610            open_folders,
 611            workspaces: Vec::new(),
 612            filtered_entries: Vec::new(),
 613            selected_index: 0,
 614            create_new_window,
 615            render_paths,
 616            reset_selected_match_index: true,
 617            has_any_non_local_projects: project_connection_options.is_some(),
 618            project_connection_options,
 619            focus_handle,
 620            style,
 621            actions_menu_handle: PopoverMenuHandle::default(),
 622        }
 623    }
 624
 625    pub fn set_workspaces(
 626        &mut self,
 627        workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
 628    ) {
 629        self.workspaces = workspaces;
 630        let has_non_local_recent = !self
 631            .workspaces
 632            .iter()
 633            .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
 634        self.has_any_non_local_projects =
 635            self.project_connection_options.is_some() || has_non_local_recent;
 636    }
 637}
 638impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
 639impl PickerDelegate for RecentProjectsDelegate {
 640    type ListItem = AnyElement;
 641
 642    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 643        "Search projects…".into()
 644    }
 645
 646    fn match_count(&self) -> usize {
 647        self.filtered_entries.len()
 648    }
 649
 650    fn selected_index(&self) -> usize {
 651        self.selected_index
 652    }
 653
 654    fn set_selected_index(
 655        &mut self,
 656        ix: usize,
 657        _window: &mut Window,
 658        _cx: &mut Context<Picker<Self>>,
 659    ) {
 660        self.selected_index = ix;
 661    }
 662
 663    fn can_select(
 664        &mut self,
 665        ix: usize,
 666        _window: &mut Window,
 667        _cx: &mut Context<Picker<Self>>,
 668    ) -> bool {
 669        matches!(
 670            self.filtered_entries.get(ix),
 671            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
 672        )
 673    }
 674
 675    fn update_matches(
 676        &mut self,
 677        query: String,
 678        _: &mut Window,
 679        cx: &mut Context<Picker<Self>>,
 680    ) -> gpui::Task<()> {
 681        let query = query.trim_start();
 682        let smart_case = query.chars().any(|c| c.is_uppercase());
 683        let is_empty_query = query.is_empty();
 684
 685        let folder_matches = if self.open_folders.is_empty() {
 686            Vec::new()
 687        } else {
 688            let candidates: Vec<_> = self
 689                .open_folders
 690                .iter()
 691                .enumerate()
 692                .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
 693                .collect();
 694
 695            smol::block_on(fuzzy::match_strings(
 696                &candidates,
 697                query,
 698                smart_case,
 699                true,
 700                100,
 701                &Default::default(),
 702                cx.background_executor().clone(),
 703            ))
 704        };
 705
 706        let recent_candidates: Vec<_> = self
 707            .workspaces
 708            .iter()
 709            .enumerate()
 710            .filter(|(_, (id, _, paths))| self.is_valid_recent_candidate(*id, paths, cx))
 711            .map(|(id, (_, _, paths))| {
 712                let combined_string = paths
 713                    .ordered_paths()
 714                    .map(|path| path.compact().to_string_lossy().into_owned())
 715                    .collect::<Vec<_>>()
 716                    .join("");
 717                StringMatchCandidate::new(id, &combined_string)
 718            })
 719            .collect();
 720
 721        let mut recent_matches = smol::block_on(fuzzy::match_strings(
 722            &recent_candidates,
 723            query,
 724            smart_case,
 725            true,
 726            100,
 727            &Default::default(),
 728            cx.background_executor().clone(),
 729        ));
 730        recent_matches.sort_unstable_by(|a, b| {
 731            b.score
 732                .partial_cmp(&a.score)
 733                .unwrap_or(std::cmp::Ordering::Equal)
 734                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
 735        });
 736
 737        let mut entries = Vec::new();
 738
 739        if !self.open_folders.is_empty() {
 740            let matched_folders: Vec<_> = if is_empty_query {
 741                (0..self.open_folders.len())
 742                    .map(|i| (i, Vec::new()))
 743                    .collect()
 744            } else {
 745                folder_matches
 746                    .iter()
 747                    .map(|m| (m.candidate_id, m.positions.clone()))
 748                    .collect()
 749            };
 750
 751            for (index, positions) in matched_folders {
 752                entries.push(ProjectPickerEntry::OpenFolder { index, positions });
 753            }
 754        }
 755
 756        let has_recent_to_show = if is_empty_query {
 757            !recent_candidates.is_empty()
 758        } else {
 759            !recent_matches.is_empty()
 760        };
 761
 762        if has_recent_to_show {
 763            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
 764
 765            if is_empty_query {
 766                for (id, (workspace_id, _, paths)) in self.workspaces.iter().enumerate() {
 767                    if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
 768                        entries.push(ProjectPickerEntry::RecentProject(StringMatch {
 769                            candidate_id: id,
 770                            score: 0.0,
 771                            positions: Vec::new(),
 772                            string: String::new(),
 773                        }));
 774                    }
 775                }
 776            } else {
 777                for m in recent_matches {
 778                    entries.push(ProjectPickerEntry::RecentProject(m));
 779                }
 780            }
 781        }
 782
 783        self.filtered_entries = entries;
 784
 785        if self.reset_selected_match_index {
 786            self.selected_index = self
 787                .filtered_entries
 788                .iter()
 789                .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
 790                .unwrap_or(0);
 791        }
 792        self.reset_selected_match_index = true;
 793        Task::ready(())
 794    }
 795
 796    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 797        match self.filtered_entries.get(self.selected_index) {
 798            Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
 799                let Some(folder) = self.open_folders.get(*index) else {
 800                    return;
 801                };
 802                let worktree_id = folder.worktree_id;
 803                if let Some(workspace) = self.workspace.upgrade() {
 804                    workspace.update(cx, |workspace, cx| {
 805                        workspace.set_active_worktree_override(Some(worktree_id), cx);
 806                    });
 807                }
 808                cx.emit(DismissEvent);
 809            }
 810            Some(ProjectPickerEntry::RecentProject(selected_match)) => {
 811                let Some(workspace) = self.workspace.upgrade() else {
 812                    return;
 813                };
 814                let Some((
 815                    candidate_workspace_id,
 816                    candidate_workspace_location,
 817                    candidate_workspace_paths,
 818                )) = self.workspaces.get(selected_match.candidate_id)
 819                else {
 820                    return;
 821                };
 822
 823                let replace_current_window = self.create_new_window == secondary;
 824                let candidate_workspace_id = *candidate_workspace_id;
 825                let candidate_workspace_location = candidate_workspace_location.clone();
 826                let candidate_workspace_paths = candidate_workspace_paths.clone();
 827
 828                workspace.update(cx, |workspace, cx| {
 829                    if workspace.database_id() == Some(candidate_workspace_id) {
 830                        return;
 831                    }
 832                    match candidate_workspace_location {
 833                        SerializedWorkspaceLocation::Local => {
 834                            let paths = candidate_workspace_paths.paths().to_vec();
 835                            if replace_current_window {
 836                                if let Some(handle) =
 837                                    window.window_handle().downcast::<MultiWorkspace>()
 838                                {
 839                                    cx.defer(move |cx| {
 840                                        if let Some(task) = handle
 841                                            .update(cx, |multi_workspace, window, cx| {
 842                                                multi_workspace.open_project(paths, window, cx)
 843                                            })
 844                                            .log_err()
 845                                        {
 846                                            task.detach_and_log_err(cx);
 847                                        }
 848                                    });
 849                                }
 850                                return;
 851                            } else {
 852                                workspace.open_workspace_for_paths(false, paths, window, cx)
 853                            }
 854                        }
 855                        SerializedWorkspaceLocation::Remote(mut connection) => {
 856                            let app_state = workspace.app_state().clone();
 857                            let replace_window = if replace_current_window {
 858                                window.window_handle().downcast::<MultiWorkspace>()
 859                            } else {
 860                                None
 861                            };
 862                            let open_options = OpenOptions {
 863                                replace_window,
 864                                ..Default::default()
 865                            };
 866                            if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
 867                                RemoteSettings::get_global(cx)
 868                                    .fill_connection_options_from_settings(connection);
 869                            };
 870                            let paths = candidate_workspace_paths.paths().to_vec();
 871                            cx.spawn_in(window, async move |_, cx| {
 872                                open_remote_project(
 873                                    connection.clone(),
 874                                    paths,
 875                                    app_state,
 876                                    open_options,
 877                                    cx,
 878                                )
 879                                .await
 880                            })
 881                        }
 882                    }
 883                    .detach_and_prompt_err(
 884                        "Failed to open project",
 885                        window,
 886                        cx,
 887                        |_, _, _| None,
 888                    );
 889                });
 890                cx.emit(DismissEvent);
 891            }
 892            _ => {}
 893        }
 894    }
 895
 896    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
 897
 898    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 899        let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
 900            "Recently opened projects will show up here".into()
 901        } else {
 902            "No matches".into()
 903        };
 904        Some(text)
 905    }
 906
 907    fn render_match(
 908        &self,
 909        ix: usize,
 910        selected: bool,
 911        window: &mut Window,
 912        cx: &mut Context<Picker<Self>>,
 913    ) -> Option<Self::ListItem> {
 914        match self.filtered_entries.get(ix)? {
 915            ProjectPickerEntry::Header(title) => Some(
 916                v_flex()
 917                    .w_full()
 918                    .gap_1()
 919                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
 920                    .child(ListSubHeader::new(title.clone()).inset(true))
 921                    .into_any_element(),
 922            ),
 923            ProjectPickerEntry::OpenFolder { index, positions } => {
 924                let folder = self.open_folders.get(*index)?;
 925                let name = folder.name.clone();
 926                let path = folder.path.compact();
 927                let branch = folder.branch.clone();
 928                let is_active = folder.is_active;
 929                let worktree_id = folder.worktree_id;
 930                let positions = positions.clone();
 931                let show_path = self.style == ProjectPickerStyle::Modal;
 932
 933                let secondary_actions = h_flex()
 934                    .gap_1()
 935                    .child(
 936                        IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
 937                            .icon_size(IconSize::Small)
 938                            .tooltip(Tooltip::text("Remove Folder from Workspace"))
 939                            .on_click(cx.listener(move |picker, _, window, cx| {
 940                                let Some(workspace) = picker.delegate.workspace.upgrade() else {
 941                                    return;
 942                                };
 943                                workspace.update(cx, |workspace, cx| {
 944                                    let project = workspace.project().clone();
 945                                    project.update(cx, |project, cx| {
 946                                        project.remove_worktree(worktree_id, cx);
 947                                    });
 948                                });
 949                                picker.delegate.open_folders =
 950                                    get_open_folders(workspace.read(cx), cx);
 951                                let query = picker.query(cx);
 952                                picker.update_matches(query, window, cx);
 953                            })),
 954                    )
 955                    .into_any_element();
 956
 957                let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
 958
 959                Some(
 960                    ListItem::new(ix)
 961                        .toggle_state(selected)
 962                        .inset(true)
 963                        .spacing(ListItemSpacing::Sparse)
 964                        .child(
 965                            h_flex()
 966                                .id("open_folder_item")
 967                                .gap_3()
 968                                .flex_grow()
 969                                .when(self.has_any_non_local_projects, |this| {
 970                                    this.child(Icon::new(icon).color(Color::Muted))
 971                                })
 972                                .child(
 973                                    v_flex()
 974                                        .child(
 975                                            h_flex()
 976                                                .gap_1()
 977                                                .child({
 978                                                    let highlighted = HighlightedMatch {
 979                                                        text: name.to_string(),
 980                                                        highlight_positions: positions,
 981                                                        color: Color::Default,
 982                                                    };
 983                                                    highlighted.render(window, cx)
 984                                                })
 985                                                .when_some(branch, |this, branch| {
 986                                                    this.child(
 987                                                        Label::new(branch).color(Color::Muted),
 988                                                    )
 989                                                })
 990                                                .when(is_active, |this| {
 991                                                    this.child(
 992                                                        Icon::new(IconName::Check)
 993                                                            .size(IconSize::Small)
 994                                                            .color(Color::Accent),
 995                                                    )
 996                                                }),
 997                                        )
 998                                        .when(show_path, |this| {
 999                                            this.child(
1000                                                Label::new(path.to_string_lossy().to_string())
1001                                                    .size(LabelSize::Small)
1002                                                    .color(Color::Muted),
1003                                            )
1004                                        }),
1005                                )
1006                                .when(!show_path, |this| {
1007                                    this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1008                                }),
1009                        )
1010                        .map(|el| {
1011                            if self.selected_index == ix {
1012                                el.end_slot(secondary_actions)
1013                            } else {
1014                                el.end_hover_slot(secondary_actions)
1015                            }
1016                        })
1017                        .into_any_element(),
1018                )
1019            }
1020            ProjectPickerEntry::RecentProject(hit) => {
1021                let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1022                let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
1023                let tooltip_path: SharedString = paths
1024                    .ordered_paths()
1025                    .map(|p| p.compact().to_string_lossy().to_string())
1026                    .collect::<Vec<_>>()
1027                    .join("\n")
1028                    .into();
1029
1030                let mut path_start_offset = 0;
1031                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1032                    .ordered_paths()
1033                    .map(|p| p.compact())
1034                    .map(|path| {
1035                        let highlighted_text =
1036                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1037                        path_start_offset += highlighted_text.1.text.len();
1038                        highlighted_text
1039                    })
1040                    .unzip();
1041
1042                let prefix = match &location {
1043                    SerializedWorkspaceLocation::Remote(options) => {
1044                        Some(SharedString::from(options.display_name()))
1045                    }
1046                    _ => None,
1047                };
1048
1049                let highlighted_match = HighlightedMatchWithPaths {
1050                    prefix,
1051                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1052                    paths,
1053                };
1054
1055                let focus_handle = self.focus_handle.clone();
1056
1057                let secondary_actions = h_flex()
1058                    .gap_px()
1059                    .when(popover_style, |this| {
1060                        this.child(
1061                            IconButton::new("open_new_window", IconName::ArrowUpRight)
1062                                .icon_size(IconSize::XSmall)
1063                                .tooltip({
1064                                    move |_, cx| {
1065                                        Tooltip::for_action_in(
1066                                            "Open Project in New Window",
1067                                            &menu::SecondaryConfirm,
1068                                            &focus_handle,
1069                                            cx,
1070                                        )
1071                                    }
1072                                })
1073                                .on_click(cx.listener(move |this, _event, window, cx| {
1074                                    cx.stop_propagation();
1075                                    window.prevent_default();
1076                                    this.delegate.set_selected_index(ix, window, cx);
1077                                    this.delegate.confirm(true, window, cx);
1078                                })),
1079                        )
1080                    })
1081                    .child(
1082                        IconButton::new("delete", IconName::Close)
1083                            .icon_size(IconSize::Small)
1084                            .tooltip(Tooltip::text("Delete from Recent Projects"))
1085                            .on_click(cx.listener(move |this, _event, window, cx| {
1086                                cx.stop_propagation();
1087                                window.prevent_default();
1088                                this.delegate.delete_recent_project(ix, window, cx)
1089                            })),
1090                    )
1091                    .into_any_element();
1092
1093                let icon = icon_for_remote_connection(match location {
1094                    SerializedWorkspaceLocation::Local => None,
1095                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1096                });
1097
1098                Some(
1099                    ListItem::new(ix)
1100                        .toggle_state(selected)
1101                        .inset(true)
1102                        .spacing(ListItemSpacing::Sparse)
1103                        .child(
1104                            h_flex()
1105                                .id("project_info_container")
1106                                .gap_3()
1107                                .flex_grow()
1108                                .when(self.has_any_non_local_projects, |this| {
1109                                    this.child(Icon::new(icon).color(Color::Muted))
1110                                })
1111                                .child({
1112                                    let mut highlighted = highlighted_match;
1113                                    if !self.render_paths {
1114                                        highlighted.paths.clear();
1115                                    }
1116                                    highlighted.render(window, cx)
1117                                })
1118                                .tooltip(Tooltip::text(tooltip_path)),
1119                        )
1120                        .map(|el| {
1121                            if self.selected_index == ix {
1122                                el.end_slot(secondary_actions)
1123                            } else {
1124                                el.end_hover_slot(secondary_actions)
1125                            }
1126                        })
1127                        .into_any_element(),
1128                )
1129            }
1130        }
1131    }
1132
1133    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1134        let focus_handle = self.focus_handle.clone();
1135        let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1136        let open_folder_section = matches!(
1137            self.filtered_entries.get(self.selected_index)?,
1138            ProjectPickerEntry::OpenFolder { .. }
1139        );
1140
1141        if popover_style {
1142            return Some(
1143                v_flex()
1144                    .flex_1()
1145                    .p_1p5()
1146                    .gap_1()
1147                    .border_t_1()
1148                    .border_color(cx.theme().colors().border_variant)
1149                    .child(
1150                        Button::new("add_folder", "Add Project to Workspace")
1151                            .key_binding(KeyBinding::for_action_in(
1152                                &workspace::AddFolderToProject,
1153                                &focus_handle,
1154                                cx,
1155                            ))
1156                            .on_click(|_, window, cx| {
1157                                window.dispatch_action(
1158                                    workspace::AddFolderToProject.boxed_clone(),
1159                                    cx,
1160                                )
1161                            }),
1162                    )
1163                    .child(
1164                        Button::new("open_local_folder", "Open Local Project")
1165                            .key_binding(KeyBinding::for_action_in(
1166                                &workspace::Open,
1167                                &focus_handle,
1168                                cx,
1169                            ))
1170                            .on_click(|_, window, cx| {
1171                                window.dispatch_action(workspace::Open.boxed_clone(), cx)
1172                            }),
1173                    )
1174                    .child(
1175                        Button::new("open_remote_folder", "Open Remote Project")
1176                            .key_binding(KeyBinding::for_action(
1177                                &OpenRemote {
1178                                    from_existing_connection: false,
1179                                    create_new_window: false,
1180                                },
1181                                cx,
1182                            ))
1183                            .on_click(|_, window, cx| {
1184                                window.dispatch_action(
1185                                    OpenRemote {
1186                                        from_existing_connection: false,
1187                                        create_new_window: false,
1188                                    }
1189                                    .boxed_clone(),
1190                                    cx,
1191                                )
1192                            }),
1193                    )
1194                    .into_any(),
1195            );
1196        }
1197
1198        Some(
1199            h_flex()
1200                .flex_1()
1201                .p_1p5()
1202                .gap_1()
1203                .justify_end()
1204                .border_t_1()
1205                .border_color(cx.theme().colors().border_variant)
1206                .map(|this| {
1207                    if open_folder_section {
1208                        this.child(
1209                            Button::new("activate", "Activate")
1210                                .key_binding(KeyBinding::for_action_in(
1211                                    &menu::Confirm,
1212                                    &focus_handle,
1213                                    cx,
1214                                ))
1215                                .on_click(|_, window, cx| {
1216                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1217                                }),
1218                        )
1219                    } else {
1220                        this.child(
1221                            Button::new("open_new_window", "New Window")
1222                                .key_binding(KeyBinding::for_action_in(
1223                                    &menu::SecondaryConfirm,
1224                                    &focus_handle,
1225                                    cx,
1226                                ))
1227                                .on_click(|_, window, cx| {
1228                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1229                                }),
1230                        )
1231                        .child(
1232                            Button::new("open_here", "Open")
1233                                .key_binding(KeyBinding::for_action_in(
1234                                    &menu::Confirm,
1235                                    &focus_handle,
1236                                    cx,
1237                                ))
1238                                .on_click(|_, window, cx| {
1239                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1240                                }),
1241                        )
1242                    }
1243                })
1244                .child(Divider::vertical())
1245                .child(
1246                    PopoverMenu::new("actions-menu-popover")
1247                        .with_handle(self.actions_menu_handle.clone())
1248                        .anchor(gpui::Corner::BottomRight)
1249                        .offset(gpui::Point {
1250                            x: px(0.0),
1251                            y: px(-2.0),
1252                        })
1253                        .trigger(
1254                            Button::new("actions-trigger", "Actions…")
1255                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1256                                .key_binding(KeyBinding::for_action_in(
1257                                    &ToggleActionsMenu,
1258                                    &focus_handle,
1259                                    cx,
1260                                )),
1261                        )
1262                        .menu({
1263                            let focus_handle = focus_handle.clone();
1264
1265                            move |window, cx| {
1266                                Some(ContextMenu::build(window, cx, {
1267                                    let focus_handle = focus_handle.clone();
1268                                    move |menu, _, _| {
1269                                        menu.context(focus_handle)
1270                                            .action(
1271                                                "Open Local Project",
1272                                                workspace::Open.boxed_clone(),
1273                                            )
1274                                            .action(
1275                                                "Open Remote Project",
1276                                                OpenRemote {
1277                                                    from_existing_connection: false,
1278                                                    create_new_window: false,
1279                                                }
1280                                                .boxed_clone(),
1281                                            )
1282                                            .action(
1283                                                "Add Project to Workspace",
1284                                                workspace::AddFolderToProject.boxed_clone(),
1285                                            )
1286                                    }
1287                                }))
1288                            }
1289                        }),
1290                )
1291                .into_any(),
1292        )
1293    }
1294}
1295
1296fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1297    match options {
1298        None => IconName::Screen,
1299        Some(options) => match options {
1300            RemoteConnectionOptions::Ssh(_) => IconName::Server,
1301            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1302            RemoteConnectionOptions::Docker(_) => IconName::Box,
1303            #[cfg(any(test, feature = "test-support"))]
1304            RemoteConnectionOptions::Mock(_) => IconName::Server,
1305        },
1306    }
1307}
1308
1309// Compute the highlighted text for the name and path
1310fn highlights_for_path(
1311    path: &Path,
1312    match_positions: &Vec<usize>,
1313    path_start_offset: usize,
1314) -> (Option<HighlightedMatch>, HighlightedMatch) {
1315    let path_string = path.to_string_lossy();
1316    let path_text = path_string.to_string();
1317    let path_byte_len = path_text.len();
1318    // Get the subset of match highlight positions that line up with the given path.
1319    // Also adjusts them to start at the path start
1320    let path_positions = match_positions
1321        .iter()
1322        .copied()
1323        .skip_while(|position| *position < path_start_offset)
1324        .take_while(|position| *position < path_start_offset + path_byte_len)
1325        .map(|position| position - path_start_offset)
1326        .collect::<Vec<_>>();
1327
1328    // Again subset the highlight positions to just those that line up with the file_name
1329    // again adjusted to the start of the file_name
1330    let file_name_text_and_positions = path.file_name().map(|file_name| {
1331        let file_name_text = file_name.to_string_lossy().into_owned();
1332        let file_name_start_byte = path_byte_len - file_name_text.len();
1333        let highlight_positions = path_positions
1334            .iter()
1335            .copied()
1336            .skip_while(|position| *position < file_name_start_byte)
1337            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1338            .map(|position| position - file_name_start_byte)
1339            .collect::<Vec<_>>();
1340        HighlightedMatch {
1341            text: file_name_text,
1342            highlight_positions,
1343            color: Color::Default,
1344        }
1345    });
1346
1347    (
1348        file_name_text_and_positions,
1349        HighlightedMatch {
1350            text: path_text,
1351            highlight_positions: path_positions,
1352            color: Color::Default,
1353        },
1354    )
1355}
1356impl RecentProjectsDelegate {
1357    fn delete_recent_project(
1358        &self,
1359        ix: usize,
1360        window: &mut Window,
1361        cx: &mut Context<Picker<Self>>,
1362    ) {
1363        if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1364            self.filtered_entries.get(ix)
1365        {
1366            let (workspace_id, _, _) = &self.workspaces[selected_match.candidate_id];
1367            let workspace_id = *workspace_id;
1368            let fs = self
1369                .workspace
1370                .upgrade()
1371                .map(|ws| ws.read(cx).app_state().fs.clone());
1372            cx.spawn_in(window, async move |this, cx| {
1373                WORKSPACE_DB
1374                    .delete_workspace_by_id(workspace_id)
1375                    .await
1376                    .log_err();
1377                let Some(fs) = fs else { return };
1378                let workspaces = WORKSPACE_DB
1379                    .recent_workspaces_on_disk(fs.as_ref())
1380                    .await
1381                    .unwrap_or_default();
1382                this.update_in(cx, move |picker, window, cx| {
1383                    picker.delegate.set_workspaces(workspaces);
1384                    picker
1385                        .delegate
1386                        .set_selected_index(ix.saturating_sub(1), window, cx);
1387                    picker.delegate.reset_selected_match_index = false;
1388                    picker.update_matches(picker.query(cx), window, cx);
1389                    // After deleting a project, we want to update the history manager to reflect the change.
1390                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1391                    if let Some(history_manager) = HistoryManager::global(cx) {
1392                        history_manager
1393                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1394                    }
1395                })
1396                .ok();
1397            })
1398            .detach();
1399        }
1400    }
1401
1402    fn is_current_workspace(
1403        &self,
1404        workspace_id: WorkspaceId,
1405        cx: &mut Context<Picker<Self>>,
1406    ) -> bool {
1407        if let Some(workspace) = self.workspace.upgrade() {
1408            let workspace = workspace.read(cx);
1409            if Some(workspace_id) == workspace.database_id() {
1410                return true;
1411            }
1412        }
1413
1414        false
1415    }
1416
1417    fn is_open_folder(&self, paths: &PathList) -> bool {
1418        if self.open_folders.is_empty() {
1419            return false;
1420        }
1421
1422        for workspace_path in paths.paths() {
1423            for open_folder in &self.open_folders {
1424                if workspace_path == &open_folder.path {
1425                    return true;
1426                }
1427            }
1428        }
1429
1430        false
1431    }
1432
1433    fn is_valid_recent_candidate(
1434        &self,
1435        workspace_id: WorkspaceId,
1436        paths: &PathList,
1437        cx: &mut Context<Picker<Self>>,
1438    ) -> bool {
1439        !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1440    }
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445    use std::path::PathBuf;
1446
1447    use editor::Editor;
1448    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1449
1450    use serde_json::json;
1451    use settings::SettingsStore;
1452    use util::path;
1453    use workspace::{AppState, open_paths};
1454
1455    use super::*;
1456
1457    #[gpui::test]
1458    async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1459        let app_state = init_test(cx);
1460
1461        cx.update(|cx| {
1462            SettingsStore::update_global(cx, |store, cx| {
1463                store.update_user_settings(cx, |settings| {
1464                    settings
1465                        .session
1466                        .get_or_insert_default()
1467                        .restore_unsaved_buffers = Some(false)
1468                });
1469            });
1470        });
1471
1472        app_state
1473            .fs
1474            .as_fake()
1475            .insert_tree(
1476                path!("/dir"),
1477                json!({
1478                    "main.ts": "a"
1479                }),
1480            )
1481            .await;
1482        app_state
1483            .fs
1484            .as_fake()
1485            .insert_tree(path!("/test/path"), json!({}))
1486            .await;
1487        cx.update(|cx| {
1488            open_paths(
1489                &[PathBuf::from(path!("/dir/main.ts"))],
1490                app_state,
1491                workspace::OpenOptions::default(),
1492                cx,
1493            )
1494        })
1495        .await
1496        .unwrap();
1497        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1498
1499        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1500        multi_workspace
1501            .update(cx, |multi_workspace, _, cx| {
1502                assert!(!multi_workspace.workspace().read(cx).is_edited())
1503            })
1504            .unwrap();
1505
1506        let editor = multi_workspace
1507            .read_with(cx, |multi_workspace, cx| {
1508                multi_workspace
1509                    .workspace()
1510                    .read(cx)
1511                    .active_item(cx)
1512                    .unwrap()
1513                    .downcast::<Editor>()
1514                    .unwrap()
1515            })
1516            .unwrap();
1517        multi_workspace
1518            .update(cx, |_, window, cx| {
1519                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1520            })
1521            .unwrap();
1522        multi_workspace
1523            .update(cx, |multi_workspace, _, cx| {
1524                assert!(
1525                    multi_workspace.workspace().read(cx).is_edited(),
1526                    "After inserting more text into the editor without saving, we should have a dirty project"
1527                )
1528            })
1529            .unwrap();
1530
1531        let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1532        multi_workspace
1533            .update(cx, |_, _, cx| {
1534                recent_projects_picker.update(cx, |picker, cx| {
1535                    assert_eq!(picker.query(cx), "");
1536                    let delegate = &mut picker.delegate;
1537                    delegate.set_workspaces(vec![(
1538                        WorkspaceId::default(),
1539                        SerializedWorkspaceLocation::Local,
1540                        PathList::new(&[path!("/test/path")]),
1541                    )]);
1542                    delegate.filtered_entries =
1543                        vec![ProjectPickerEntry::RecentProject(StringMatch {
1544                            candidate_id: 0,
1545                            score: 1.0,
1546                            positions: Vec::new(),
1547                            string: "fake candidate".to_string(),
1548                        })];
1549                });
1550            })
1551            .unwrap();
1552
1553        assert!(
1554            !cx.has_pending_prompt(),
1555            "Should have no pending prompt on dirty project before opening the new recent project"
1556        );
1557        let dirty_workspace = multi_workspace
1558            .read_with(cx, |multi_workspace, _cx| {
1559                multi_workspace.workspace().clone()
1560            })
1561            .unwrap();
1562
1563        cx.dispatch_action(*multi_workspace, menu::Confirm);
1564        cx.run_until_parked();
1565
1566        multi_workspace
1567            .update(cx, |multi_workspace, _, cx| {
1568                assert!(
1569                    multi_workspace
1570                        .workspace()
1571                        .read(cx)
1572                        .active_modal::<RecentProjects>(cx)
1573                        .is_none(),
1574                    "Should remove the modal after selecting new recent project"
1575                );
1576
1577                assert!(
1578                    multi_workspace.workspaces().len() >= 2,
1579                    "Should have at least 2 workspaces: the dirty one and the newly opened one"
1580                );
1581
1582                assert!(
1583                    multi_workspace.workspaces().contains(&dirty_workspace),
1584                    "The original dirty workspace should still be present"
1585                );
1586
1587                assert!(
1588                    dirty_workspace.read(cx).is_edited(),
1589                    "The original workspace should still be dirty"
1590                );
1591            })
1592            .unwrap();
1593
1594        assert!(
1595            !cx.has_pending_prompt(),
1596            "No save prompt in multi-workspace mode — dirty workspace survives in background"
1597        );
1598    }
1599
1600    fn open_recent_projects(
1601        multi_workspace: &WindowHandle<MultiWorkspace>,
1602        cx: &mut TestAppContext,
1603    ) -> Entity<Picker<RecentProjectsDelegate>> {
1604        cx.dispatch_action(
1605            (*multi_workspace).into(),
1606            OpenRecent {
1607                create_new_window: false,
1608            },
1609        );
1610        multi_workspace
1611            .update(cx, |multi_workspace, _, cx| {
1612                multi_workspace
1613                    .workspace()
1614                    .read(cx)
1615                    .active_modal::<RecentProjects>(cx)
1616                    .unwrap()
1617                    .read(cx)
1618                    .picker
1619                    .clone()
1620            })
1621            .unwrap()
1622    }
1623
1624    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1625        cx.update(|cx| {
1626            let state = AppState::test(cx);
1627            crate::init(cx);
1628            editor::init(cx);
1629            state
1630        })
1631    }
1632}