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