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