recent_projects.rs

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