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(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
 754        matches!(
 755            self.filtered_entries.get(ix),
 756            Some(ProjectPickerEntry::OpenFolder { .. } | ProjectPickerEntry::RecentProject(_))
 757        )
 758    }
 759
 760    fn update_matches(
 761        &mut self,
 762        query: String,
 763        _: &mut Window,
 764        cx: &mut Context<Picker<Self>>,
 765    ) -> gpui::Task<()> {
 766        let query = query.trim_start();
 767        let smart_case = query.chars().any(|c| c.is_uppercase());
 768        let is_empty_query = query.is_empty();
 769
 770        let folder_matches = if self.open_folders.is_empty() {
 771            Vec::new()
 772        } else {
 773            let candidates: Vec<_> = self
 774                .open_folders
 775                .iter()
 776                .enumerate()
 777                .map(|(id, folder)| StringMatchCandidate::new(id, folder.name.as_ref()))
 778                .collect();
 779
 780            smol::block_on(fuzzy::match_strings(
 781                &candidates,
 782                query,
 783                smart_case,
 784                true,
 785                100,
 786                &Default::default(),
 787                cx.background_executor().clone(),
 788            ))
 789        };
 790
 791        let recent_candidates: Vec<_> = self
 792            .workspaces
 793            .iter()
 794            .enumerate()
 795            .filter(|(_, (id, _, paths, _))| self.is_valid_recent_candidate(*id, paths, cx))
 796            .map(|(id, (_, _, paths, _))| {
 797                let combined_string = paths
 798                    .ordered_paths()
 799                    .map(|path| path.compact().to_string_lossy().into_owned())
 800                    .collect::<Vec<_>>()
 801                    .join("");
 802                StringMatchCandidate::new(id, &combined_string)
 803            })
 804            .collect();
 805
 806        let mut recent_matches = smol::block_on(fuzzy::match_strings(
 807            &recent_candidates,
 808            query,
 809            smart_case,
 810            true,
 811            100,
 812            &Default::default(),
 813            cx.background_executor().clone(),
 814        ));
 815        recent_matches.sort_unstable_by(|a, b| {
 816            b.score
 817                .partial_cmp(&a.score)
 818                .unwrap_or(std::cmp::Ordering::Equal)
 819                .then_with(|| a.candidate_id.cmp(&b.candidate_id))
 820        });
 821
 822        let mut entries = Vec::new();
 823
 824        if !self.open_folders.is_empty() {
 825            let matched_folders: Vec<_> = if is_empty_query {
 826                (0..self.open_folders.len())
 827                    .map(|i| (i, Vec::new()))
 828                    .collect()
 829            } else {
 830                folder_matches
 831                    .iter()
 832                    .map(|m| (m.candidate_id, m.positions.clone()))
 833                    .collect()
 834            };
 835
 836            for (index, positions) in matched_folders {
 837                entries.push(ProjectPickerEntry::OpenFolder { index, positions });
 838            }
 839        }
 840
 841        let has_recent_to_show = if is_empty_query {
 842            !recent_candidates.is_empty()
 843        } else {
 844            !recent_matches.is_empty()
 845        };
 846
 847        if has_recent_to_show {
 848            entries.push(ProjectPickerEntry::Header("Recent Projects".into()));
 849
 850            if is_empty_query {
 851                for (id, (workspace_id, _, paths, _)) in self.workspaces.iter().enumerate() {
 852                    if self.is_valid_recent_candidate(*workspace_id, paths, cx) {
 853                        entries.push(ProjectPickerEntry::RecentProject(StringMatch {
 854                            candidate_id: id,
 855                            score: 0.0,
 856                            positions: Vec::new(),
 857                            string: String::new(),
 858                        }));
 859                    }
 860                }
 861            } else {
 862                for m in recent_matches {
 863                    entries.push(ProjectPickerEntry::RecentProject(m));
 864                }
 865            }
 866        }
 867
 868        self.filtered_entries = entries;
 869
 870        if self.reset_selected_match_index {
 871            self.selected_index = self
 872                .filtered_entries
 873                .iter()
 874                .position(|e| !matches!(e, ProjectPickerEntry::Header(_)))
 875                .unwrap_or(0);
 876        }
 877        self.reset_selected_match_index = true;
 878        Task::ready(())
 879    }
 880
 881    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 882        match self.filtered_entries.get(self.selected_index) {
 883            Some(ProjectPickerEntry::OpenFolder { index, .. }) => {
 884                let Some(folder) = self.open_folders.get(*index) else {
 885                    return;
 886                };
 887                let worktree_id = folder.worktree_id;
 888                if let Some(workspace) = self.workspace.upgrade() {
 889                    workspace.update(cx, |workspace, cx| {
 890                        workspace.set_active_worktree_override(Some(worktree_id), cx);
 891                    });
 892                }
 893                cx.emit(DismissEvent);
 894            }
 895            Some(ProjectPickerEntry::RecentProject(selected_match)) => {
 896                let Some(workspace) = self.workspace.upgrade() else {
 897                    return;
 898                };
 899                let Some((
 900                    candidate_workspace_id,
 901                    candidate_workspace_location,
 902                    candidate_workspace_paths,
 903                    _,
 904                )) = self.workspaces.get(selected_match.candidate_id)
 905                else {
 906                    return;
 907                };
 908
 909                let replace_current_window = self.create_new_window == secondary;
 910                let candidate_workspace_id = *candidate_workspace_id;
 911                let candidate_workspace_location = candidate_workspace_location.clone();
 912                let candidate_workspace_paths = candidate_workspace_paths.clone();
 913
 914                workspace.update(cx, |workspace, cx| {
 915                    if workspace.database_id() == Some(candidate_workspace_id) {
 916                        return;
 917                    }
 918                    match candidate_workspace_location {
 919                        SerializedWorkspaceLocation::Local => {
 920                            let paths = candidate_workspace_paths.paths().to_vec();
 921                            if replace_current_window {
 922                                if let Some(handle) =
 923                                    window.window_handle().downcast::<MultiWorkspace>()
 924                                {
 925                                    cx.defer(move |cx| {
 926                                        if let Some(task) = handle
 927                                            .update(cx, |multi_workspace, window, cx| {
 928                                                multi_workspace.open_project(paths, window, cx)
 929                                            })
 930                                            .log_err()
 931                                        {
 932                                            task.detach_and_log_err(cx);
 933                                        }
 934                                    });
 935                                }
 936                                return;
 937                            } else {
 938                                workspace.open_workspace_for_paths(false, paths, window, cx)
 939                            }
 940                        }
 941                        SerializedWorkspaceLocation::Remote(mut connection) => {
 942                            let app_state = workspace.app_state().clone();
 943                            let replace_window = if replace_current_window {
 944                                window.window_handle().downcast::<MultiWorkspace>()
 945                            } else {
 946                                None
 947                            };
 948                            let open_options = OpenOptions {
 949                                replace_window,
 950                                ..Default::default()
 951                            };
 952                            if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
 953                                RemoteSettings::get_global(cx)
 954                                    .fill_connection_options_from_settings(connection);
 955                            };
 956                            let paths = candidate_workspace_paths.paths().to_vec();
 957                            cx.spawn_in(window, async move |_, cx| {
 958                                open_remote_project(
 959                                    connection.clone(),
 960                                    paths,
 961                                    app_state,
 962                                    open_options,
 963                                    cx,
 964                                )
 965                                .await
 966                            })
 967                        }
 968                    }
 969                    .detach_and_prompt_err(
 970                        "Failed to open project",
 971                        window,
 972                        cx,
 973                        |_, _, _| None,
 974                    );
 975                });
 976                cx.emit(DismissEvent);
 977            }
 978            _ => {}
 979        }
 980    }
 981
 982    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
 983
 984    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 985        let text = if self.workspaces.is_empty() && self.open_folders.is_empty() {
 986            "Recently opened projects will show up here".into()
 987        } else {
 988            "No matches".into()
 989        };
 990        Some(text)
 991    }
 992
 993    fn render_match(
 994        &self,
 995        ix: usize,
 996        selected: bool,
 997        window: &mut Window,
 998        cx: &mut Context<Picker<Self>>,
 999    ) -> Option<Self::ListItem> {
1000        match self.filtered_entries.get(ix)? {
1001            ProjectPickerEntry::Header(title) => Some(
1002                v_flex()
1003                    .w_full()
1004                    .gap_1()
1005                    .when(ix > 0, |this| this.mt_1().child(Divider::horizontal()))
1006                    .child(ListSubHeader::new(title.clone()).inset(true))
1007                    .into_any_element(),
1008            ),
1009            ProjectPickerEntry::OpenFolder { index, positions } => {
1010                let folder = self.open_folders.get(*index)?;
1011                let name = folder.name.clone();
1012                let path = folder.path.compact();
1013                let branch = folder.branch.clone();
1014                let is_active = folder.is_active;
1015                let worktree_id = folder.worktree_id;
1016                let positions = positions.clone();
1017                let show_path = self.style == ProjectPickerStyle::Modal;
1018
1019                let secondary_actions = h_flex()
1020                    .gap_1()
1021                    .child(
1022                        IconButton::new(("remove-folder", worktree_id.to_usize()), IconName::Close)
1023                            .icon_size(IconSize::Small)
1024                            .tooltip(Tooltip::text("Remove Folder from Workspace"))
1025                            .on_click(cx.listener(move |picker, _, window, cx| {
1026                                let Some(workspace) = picker.delegate.workspace.upgrade() else {
1027                                    return;
1028                                };
1029                                workspace.update(cx, |workspace, cx| {
1030                                    let project = workspace.project().clone();
1031                                    project.update(cx, |project, cx| {
1032                                        project.remove_worktree(worktree_id, cx);
1033                                    });
1034                                });
1035                                picker.delegate.open_folders =
1036                                    get_open_folders(workspace.read(cx), cx);
1037                                let query = picker.query(cx);
1038                                picker.update_matches(query, window, cx);
1039                            })),
1040                    )
1041                    .into_any_element();
1042
1043                let icon = icon_for_remote_connection(self.project_connection_options.as_ref());
1044
1045                Some(
1046                    ListItem::new(ix)
1047                        .toggle_state(selected)
1048                        .inset(true)
1049                        .spacing(ListItemSpacing::Sparse)
1050                        .child(
1051                            h_flex()
1052                                .id("open_folder_item")
1053                                .gap_3()
1054                                .flex_grow()
1055                                .when(self.has_any_non_local_projects, |this| {
1056                                    this.child(Icon::new(icon).color(Color::Muted))
1057                                })
1058                                .child(
1059                                    v_flex()
1060                                        .child(
1061                                            h_flex()
1062                                                .gap_1()
1063                                                .child({
1064                                                    let highlighted = HighlightedMatch {
1065                                                        text: name.to_string(),
1066                                                        highlight_positions: positions,
1067                                                        color: Color::Default,
1068                                                    };
1069                                                    highlighted.render(window, cx)
1070                                                })
1071                                                .when_some(branch, |this, branch| {
1072                                                    this.child(
1073                                                        Label::new(branch).color(Color::Muted),
1074                                                    )
1075                                                })
1076                                                .when(is_active, |this| {
1077                                                    this.child(
1078                                                        Icon::new(IconName::Check)
1079                                                            .size(IconSize::Small)
1080                                                            .color(Color::Accent),
1081                                                    )
1082                                                }),
1083                                        )
1084                                        .when(show_path, |this| {
1085                                            this.child(
1086                                                Label::new(path.to_string_lossy().to_string())
1087                                                    .size(LabelSize::Small)
1088                                                    .color(Color::Muted),
1089                                            )
1090                                        }),
1091                                )
1092                                .when(!show_path, |this| {
1093                                    this.tooltip(Tooltip::text(path.to_string_lossy().to_string()))
1094                                }),
1095                        )
1096                        .map(|el| {
1097                            if self.selected_index == ix {
1098                                el.end_slot(secondary_actions)
1099                            } else {
1100                                el.end_hover_slot(secondary_actions)
1101                            }
1102                        })
1103                        .into_any_element(),
1104                )
1105            }
1106            ProjectPickerEntry::RecentProject(hit) => {
1107                let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1108                let (_, location, paths, _) = self.workspaces.get(hit.candidate_id)?;
1109                let is_local = matches!(location, SerializedWorkspaceLocation::Local);
1110                let paths_to_add = paths.paths().to_vec();
1111                let tooltip_path: SharedString = paths
1112                    .ordered_paths()
1113                    .map(|p| p.compact().to_string_lossy().to_string())
1114                    .collect::<Vec<_>>()
1115                    .join("\n")
1116                    .into();
1117
1118                let mut path_start_offset = 0;
1119                let (match_labels, paths): (Vec<_>, Vec<_>) = paths
1120                    .ordered_paths()
1121                    .map(|p| p.compact())
1122                    .map(|path| {
1123                        let highlighted_text =
1124                            highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
1125                        path_start_offset += highlighted_text.1.text.len();
1126                        highlighted_text
1127                    })
1128                    .unzip();
1129
1130                let prefix = match &location {
1131                    SerializedWorkspaceLocation::Remote(options) => {
1132                        Some(SharedString::from(options.display_name()))
1133                    }
1134                    _ => None,
1135                };
1136
1137                let highlighted_match = HighlightedMatchWithPaths {
1138                    prefix,
1139                    match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
1140                    paths,
1141                };
1142
1143                let focus_handle = self.focus_handle.clone();
1144
1145                let secondary_actions = h_flex()
1146                    .gap_px()
1147                    .when(is_local, |this| {
1148                        this.child(
1149                            IconButton::new("add_to_workspace", IconName::Plus)
1150                                .icon_size(IconSize::Small)
1151                                .tooltip(Tooltip::text("Add Project to Workspace"))
1152                                .on_click({
1153                                    let paths_to_add = paths_to_add.clone();
1154                                    cx.listener(move |picker, _event, window, cx| {
1155                                        cx.stop_propagation();
1156                                        window.prevent_default();
1157                                        picker.delegate.add_project_to_workspace(
1158                                            paths_to_add.clone(),
1159                                            window,
1160                                            cx,
1161                                        );
1162                                    })
1163                                }),
1164                        )
1165                    })
1166                    .when(popover_style, |this| {
1167                        this.child(
1168                            IconButton::new("open_new_window", IconName::ArrowUpRight)
1169                                .icon_size(IconSize::XSmall)
1170                                .tooltip({
1171                                    move |_, cx| {
1172                                        Tooltip::for_action_in(
1173                                            "Open Project in New Window",
1174                                            &menu::SecondaryConfirm,
1175                                            &focus_handle,
1176                                            cx,
1177                                        )
1178                                    }
1179                                })
1180                                .on_click(cx.listener(move |this, _event, window, cx| {
1181                                    cx.stop_propagation();
1182                                    window.prevent_default();
1183                                    this.delegate.set_selected_index(ix, window, cx);
1184                                    this.delegate.confirm(true, window, cx);
1185                                })),
1186                        )
1187                    })
1188                    .child(
1189                        IconButton::new("delete", IconName::Close)
1190                            .icon_size(IconSize::Small)
1191                            .tooltip(Tooltip::text("Delete from Recent Projects"))
1192                            .on_click(cx.listener(move |this, _event, window, cx| {
1193                                cx.stop_propagation();
1194                                window.prevent_default();
1195                                this.delegate.delete_recent_project(ix, window, cx)
1196                            })),
1197                    )
1198                    .into_any_element();
1199
1200                let icon = icon_for_remote_connection(match location {
1201                    SerializedWorkspaceLocation::Local => None,
1202                    SerializedWorkspaceLocation::Remote(options) => Some(options),
1203                });
1204
1205                Some(
1206                    ListItem::new(ix)
1207                        .toggle_state(selected)
1208                        .inset(true)
1209                        .spacing(ListItemSpacing::Sparse)
1210                        .child(
1211                            h_flex()
1212                                .id("project_info_container")
1213                                .gap_3()
1214                                .flex_grow()
1215                                .when(self.has_any_non_local_projects, |this| {
1216                                    this.child(Icon::new(icon).color(Color::Muted))
1217                                })
1218                                .child({
1219                                    let mut highlighted = highlighted_match;
1220                                    if !self.render_paths {
1221                                        highlighted.paths.clear();
1222                                    }
1223                                    highlighted.render(window, cx)
1224                                })
1225                                .tooltip(Tooltip::text(tooltip_path)),
1226                        )
1227                        .map(|el| {
1228                            if self.selected_index == ix {
1229                                el.end_slot(secondary_actions)
1230                            } else {
1231                                el.end_hover_slot(secondary_actions)
1232                            }
1233                        })
1234                        .into_any_element(),
1235                )
1236            }
1237        }
1238    }
1239
1240    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
1241        let focus_handle = self.focus_handle.clone();
1242        let popover_style = matches!(self.style, ProjectPickerStyle::Popover);
1243        let open_folder_section = matches!(
1244            self.filtered_entries.get(self.selected_index)?,
1245            ProjectPickerEntry::OpenFolder { .. }
1246        );
1247
1248        if popover_style {
1249            return Some(
1250                v_flex()
1251                    .flex_1()
1252                    .p_1p5()
1253                    .gap_1()
1254                    .border_t_1()
1255                    .border_color(cx.theme().colors().border_variant)
1256                    .child({
1257                        let open_action = workspace::Open {
1258                            create_new_window: self.create_new_window,
1259                        };
1260                        Button::new("open_local_folder", "Open Local Project")
1261                            .key_binding(KeyBinding::for_action_in(&open_action, &focus_handle, cx))
1262                            .on_click(move |_, window, cx| {
1263                                window.dispatch_action(open_action.boxed_clone(), cx)
1264                            })
1265                    })
1266                    .child(
1267                        Button::new("open_remote_folder", "Open Remote Project")
1268                            .key_binding(KeyBinding::for_action(
1269                                &OpenRemote {
1270                                    from_existing_connection: false,
1271                                    create_new_window: false,
1272                                },
1273                                cx,
1274                            ))
1275                            .on_click(|_, window, cx| {
1276                                window.dispatch_action(
1277                                    OpenRemote {
1278                                        from_existing_connection: false,
1279                                        create_new_window: false,
1280                                    }
1281                                    .boxed_clone(),
1282                                    cx,
1283                                )
1284                            }),
1285                    )
1286                    .into_any(),
1287            );
1288        }
1289
1290        Some(
1291            h_flex()
1292                .flex_1()
1293                .p_1p5()
1294                .gap_1()
1295                .justify_end()
1296                .border_t_1()
1297                .border_color(cx.theme().colors().border_variant)
1298                .map(|this| {
1299                    if open_folder_section {
1300                        this.child(
1301                            Button::new("activate", "Activate")
1302                                .key_binding(KeyBinding::for_action_in(
1303                                    &menu::Confirm,
1304                                    &focus_handle,
1305                                    cx,
1306                                ))
1307                                .on_click(|_, window, cx| {
1308                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1309                                }),
1310                        )
1311                    } else {
1312                        this.child(
1313                            Button::new("open_new_window", "New Window")
1314                                .key_binding(KeyBinding::for_action_in(
1315                                    &menu::SecondaryConfirm,
1316                                    &focus_handle,
1317                                    cx,
1318                                ))
1319                                .on_click(|_, window, cx| {
1320                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
1321                                }),
1322                        )
1323                        .child(
1324                            Button::new("open_here", "Open")
1325                                .key_binding(KeyBinding::for_action_in(
1326                                    &menu::Confirm,
1327                                    &focus_handle,
1328                                    cx,
1329                                ))
1330                                .on_click(|_, window, cx| {
1331                                    window.dispatch_action(menu::Confirm.boxed_clone(), cx)
1332                                }),
1333                        )
1334                    }
1335                })
1336                .child(Divider::vertical())
1337                .child(
1338                    PopoverMenu::new("actions-menu-popover")
1339                        .with_handle(self.actions_menu_handle.clone())
1340                        .anchor(gpui::Corner::BottomRight)
1341                        .offset(gpui::Point {
1342                            x: px(0.0),
1343                            y: px(-2.0),
1344                        })
1345                        .trigger(
1346                            Button::new("actions-trigger", "Actions…")
1347                                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
1348                                .key_binding(KeyBinding::for_action_in(
1349                                    &ToggleActionsMenu,
1350                                    &focus_handle,
1351                                    cx,
1352                                )),
1353                        )
1354                        .menu({
1355                            let focus_handle = focus_handle.clone();
1356                            let create_new_window = self.create_new_window;
1357
1358                            move |window, cx| {
1359                                Some(ContextMenu::build(window, cx, {
1360                                    let focus_handle = focus_handle.clone();
1361                                    move |menu, _, _| {
1362                                        menu.context(focus_handle)
1363                                            .action(
1364                                                "Open Local Project",
1365                                                workspace::Open { create_new_window }.boxed_clone(),
1366                                            )
1367                                            .action(
1368                                                "Open Remote Project",
1369                                                OpenRemote {
1370                                                    from_existing_connection: false,
1371                                                    create_new_window: false,
1372                                                }
1373                                                .boxed_clone(),
1374                                            )
1375                                    }
1376                                }))
1377                            }
1378                        }),
1379                )
1380                .into_any(),
1381        )
1382    }
1383}
1384
1385fn icon_for_remote_connection(options: Option<&RemoteConnectionOptions>) -> IconName {
1386    match options {
1387        None => IconName::Screen,
1388        Some(options) => match options {
1389            RemoteConnectionOptions::Ssh(_) => IconName::Server,
1390            RemoteConnectionOptions::Wsl(_) => IconName::Linux,
1391            RemoteConnectionOptions::Docker(_) => IconName::Box,
1392            #[cfg(any(test, feature = "test-support"))]
1393            RemoteConnectionOptions::Mock(_) => IconName::Server,
1394        },
1395    }
1396}
1397
1398// Compute the highlighted text for the name and path
1399fn highlights_for_path(
1400    path: &Path,
1401    match_positions: &Vec<usize>,
1402    path_start_offset: usize,
1403) -> (Option<HighlightedMatch>, HighlightedMatch) {
1404    let path_string = path.to_string_lossy();
1405    let path_text = path_string.to_string();
1406    let path_byte_len = path_text.len();
1407    // Get the subset of match highlight positions that line up with the given path.
1408    // Also adjusts them to start at the path start
1409    let path_positions = match_positions
1410        .iter()
1411        .copied()
1412        .skip_while(|position| *position < path_start_offset)
1413        .take_while(|position| *position < path_start_offset + path_byte_len)
1414        .map(|position| position - path_start_offset)
1415        .collect::<Vec<_>>();
1416
1417    // Again subset the highlight positions to just those that line up with the file_name
1418    // again adjusted to the start of the file_name
1419    let file_name_text_and_positions = path.file_name().map(|file_name| {
1420        let file_name_text = file_name.to_string_lossy().into_owned();
1421        let file_name_start_byte = path_byte_len - file_name_text.len();
1422        let highlight_positions = path_positions
1423            .iter()
1424            .copied()
1425            .skip_while(|position| *position < file_name_start_byte)
1426            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
1427            .map(|position| position - file_name_start_byte)
1428            .collect::<Vec<_>>();
1429        HighlightedMatch {
1430            text: file_name_text,
1431            highlight_positions,
1432            color: Color::Default,
1433        }
1434    });
1435
1436    (
1437        file_name_text_and_positions,
1438        HighlightedMatch {
1439            text: path_text,
1440            highlight_positions: path_positions,
1441            color: Color::Default,
1442        },
1443    )
1444}
1445impl RecentProjectsDelegate {
1446    fn add_project_to_workspace(
1447        &mut self,
1448        paths: Vec<PathBuf>,
1449        window: &mut Window,
1450        cx: &mut Context<Picker<Self>>,
1451    ) {
1452        let Some(workspace) = self.workspace.upgrade() else {
1453            return;
1454        };
1455        let open_paths_task = workspace.update(cx, |workspace, cx| {
1456            workspace.open_paths(
1457                paths,
1458                OpenOptions {
1459                    visible: Some(OpenVisible::All),
1460                    ..Default::default()
1461                },
1462                None,
1463                window,
1464                cx,
1465            )
1466        });
1467        cx.spawn_in(window, async move |picker, cx| {
1468            let _result = open_paths_task.await;
1469            picker
1470                .update_in(cx, |picker, window, cx| {
1471                    let Some(workspace) = picker.delegate.workspace.upgrade() else {
1472                        return;
1473                    };
1474                    picker.delegate.open_folders = get_open_folders(workspace.read(cx), cx);
1475                    let query = picker.query(cx);
1476                    picker.update_matches(query, window, cx);
1477                })
1478                .ok();
1479        })
1480        .detach();
1481    }
1482
1483    fn delete_recent_project(
1484        &self,
1485        ix: usize,
1486        window: &mut Window,
1487        cx: &mut Context<Picker<Self>>,
1488    ) {
1489        if let Some(ProjectPickerEntry::RecentProject(selected_match)) =
1490            self.filtered_entries.get(ix)
1491        {
1492            let (workspace_id, _, _, _) = &self.workspaces[selected_match.candidate_id];
1493            let workspace_id = *workspace_id;
1494            let fs = self
1495                .workspace
1496                .upgrade()
1497                .map(|ws| ws.read(cx).app_state().fs.clone());
1498            cx.spawn_in(window, async move |this, cx| {
1499                WORKSPACE_DB
1500                    .delete_workspace_by_id(workspace_id)
1501                    .await
1502                    .log_err();
1503                let Some(fs) = fs else { return };
1504                let workspaces = WORKSPACE_DB
1505                    .recent_workspaces_on_disk(fs.as_ref())
1506                    .await
1507                    .unwrap_or_default();
1508                this.update_in(cx, move |picker, window, cx| {
1509                    picker.delegate.set_workspaces(workspaces);
1510                    picker
1511                        .delegate
1512                        .set_selected_index(ix.saturating_sub(1), window, cx);
1513                    picker.delegate.reset_selected_match_index = false;
1514                    picker.update_matches(picker.query(cx), window, cx);
1515                    // After deleting a project, we want to update the history manager to reflect the change.
1516                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
1517                    if let Some(history_manager) = HistoryManager::global(cx) {
1518                        history_manager
1519                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
1520                    }
1521                })
1522                .ok();
1523            })
1524            .detach();
1525        }
1526    }
1527
1528    fn is_current_workspace(
1529        &self,
1530        workspace_id: WorkspaceId,
1531        cx: &mut Context<Picker<Self>>,
1532    ) -> bool {
1533        if let Some(workspace) = self.workspace.upgrade() {
1534            let workspace = workspace.read(cx);
1535            if Some(workspace_id) == workspace.database_id() {
1536                return true;
1537            }
1538        }
1539
1540        false
1541    }
1542
1543    fn is_open_folder(&self, paths: &PathList) -> bool {
1544        if self.open_folders.is_empty() {
1545            return false;
1546        }
1547
1548        for workspace_path in paths.paths() {
1549            for open_folder in &self.open_folders {
1550                if workspace_path == &open_folder.path {
1551                    return true;
1552                }
1553            }
1554        }
1555
1556        false
1557    }
1558
1559    fn is_valid_recent_candidate(
1560        &self,
1561        workspace_id: WorkspaceId,
1562        paths: &PathList,
1563        cx: &mut Context<Picker<Self>>,
1564    ) -> bool {
1565        !self.is_current_workspace(workspace_id, cx) && !self.is_open_folder(paths)
1566    }
1567}
1568
1569#[cfg(test)]
1570mod tests {
1571    use std::path::PathBuf;
1572
1573    use editor::Editor;
1574    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
1575
1576    use serde_json::json;
1577    use settings::SettingsStore;
1578    use util::path;
1579    use workspace::{AppState, open_paths};
1580
1581    use super::*;
1582
1583    #[gpui::test]
1584    async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
1585        let app_state = init_test(cx);
1586
1587        cx.update(|cx| {
1588            SettingsStore::update_global(cx, |store, cx| {
1589                store.update_user_settings(cx, |settings| {
1590                    settings
1591                        .session
1592                        .get_or_insert_default()
1593                        .restore_unsaved_buffers = Some(false)
1594                });
1595            });
1596        });
1597
1598        app_state
1599            .fs
1600            .as_fake()
1601            .insert_tree(
1602                path!("/dir"),
1603                json!({
1604                    "main.ts": "a"
1605                }),
1606            )
1607            .await;
1608        app_state
1609            .fs
1610            .as_fake()
1611            .insert_tree(path!("/test/path"), json!({}))
1612            .await;
1613        cx.update(|cx| {
1614            open_paths(
1615                &[PathBuf::from(path!("/dir/main.ts"))],
1616                app_state,
1617                workspace::OpenOptions::default(),
1618                cx,
1619            )
1620        })
1621        .await
1622        .unwrap();
1623        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1624
1625        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1626        multi_workspace
1627            .update(cx, |multi_workspace, _, cx| {
1628                assert!(!multi_workspace.workspace().read(cx).is_edited())
1629            })
1630            .unwrap();
1631
1632        let editor = multi_workspace
1633            .read_with(cx, |multi_workspace, cx| {
1634                multi_workspace
1635                    .workspace()
1636                    .read(cx)
1637                    .active_item(cx)
1638                    .unwrap()
1639                    .downcast::<Editor>()
1640                    .unwrap()
1641            })
1642            .unwrap();
1643        multi_workspace
1644            .update(cx, |_, window, cx| {
1645                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1646            })
1647            .unwrap();
1648        multi_workspace
1649            .update(cx, |multi_workspace, _, cx| {
1650                assert!(
1651                    multi_workspace.workspace().read(cx).is_edited(),
1652                    "After inserting more text into the editor without saving, we should have a dirty project"
1653                )
1654            })
1655            .unwrap();
1656
1657        let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1658        multi_workspace
1659            .update(cx, |_, _, cx| {
1660                recent_projects_picker.update(cx, |picker, cx| {
1661                    assert_eq!(picker.query(cx), "");
1662                    let delegate = &mut picker.delegate;
1663                    delegate.set_workspaces(vec![(
1664                        WorkspaceId::default(),
1665                        SerializedWorkspaceLocation::Local,
1666                        PathList::new(&[path!("/test/path")]),
1667                        Utc::now(),
1668                    )]);
1669                    delegate.filtered_entries =
1670                        vec![ProjectPickerEntry::RecentProject(StringMatch {
1671                            candidate_id: 0,
1672                            score: 1.0,
1673                            positions: Vec::new(),
1674                            string: "fake candidate".to_string(),
1675                        })];
1676                });
1677            })
1678            .unwrap();
1679
1680        assert!(
1681            !cx.has_pending_prompt(),
1682            "Should have no pending prompt on dirty project before opening the new recent project"
1683        );
1684        let dirty_workspace = multi_workspace
1685            .read_with(cx, |multi_workspace, _cx| {
1686                multi_workspace.workspace().clone()
1687            })
1688            .unwrap();
1689
1690        cx.dispatch_action(*multi_workspace, menu::Confirm);
1691        cx.run_until_parked();
1692
1693        multi_workspace
1694            .update(cx, |multi_workspace, _, cx| {
1695                assert!(
1696                    multi_workspace
1697                        .workspace()
1698                        .read(cx)
1699                        .active_modal::<RecentProjects>(cx)
1700                        .is_none(),
1701                    "Should remove the modal after selecting new recent project"
1702                );
1703
1704                assert!(
1705                    multi_workspace.workspaces().len() >= 2,
1706                    "Should have at least 2 workspaces: the dirty one and the newly opened one"
1707                );
1708
1709                assert!(
1710                    multi_workspace.workspaces().contains(&dirty_workspace),
1711                    "The original dirty workspace should still be present"
1712                );
1713
1714                assert!(
1715                    dirty_workspace.read(cx).is_edited(),
1716                    "The original workspace should still be dirty"
1717                );
1718            })
1719            .unwrap();
1720
1721        assert!(
1722            !cx.has_pending_prompt(),
1723            "No save prompt in multi-workspace mode — dirty workspace survives in background"
1724        );
1725    }
1726
1727    fn open_recent_projects(
1728        multi_workspace: &WindowHandle<MultiWorkspace>,
1729        cx: &mut TestAppContext,
1730    ) -> Entity<Picker<RecentProjectsDelegate>> {
1731        cx.dispatch_action(
1732            (*multi_workspace).into(),
1733            OpenRecent {
1734                create_new_window: false,
1735            },
1736        );
1737        multi_workspace
1738            .update(cx, |multi_workspace, _, cx| {
1739                multi_workspace
1740                    .workspace()
1741                    .read(cx)
1742                    .active_modal::<RecentProjects>(cx)
1743                    .unwrap()
1744                    .read(cx)
1745                    .picker
1746                    .clone()
1747            })
1748            .unwrap()
1749    }
1750
1751    #[gpui::test]
1752    async fn test_open_dev_container_action_with_single_config(cx: &mut TestAppContext) {
1753        let app_state = init_test(cx);
1754
1755        app_state
1756            .fs
1757            .as_fake()
1758            .insert_tree(
1759                path!("/project"),
1760                json!({
1761                    ".devcontainer": {
1762                        "devcontainer.json": "{}"
1763                    },
1764                    "src": {
1765                        "main.rs": "fn main() {}"
1766                    }
1767                }),
1768            )
1769            .await;
1770
1771        cx.update(|cx| {
1772            open_paths(
1773                &[PathBuf::from(path!("/project"))],
1774                app_state,
1775                workspace::OpenOptions::default(),
1776                cx,
1777            )
1778        })
1779        .await
1780        .unwrap();
1781
1782        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1783        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1784
1785        cx.run_until_parked();
1786
1787        // This dispatch triggers with_active_or_new_workspace -> MultiWorkspace::update
1788        // -> Workspace::update -> toggle_modal -> new_dev_container.
1789        // Before the fix, this panicked with "cannot read workspace::Workspace while
1790        // it is already being updated" because new_dev_container and open_dev_container
1791        // tried to read the Workspace entity through a WeakEntity handle while it was
1792        // already leased by the outer update.
1793        cx.dispatch_action(*multi_workspace, OpenDevContainer);
1794
1795        multi_workspace
1796            .update(cx, |multi_workspace, _, cx| {
1797                let modal = multi_workspace
1798                    .workspace()
1799                    .read(cx)
1800                    .active_modal::<RemoteServerProjects>(cx);
1801                assert!(
1802                    modal.is_some(),
1803                    "Dev container modal should be open after dispatching OpenDevContainer"
1804                );
1805            })
1806            .unwrap();
1807    }
1808
1809    #[gpui::test]
1810    async fn test_open_dev_container_action_with_multiple_configs(cx: &mut TestAppContext) {
1811        let app_state = init_test(cx);
1812
1813        app_state
1814            .fs
1815            .as_fake()
1816            .insert_tree(
1817                path!("/project"),
1818                json!({
1819                    ".devcontainer": {
1820                        "rust": {
1821                            "devcontainer.json": "{}"
1822                        },
1823                        "python": {
1824                            "devcontainer.json": "{}"
1825                        }
1826                    },
1827                    "src": {
1828                        "main.rs": "fn main() {}"
1829                    }
1830                }),
1831            )
1832            .await;
1833
1834        cx.update(|cx| {
1835            open_paths(
1836                &[PathBuf::from(path!("/project"))],
1837                app_state,
1838                workspace::OpenOptions::default(),
1839                cx,
1840            )
1841        })
1842        .await
1843        .unwrap();
1844
1845        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
1846        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1847
1848        cx.run_until_parked();
1849
1850        cx.dispatch_action(*multi_workspace, OpenDevContainer);
1851
1852        multi_workspace
1853            .update(cx, |multi_workspace, _, cx| {
1854                let modal = multi_workspace
1855                    .workspace()
1856                    .read(cx)
1857                    .active_modal::<RemoteServerProjects>(cx);
1858                assert!(
1859                    modal.is_some(),
1860                    "Dev container modal should be open after dispatching OpenDevContainer with multiple configs"
1861                );
1862            })
1863            .unwrap();
1864    }
1865
1866    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1867        cx.update(|cx| {
1868            let state = AppState::test(cx);
1869            crate::init(cx);
1870            editor::init(cx);
1871            state
1872        })
1873    }
1874}