recent_projects.rs

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