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