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