recent_projects.rs

   1mod dev_container_suggest;
   2pub mod disconnected_overlay;
   3mod remote_connections;
   4mod remote_servers;
   5mod ssh_config;
   6
   7use std::{path::PathBuf, sync::Arc};
   8
   9use fs::Fs;
  10
  11#[cfg(target_os = "windows")]
  12mod wsl_picker;
  13
  14use remote::RemoteConnectionOptions;
  15pub use remote_connection::{RemoteConnectionModal, connect};
  16pub use remote_connections::open_remote_project;
  17
  18use disconnected_overlay::DisconnectedOverlay;
  19use fuzzy::{StringMatch, StringMatchCandidate};
  20use gpui::{
  21    Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  22    Subscription, Task, WeakEntity, Window,
  23};
  24use ordered_float::OrderedFloat;
  25use picker::{
  26    Picker, PickerDelegate,
  27    highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
  28};
  29pub use remote_connections::RemoteSettings;
  30pub use remote_servers::RemoteServerProjects;
  31use settings::Settings;
  32use std::path::Path;
  33use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
  34use util::{ResultExt, paths::PathExt};
  35use workspace::{
  36    HistoryManager, ModalView, MultiWorkspace, OpenOptions, PathList, SerializedWorkspaceLocation,
  37    WORKSPACE_DB, Workspace, WorkspaceId, notifications::DetachAndPromptErr,
  38    with_active_or_new_workspace,
  39};
  40use zed_actions::{OpenDevContainer, OpenRecent, OpenRemote};
  41
  42#[derive(Clone, Debug)]
  43pub struct RecentProjectEntry {
  44    pub name: SharedString,
  45    pub full_path: SharedString,
  46    pub paths: Vec<PathBuf>,
  47    pub workspace_id: WorkspaceId,
  48}
  49
  50pub async fn get_recent_projects(
  51    current_workspace_id: Option<WorkspaceId>,
  52    limit: Option<usize>,
  53    fs: Arc<dyn fs::Fs>,
  54) -> Vec<RecentProjectEntry> {
  55    let workspaces = WORKSPACE_DB
  56        .recent_workspaces_on_disk(fs.as_ref())
  57        .await
  58        .unwrap_or_default();
  59
  60    let entries: Vec<RecentProjectEntry> = workspaces
  61        .into_iter()
  62        .filter(|(id, _, _)| Some(*id) != current_workspace_id)
  63        .filter(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local))
  64        .map(|(workspace_id, _, path_list)| {
  65            let paths: Vec<PathBuf> = path_list.paths().to_vec();
  66            let ordered_paths: Vec<&PathBuf> = path_list.ordered_paths().collect();
  67
  68            let name = if ordered_paths.len() == 1 {
  69                ordered_paths[0]
  70                    .file_name()
  71                    .map(|n| n.to_string_lossy().to_string())
  72                    .unwrap_or_else(|| ordered_paths[0].to_string_lossy().to_string())
  73            } else {
  74                ordered_paths
  75                    .iter()
  76                    .filter_map(|p| p.file_name())
  77                    .map(|n| n.to_string_lossy().to_string())
  78                    .collect::<Vec<_>>()
  79                    .join(", ")
  80            };
  81
  82            let full_path = ordered_paths
  83                .iter()
  84                .map(|p| p.to_string_lossy().to_string())
  85                .collect::<Vec<_>>()
  86                .join("\n");
  87
  88            RecentProjectEntry {
  89                name: SharedString::from(name),
  90                full_path: SharedString::from(full_path),
  91                paths,
  92                workspace_id,
  93            }
  94        })
  95        .collect();
  96
  97    match limit {
  98        Some(n) => entries.into_iter().take(n).collect(),
  99        None => entries,
 100    }
 101}
 102
 103pub async fn delete_recent_project(workspace_id: WorkspaceId) {
 104    let _ = WORKSPACE_DB.delete_workspace_by_id(workspace_id).await;
 105}
 106
 107pub fn init(cx: &mut App) {
 108    #[cfg(target_os = "windows")]
 109    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
 110        let create_new_window = open_wsl.create_new_window;
 111        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 112            use gpui::PathPromptOptions;
 113            use project::DirectoryLister;
 114
 115            let paths = workspace.prompt_for_open_path(
 116                PathPromptOptions {
 117                    files: true,
 118                    directories: true,
 119                    multiple: false,
 120                    prompt: None,
 121                },
 122                DirectoryLister::Local(
 123                    workspace.project().clone(),
 124                    workspace.app_state().fs.clone(),
 125                ),
 126                window,
 127                cx,
 128            );
 129
 130            cx.spawn_in(window, async move |workspace, cx| {
 131                use util::paths::SanitizedPath;
 132
 133                let Some(paths) = paths.await.log_err().flatten() else {
 134                    return;
 135                };
 136
 137                let paths = paths
 138                    .into_iter()
 139                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
 140                    .collect::<Vec<_>>();
 141
 142                if paths.is_empty() {
 143                    let message = indoc::indoc! { r#"
 144                        Invalid path specified when trying to open a folder inside WSL.
 145
 146                        Please note that Zed currently does not support opening network share folders inside wsl.
 147                    "#};
 148
 149                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
 150                    return;
 151                }
 152
 153                workspace.update_in(cx, |workspace, window, cx| {
 154                    workspace.toggle_modal(window, cx, |window, cx| {
 155                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
 156                    });
 157                }).log_err();
 158            })
 159            .detach();
 160        });
 161    });
 162
 163    #[cfg(target_os = "windows")]
 164    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenWsl, cx| {
 165        let create_new_window = open_wsl.create_new_window;
 166        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 167            let handle = cx.entity().downgrade();
 168            let fs = workspace.project().read(cx).fs().clone();
 169            workspace.toggle_modal(window, cx, |window, cx| {
 170                RemoteServerProjects::wsl(create_new_window, fs, window, handle, cx)
 171            });
 172        });
 173    });
 174
 175    #[cfg(target_os = "windows")]
 176    cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
 177        let open_wsl = open_wsl.clone();
 178        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 179            let fs = workspace.project().read(cx).fs().clone();
 180            add_wsl_distro(fs, &open_wsl.distro, cx);
 181            let open_options = OpenOptions {
 182                replace_window: window.window_handle().downcast::<MultiWorkspace>(),
 183                ..Default::default()
 184            };
 185
 186            let app_state = workspace.app_state().clone();
 187
 188            cx.spawn_in(window, async move |_, cx| {
 189                open_remote_project(
 190                    RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
 191                    open_wsl.paths,
 192                    app_state,
 193                    open_options,
 194                    cx,
 195                )
 196                .await
 197            })
 198            .detach();
 199        });
 200    });
 201
 202    cx.on_action(|open_recent: &OpenRecent, cx| {
 203        let create_new_window = open_recent.create_new_window;
 204        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 205            let Some(recent_projects) = workspace.active_modal::<RecentProjects>(cx) else {
 206                let focus_handle = workspace.focus_handle(cx);
 207                RecentProjects::open(workspace, create_new_window, window, focus_handle, cx);
 208                return;
 209            };
 210
 211            recent_projects.update(cx, |recent_projects, cx| {
 212                recent_projects
 213                    .picker
 214                    .update(cx, |picker, cx| picker.cycle_selection(window, cx))
 215            });
 216        });
 217    });
 218    cx.on_action(|open_remote: &OpenRemote, cx| {
 219        let from_existing_connection = open_remote.from_existing_connection;
 220        let create_new_window = open_remote.create_new_window;
 221        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 222            if from_existing_connection {
 223                cx.propagate();
 224                return;
 225            }
 226            let handle = cx.entity().downgrade();
 227            let fs = workspace.project().read(cx).fs().clone();
 228            workspace.toggle_modal(window, cx, |window, cx| {
 229                RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
 230            })
 231        });
 232    });
 233
 234    cx.observe_new(DisconnectedOverlay::register).detach();
 235
 236    cx.on_action(|_: &OpenDevContainer, cx| {
 237        with_active_or_new_workspace(cx, move |workspace, window, cx| {
 238            if !workspace.project().read(cx).is_local() {
 239                cx.spawn_in(window, async move |_, cx| {
 240                    cx.prompt(
 241                        gpui::PromptLevel::Critical,
 242                        "Cannot open Dev Container from remote project",
 243                        None,
 244                        &["Ok"],
 245                    )
 246                    .await
 247                    .ok();
 248                })
 249                .detach();
 250                return;
 251            }
 252
 253            let fs = workspace.project().read(cx).fs().clone();
 254            let handle = cx.entity().downgrade();
 255            workspace.toggle_modal(window, cx, |window, cx| {
 256                RemoteServerProjects::new_dev_container(fs, window, handle, cx)
 257            });
 258        });
 259    });
 260
 261    // Subscribe to worktree additions to suggest opening the project in a dev container
 262    cx.observe_new(
 263        |workspace: &mut Workspace, window: Option<&mut Window>, cx: &mut Context<Workspace>| {
 264            let Some(window) = window else {
 265                return;
 266            };
 267            cx.subscribe_in(
 268                workspace.project(),
 269                window,
 270                move |_, project, event, window, cx| {
 271                    if let project::Event::WorktreeUpdatedEntries(worktree_id, updated_entries) =
 272                        event
 273                    {
 274                        dev_container_suggest::suggest_on_worktree_updated(
 275                            *worktree_id,
 276                            updated_entries,
 277                            project,
 278                            window,
 279                            cx,
 280                        );
 281                    }
 282                },
 283            )
 284            .detach();
 285        },
 286    )
 287    .detach();
 288}
 289
 290#[cfg(target_os = "windows")]
 291pub fn add_wsl_distro(
 292    fs: Arc<dyn project::Fs>,
 293    connection_options: &remote::WslConnectionOptions,
 294    cx: &App,
 295) {
 296    use gpui::ReadGlobal;
 297    use settings::SettingsStore;
 298
 299    let distro_name = connection_options.distro_name.clone();
 300    let user = connection_options.user.clone();
 301    SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
 302        let connections = setting
 303            .remote
 304            .wsl_connections
 305            .get_or_insert(Default::default());
 306
 307        if !connections
 308            .iter()
 309            .any(|conn| conn.distro_name == distro_name && conn.user == user)
 310        {
 311            use std::collections::BTreeSet;
 312
 313            connections.push(settings::WslConnection {
 314                distro_name,
 315                user,
 316                projects: BTreeSet::new(),
 317            })
 318        }
 319    });
 320}
 321
 322pub struct RecentProjects {
 323    pub picker: Entity<Picker<RecentProjectsDelegate>>,
 324    rem_width: f32,
 325    _subscription: Subscription,
 326}
 327
 328impl ModalView for RecentProjects {}
 329
 330impl RecentProjects {
 331    fn new(
 332        delegate: RecentProjectsDelegate,
 333        fs: Option<Arc<dyn Fs>>,
 334        rem_width: f32,
 335        window: &mut Window,
 336        cx: &mut Context<Self>,
 337    ) -> Self {
 338        let picker = cx.new(|cx| {
 339            // We want to use a list when we render paths, because the items can have different heights (multiple paths).
 340            if delegate.render_paths {
 341                Picker::list(delegate, window, cx)
 342            } else {
 343                Picker::uniform_list(delegate, window, cx)
 344            }
 345        });
 346        let _subscription = cx.subscribe(&picker, |_, _, _, cx| cx.emit(DismissEvent));
 347        // We do not want to block the UI on a potentially lengthy call to DB, so we're gonna swap
 348        // out workspace locations once the future runs to completion.
 349        cx.spawn_in(window, async move |this, cx| {
 350            let Some(fs) = fs else { return };
 351            let workspaces = WORKSPACE_DB
 352                .recent_workspaces_on_disk(fs.as_ref())
 353                .await
 354                .log_err()
 355                .unwrap_or_default();
 356            this.update_in(cx, move |this, window, cx| {
 357                this.picker.update(cx, move |picker, cx| {
 358                    picker.delegate.set_workspaces(workspaces);
 359                    picker.update_matches(picker.query(cx), window, cx)
 360                })
 361            })
 362            .ok();
 363        })
 364        .detach();
 365        Self {
 366            picker,
 367            rem_width,
 368            _subscription,
 369        }
 370    }
 371
 372    pub fn open(
 373        workspace: &mut Workspace,
 374        create_new_window: bool,
 375        window: &mut Window,
 376        focus_handle: FocusHandle,
 377        cx: &mut Context<Workspace>,
 378    ) {
 379        let weak = cx.entity().downgrade();
 380        let fs = Some(workspace.app_state().fs.clone());
 381        workspace.toggle_modal(window, cx, |window, cx| {
 382            let delegate = RecentProjectsDelegate::new(weak, create_new_window, true, focus_handle);
 383
 384            Self::new(delegate, fs, 34., window, cx)
 385        })
 386    }
 387
 388    pub fn popover(
 389        workspace: WeakEntity<Workspace>,
 390        create_new_window: bool,
 391        focus_handle: FocusHandle,
 392        window: &mut Window,
 393        cx: &mut App,
 394    ) -> Entity<Self> {
 395        let fs = workspace
 396            .upgrade()
 397            .map(|ws| ws.read(cx).app_state().fs.clone());
 398        cx.new(|cx| {
 399            let delegate =
 400                RecentProjectsDelegate::new(workspace, create_new_window, true, focus_handle);
 401            let list = Self::new(delegate, fs, 34., window, cx);
 402            list.picker.focus_handle(cx).focus(window, cx);
 403            list
 404        })
 405    }
 406}
 407
 408impl EventEmitter<DismissEvent> for RecentProjects {}
 409
 410impl Focusable for RecentProjects {
 411    fn focus_handle(&self, cx: &App) -> FocusHandle {
 412        self.picker.focus_handle(cx)
 413    }
 414}
 415
 416impl Render for RecentProjects {
 417    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 418        v_flex()
 419            .key_context("RecentProjects")
 420            .w(rems(self.rem_width))
 421            .child(self.picker.clone())
 422            .on_mouse_down_out(cx.listener(|this, _, window, cx| {
 423                this.picker.update(cx, |this, cx| {
 424                    this.cancel(&Default::default(), window, cx);
 425                })
 426            }))
 427    }
 428}
 429
 430pub struct RecentProjectsDelegate {
 431    workspace: WeakEntity<Workspace>,
 432    workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
 433    selected_match_index: usize,
 434    matches: Vec<StringMatch>,
 435    render_paths: bool,
 436    create_new_window: bool,
 437    // Flag to reset index when there is a new query vs not reset index when user delete an item
 438    reset_selected_match_index: bool,
 439    has_any_non_local_projects: bool,
 440    focus_handle: FocusHandle,
 441}
 442
 443impl RecentProjectsDelegate {
 444    fn new(
 445        workspace: WeakEntity<Workspace>,
 446        create_new_window: bool,
 447        render_paths: bool,
 448        focus_handle: FocusHandle,
 449    ) -> Self {
 450        Self {
 451            workspace,
 452            workspaces: Vec::new(),
 453            selected_match_index: 0,
 454            matches: Default::default(),
 455            create_new_window,
 456            render_paths,
 457            reset_selected_match_index: true,
 458            has_any_non_local_projects: false,
 459            focus_handle,
 460        }
 461    }
 462
 463    pub fn set_workspaces(
 464        &mut self,
 465        workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>,
 466    ) {
 467        self.workspaces = workspaces;
 468        self.has_any_non_local_projects = !self
 469            .workspaces
 470            .iter()
 471            .all(|(_, location, _)| matches!(location, SerializedWorkspaceLocation::Local));
 472    }
 473}
 474impl EventEmitter<DismissEvent> for RecentProjectsDelegate {}
 475impl PickerDelegate for RecentProjectsDelegate {
 476    type ListItem = ListItem;
 477
 478    fn placeholder_text(&self, window: &mut Window, _: &mut App) -> Arc<str> {
 479        let (create_window, reuse_window) = if self.create_new_window {
 480            (
 481                window.keystroke_text_for(&menu::Confirm),
 482                window.keystroke_text_for(&menu::SecondaryConfirm),
 483            )
 484        } else {
 485            (
 486                window.keystroke_text_for(&menu::SecondaryConfirm),
 487                window.keystroke_text_for(&menu::Confirm),
 488            )
 489        };
 490        Arc::from(format!(
 491            "{reuse_window} reuses this window, {create_window} opens a new one",
 492        ))
 493    }
 494
 495    fn match_count(&self) -> usize {
 496        self.matches.len()
 497    }
 498
 499    fn selected_index(&self) -> usize {
 500        self.selected_match_index
 501    }
 502
 503    fn set_selected_index(
 504        &mut self,
 505        ix: usize,
 506        _window: &mut Window,
 507        _cx: &mut Context<Picker<Self>>,
 508    ) {
 509        self.selected_match_index = ix;
 510    }
 511
 512    fn update_matches(
 513        &mut self,
 514        query: String,
 515        _: &mut Window,
 516        cx: &mut Context<Picker<Self>>,
 517    ) -> gpui::Task<()> {
 518        let query = query.trim_start();
 519        let smart_case = query.chars().any(|c| c.is_uppercase());
 520        let candidates = self
 521            .workspaces
 522            .iter()
 523            .enumerate()
 524            .filter(|(_, (id, _, _))| !self.is_current_workspace(*id, cx))
 525            .map(|(id, (_, _, paths))| {
 526                let combined_string = paths
 527                    .ordered_paths()
 528                    .map(|path| path.compact().to_string_lossy().into_owned())
 529                    .collect::<Vec<_>>()
 530                    .join("");
 531                StringMatchCandidate::new(id, &combined_string)
 532            })
 533            .collect::<Vec<_>>();
 534        self.matches = smol::block_on(fuzzy::match_strings(
 535            candidates.as_slice(),
 536            query,
 537            smart_case,
 538            true,
 539            100,
 540            &Default::default(),
 541            cx.background_executor().clone(),
 542        ));
 543        self.matches.sort_unstable_by(|a, b| {
 544            b.score
 545                .partial_cmp(&a.score) // Descending score
 546                .unwrap_or(std::cmp::Ordering::Equal)
 547                .then_with(|| a.candidate_id.cmp(&b.candidate_id)) // Ascending candidate_id for ties
 548        });
 549
 550        if self.reset_selected_match_index {
 551            self.selected_match_index = self
 552                .matches
 553                .iter()
 554                .enumerate()
 555                .rev()
 556                .max_by_key(|(_, m)| OrderedFloat(m.score))
 557                .map(|(ix, _)| ix)
 558                .unwrap_or(0);
 559        }
 560        self.reset_selected_match_index = true;
 561        Task::ready(())
 562    }
 563
 564    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 565        if let Some((selected_match, workspace)) = self
 566            .matches
 567            .get(self.selected_index())
 568            .zip(self.workspace.upgrade())
 569        {
 570            let (candidate_workspace_id, candidate_workspace_location, candidate_workspace_paths) =
 571                &self.workspaces[selected_match.candidate_id];
 572            let replace_current_window = if self.create_new_window {
 573                secondary
 574            } else {
 575                !secondary
 576            };
 577            workspace.update(cx, |workspace, cx| {
 578                if workspace.database_id() == Some(*candidate_workspace_id) {
 579                    return;
 580                }
 581                match candidate_workspace_location.clone() {
 582                    SerializedWorkspaceLocation::Local => {
 583                        let paths = candidate_workspace_paths.paths().to_vec();
 584                        if replace_current_window {
 585                            if let Some(handle) =
 586                                window.window_handle().downcast::<MultiWorkspace>()
 587                            {
 588                                cx.defer(move |cx| {
 589                                    if let Some(task) = handle
 590                                        .update(cx, |multi_workspace, window, cx| {
 591                                            multi_workspace.open_project(paths, window, cx)
 592                                        })
 593                                        .log_err()
 594                                    {
 595                                        task.detach_and_log_err(cx);
 596                                    }
 597                                });
 598                            }
 599                            return;
 600                        } else {
 601                            workspace.open_workspace_for_paths(false, paths, window, cx)
 602                        }
 603                    }
 604                    SerializedWorkspaceLocation::Remote(mut connection) => {
 605                        let app_state = workspace.app_state().clone();
 606
 607                        let replace_window = if replace_current_window {
 608                            window.window_handle().downcast::<MultiWorkspace>()
 609                        } else {
 610                            None
 611                        };
 612
 613                        let open_options = OpenOptions {
 614                            replace_window,
 615                            ..Default::default()
 616                        };
 617
 618                        if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
 619                            RemoteSettings::get_global(cx)
 620                                .fill_connection_options_from_settings(connection);
 621                        };
 622
 623                        let paths = candidate_workspace_paths.paths().to_vec();
 624
 625                        cx.spawn_in(window, async move |_, cx| {
 626                            open_remote_project(
 627                                connection.clone(),
 628                                paths,
 629                                app_state,
 630                                open_options,
 631                                cx,
 632                            )
 633                            .await
 634                        })
 635                    }
 636                }
 637                .detach_and_prompt_err(
 638                    "Failed to open project",
 639                    window,
 640                    cx,
 641                    |_, _, _| None,
 642                );
 643            });
 644            cx.emit(DismissEvent);
 645        }
 646    }
 647
 648    fn dismissed(&mut self, _window: &mut Window, _: &mut Context<Picker<Self>>) {}
 649
 650    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
 651        let text = if self.workspaces.is_empty() {
 652            "Recently opened projects will show up here".into()
 653        } else {
 654            "No matches".into()
 655        };
 656        Some(text)
 657    }
 658
 659    fn render_match(
 660        &self,
 661        ix: usize,
 662        selected: bool,
 663        window: &mut Window,
 664        cx: &mut Context<Picker<Self>>,
 665    ) -> Option<Self::ListItem> {
 666        let hit = self.matches.get(ix)?;
 667
 668        let (_, location, paths) = self.workspaces.get(hit.candidate_id)?;
 669
 670        let mut path_start_offset = 0;
 671
 672        let (match_labels, paths): (Vec<_>, Vec<_>) = paths
 673            .ordered_paths()
 674            .map(|p| p.compact())
 675            .map(|path| {
 676                let highlighted_text =
 677                    highlights_for_path(path.as_ref(), &hit.positions, path_start_offset);
 678                path_start_offset += highlighted_text.1.text.len();
 679                highlighted_text
 680            })
 681            .unzip();
 682
 683        let prefix = match &location {
 684            SerializedWorkspaceLocation::Remote(options) => {
 685                Some(SharedString::from(options.display_name()))
 686            }
 687            _ => None,
 688        };
 689
 690        let highlighted_match = HighlightedMatchWithPaths {
 691            prefix,
 692            match_label: HighlightedMatch::join(match_labels.into_iter().flatten(), ", "),
 693            paths,
 694        };
 695
 696        let focus_handle = self.focus_handle.clone();
 697
 698        let secondary_actions = h_flex()
 699            .gap_px()
 700            .child(
 701                IconButton::new("open_new_window", IconName::ArrowUpRight)
 702                    .icon_size(IconSize::XSmall)
 703                    .tooltip({
 704                        move |_, cx| {
 705                            Tooltip::for_action_in(
 706                                "Open Project in New Window",
 707                                &menu::SecondaryConfirm,
 708                                &focus_handle,
 709                                cx,
 710                            )
 711                        }
 712                    })
 713                    .on_click(cx.listener(move |this, _event, window, cx| {
 714                        cx.stop_propagation();
 715                        window.prevent_default();
 716                        this.delegate.set_selected_index(ix, window, cx);
 717                        this.delegate.confirm(true, window, cx);
 718                    })),
 719            )
 720            .child(
 721                IconButton::new("delete", IconName::Close)
 722                    .icon_size(IconSize::Small)
 723                    .tooltip(Tooltip::text("Delete from Recent Projects"))
 724                    .on_click(cx.listener(move |this, _event, window, cx| {
 725                        cx.stop_propagation();
 726                        window.prevent_default();
 727
 728                        this.delegate.delete_recent_project(ix, window, cx)
 729                    })),
 730            )
 731            .into_any_element();
 732
 733        Some(
 734            ListItem::new(ix)
 735                .toggle_state(selected)
 736                .inset(true)
 737                .spacing(ListItemSpacing::Sparse)
 738                .child(
 739                    h_flex()
 740                        .id("projecy_info_container")
 741                        .gap_3()
 742                        .flex_grow()
 743                        .when(self.has_any_non_local_projects, |this| {
 744                            this.child(match location {
 745                                SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
 746                                    .color(Color::Muted)
 747                                    .into_any_element(),
 748                                SerializedWorkspaceLocation::Remote(options) => {
 749                                    Icon::new(match options {
 750                                        RemoteConnectionOptions::Ssh { .. } => IconName::Server,
 751                                        RemoteConnectionOptions::Wsl { .. } => IconName::Linux,
 752                                        RemoteConnectionOptions::Docker(_) => IconName::Box,
 753                                        #[cfg(any(test, feature = "test-support"))]
 754                                        RemoteConnectionOptions::Mock(_) => IconName::Server,
 755                                    })
 756                                    .color(Color::Muted)
 757                                    .into_any_element()
 758                                }
 759                            })
 760                        })
 761                        .child({
 762                            let mut highlighted = highlighted_match.clone();
 763                            if !self.render_paths {
 764                                highlighted.paths.clear();
 765                            }
 766                            highlighted.render(window, cx)
 767                        })
 768                        .tooltip(move |_, cx| {
 769                            let tooltip_highlighted_location = highlighted_match.clone();
 770                            cx.new(|_| MatchTooltip {
 771                                highlighted_location: tooltip_highlighted_location,
 772                            })
 773                            .into()
 774                        }),
 775                )
 776                .map(|el| {
 777                    if self.selected_index() == ix {
 778                        el.end_slot(secondary_actions)
 779                    } else {
 780                        el.end_hover_slot(secondary_actions)
 781                    }
 782                }),
 783        )
 784    }
 785
 786    fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
 787        Some(
 788            h_flex()
 789                .w_full()
 790                .p_2()
 791                .gap_2()
 792                .justify_end()
 793                .border_t_1()
 794                .border_color(cx.theme().colors().border_variant)
 795                .child(
 796                    Button::new("remote", "Open Remote Folder")
 797                        .key_binding(KeyBinding::for_action(
 798                            &OpenRemote {
 799                                from_existing_connection: false,
 800                                create_new_window: false,
 801                            },
 802                            cx,
 803                        ))
 804                        .on_click(|_, window, cx| {
 805                            window.dispatch_action(
 806                                OpenRemote {
 807                                    from_existing_connection: false,
 808                                    create_new_window: false,
 809                                }
 810                                .boxed_clone(),
 811                                cx,
 812                            )
 813                        }),
 814                )
 815                .child(
 816                    Button::new("local", "Open Local Folder")
 817                        .key_binding(KeyBinding::for_action(&workspace::Open, cx))
 818                        .on_click(|_, window, cx| {
 819                            window.dispatch_action(workspace::Open.boxed_clone(), cx)
 820                        }),
 821                )
 822                .into_any(),
 823        )
 824    }
 825}
 826
 827// Compute the highlighted text for the name and path
 828fn highlights_for_path(
 829    path: &Path,
 830    match_positions: &Vec<usize>,
 831    path_start_offset: usize,
 832) -> (Option<HighlightedMatch>, HighlightedMatch) {
 833    let path_string = path.to_string_lossy();
 834    let path_text = path_string.to_string();
 835    let path_byte_len = path_text.len();
 836    // Get the subset of match highlight positions that line up with the given path.
 837    // Also adjusts them to start at the path start
 838    let path_positions = match_positions
 839        .iter()
 840        .copied()
 841        .skip_while(|position| *position < path_start_offset)
 842        .take_while(|position| *position < path_start_offset + path_byte_len)
 843        .map(|position| position - path_start_offset)
 844        .collect::<Vec<_>>();
 845
 846    // Again subset the highlight positions to just those that line up with the file_name
 847    // again adjusted to the start of the file_name
 848    let file_name_text_and_positions = path.file_name().map(|file_name| {
 849        let file_name_text = file_name.to_string_lossy().into_owned();
 850        let file_name_start_byte = path_byte_len - file_name_text.len();
 851        let highlight_positions = path_positions
 852            .iter()
 853            .copied()
 854            .skip_while(|position| *position < file_name_start_byte)
 855            .take_while(|position| *position < file_name_start_byte + file_name_text.len())
 856            .map(|position| position - file_name_start_byte)
 857            .collect::<Vec<_>>();
 858        HighlightedMatch {
 859            text: file_name_text,
 860            highlight_positions,
 861            color: Color::Default,
 862        }
 863    });
 864
 865    (
 866        file_name_text_and_positions,
 867        HighlightedMatch {
 868            text: path_text,
 869            highlight_positions: path_positions,
 870            color: Color::Default,
 871        },
 872    )
 873}
 874impl RecentProjectsDelegate {
 875    fn delete_recent_project(
 876        &self,
 877        ix: usize,
 878        window: &mut Window,
 879        cx: &mut Context<Picker<Self>>,
 880    ) {
 881        if let Some(selected_match) = self.matches.get(ix) {
 882            let (workspace_id, _, _) = self.workspaces[selected_match.candidate_id];
 883            let fs = self
 884                .workspace
 885                .upgrade()
 886                .map(|ws| ws.read(cx).app_state().fs.clone());
 887            cx.spawn_in(window, async move |this, cx| {
 888                WORKSPACE_DB
 889                    .delete_workspace_by_id(workspace_id)
 890                    .await
 891                    .log_err();
 892                let Some(fs) = fs else { return };
 893                let workspaces = WORKSPACE_DB
 894                    .recent_workspaces_on_disk(fs.as_ref())
 895                    .await
 896                    .unwrap_or_default();
 897                this.update_in(cx, move |picker, window, cx| {
 898                    picker.delegate.set_workspaces(workspaces);
 899                    picker
 900                        .delegate
 901                        .set_selected_index(ix.saturating_sub(1), window, cx);
 902                    picker.delegate.reset_selected_match_index = false;
 903                    picker.update_matches(picker.query(cx), window, cx);
 904                    // After deleting a project, we want to update the history manager to reflect the change.
 905                    // But we do not emit a update event when user opens a project, because it's handled in `workspace::load_workspace`.
 906                    if let Some(history_manager) = HistoryManager::global(cx) {
 907                        history_manager
 908                            .update(cx, |this, cx| this.delete_history(workspace_id, cx));
 909                    }
 910                })
 911                .ok();
 912            })
 913            .detach();
 914        }
 915    }
 916
 917    fn is_current_workspace(
 918        &self,
 919        workspace_id: WorkspaceId,
 920        cx: &mut Context<Picker<Self>>,
 921    ) -> bool {
 922        if let Some(workspace) = self.workspace.upgrade() {
 923            let workspace = workspace.read(cx);
 924            if Some(workspace_id) == workspace.database_id() {
 925                return true;
 926            }
 927        }
 928
 929        false
 930    }
 931}
 932struct MatchTooltip {
 933    highlighted_location: HighlightedMatchWithPaths,
 934}
 935
 936impl Render for MatchTooltip {
 937    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 938        tooltip_container(cx, |div, _| {
 939            self.highlighted_location.render_paths_children(div)
 940        })
 941    }
 942}
 943
 944#[cfg(test)]
 945mod tests {
 946    use std::path::PathBuf;
 947
 948    use editor::Editor;
 949    use gpui::{TestAppContext, UpdateGlobal, WindowHandle};
 950
 951    use serde_json::json;
 952    use settings::SettingsStore;
 953    use util::path;
 954    use workspace::{AppState, open_paths};
 955
 956    use super::*;
 957
 958    #[gpui::test]
 959    async fn test_dirty_workspace_survives_when_opening_recent_project(cx: &mut TestAppContext) {
 960        let app_state = init_test(cx);
 961
 962        cx.update(|cx| {
 963            SettingsStore::update_global(cx, |store, cx| {
 964                store.update_user_settings(cx, |settings| {
 965                    settings
 966                        .session
 967                        .get_or_insert_default()
 968                        .restore_unsaved_buffers = Some(false)
 969                });
 970            });
 971        });
 972
 973        app_state
 974            .fs
 975            .as_fake()
 976            .insert_tree(
 977                path!("/dir"),
 978                json!({
 979                    "main.ts": "a"
 980                }),
 981            )
 982            .await;
 983        app_state
 984            .fs
 985            .as_fake()
 986            .insert_tree(path!("/test/path"), json!({}))
 987            .await;
 988        cx.update(|cx| {
 989            open_paths(
 990                &[PathBuf::from(path!("/dir/main.ts"))],
 991                app_state,
 992                workspace::OpenOptions::default(),
 993                cx,
 994            )
 995        })
 996        .await
 997        .unwrap();
 998        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 999
1000        let multi_workspace = cx.update(|cx| cx.windows()[0].downcast::<MultiWorkspace>().unwrap());
1001        multi_workspace
1002            .update(cx, |multi_workspace, _, cx| {
1003                assert!(!multi_workspace.workspace().read(cx).is_edited())
1004            })
1005            .unwrap();
1006
1007        let editor = multi_workspace
1008            .read_with(cx, |multi_workspace, cx| {
1009                multi_workspace
1010                    .workspace()
1011                    .read(cx)
1012                    .active_item(cx)
1013                    .unwrap()
1014                    .downcast::<Editor>()
1015                    .unwrap()
1016            })
1017            .unwrap();
1018        multi_workspace
1019            .update(cx, |_, window, cx| {
1020                editor.update(cx, |editor, cx| editor.insert("EDIT", window, cx));
1021            })
1022            .unwrap();
1023        multi_workspace
1024            .update(cx, |multi_workspace, _, cx| {
1025                assert!(
1026                    multi_workspace.workspace().read(cx).is_edited(),
1027                    "After inserting more text into the editor without saving, we should have a dirty project"
1028                )
1029            })
1030            .unwrap();
1031
1032        let recent_projects_picker = open_recent_projects(&multi_workspace, cx);
1033        multi_workspace
1034            .update(cx, |_, _, cx| {
1035                recent_projects_picker.update(cx, |picker, cx| {
1036                    assert_eq!(picker.query(cx), "");
1037                    let delegate = &mut picker.delegate;
1038                    delegate.matches = vec![StringMatch {
1039                        candidate_id: 0,
1040                        score: 1.0,
1041                        positions: Vec::new(),
1042                        string: "fake candidate".to_string(),
1043                    }];
1044                    delegate.set_workspaces(vec![(
1045                        WorkspaceId::default(),
1046                        SerializedWorkspaceLocation::Local,
1047                        PathList::new(&[path!("/test/path")]),
1048                    )]);
1049                });
1050            })
1051            .unwrap();
1052
1053        assert!(
1054            !cx.has_pending_prompt(),
1055            "Should have no pending prompt on dirty project before opening the new recent project"
1056        );
1057        let dirty_workspace = multi_workspace
1058            .read_with(cx, |multi_workspace, _cx| {
1059                multi_workspace.workspace().clone()
1060            })
1061            .unwrap();
1062
1063        cx.dispatch_action(*multi_workspace, menu::Confirm);
1064        cx.run_until_parked();
1065
1066        multi_workspace
1067            .update(cx, |multi_workspace, _, cx| {
1068                assert!(
1069                    multi_workspace
1070                        .workspace()
1071                        .read(cx)
1072                        .active_modal::<RecentProjects>(cx)
1073                        .is_none(),
1074                    "Should remove the modal after selecting new recent project"
1075                );
1076
1077                assert!(
1078                    multi_workspace.workspaces().len() >= 2,
1079                    "Should have at least 2 workspaces: the dirty one and the newly opened one"
1080                );
1081
1082                assert!(
1083                    multi_workspace.workspaces().contains(&dirty_workspace),
1084                    "The original dirty workspace should still be present"
1085                );
1086
1087                assert!(
1088                    dirty_workspace.read(cx).is_edited(),
1089                    "The original workspace should still be dirty"
1090                );
1091            })
1092            .unwrap();
1093
1094        assert!(
1095            !cx.has_pending_prompt(),
1096            "No save prompt in multi-workspace mode — dirty workspace survives in background"
1097        );
1098    }
1099
1100    fn open_recent_projects(
1101        multi_workspace: &WindowHandle<MultiWorkspace>,
1102        cx: &mut TestAppContext,
1103    ) -> Entity<Picker<RecentProjectsDelegate>> {
1104        cx.dispatch_action(
1105            (*multi_workspace).into(),
1106            OpenRecent {
1107                create_new_window: false,
1108            },
1109        );
1110        multi_workspace
1111            .update(cx, |multi_workspace, _, cx| {
1112                multi_workspace
1113                    .workspace()
1114                    .read(cx)
1115                    .active_modal::<RecentProjects>(cx)
1116                    .unwrap()
1117                    .read(cx)
1118                    .picker
1119                    .clone()
1120            })
1121            .unwrap()
1122    }
1123
1124    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1125        cx.update(|cx| {
1126            let state = AppState::test(cx);
1127            crate::init(cx);
1128            editor::init(cx);
1129            state
1130        })
1131    }
1132}