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