recent_projects.rs

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