recent_projects.rs

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