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