recent_projects.rs

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