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