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