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