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