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