recent_projects.rs

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