remote_servers.rs

   1use crate::{
   2    remote_connections::{
   3        Connection, RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettings, SshConnection,
   4        SshConnectionHeader, connect, determine_paths_with_positions, open_remote_project,
   5    },
   6    ssh_config::parse_ssh_config_hosts,
   7};
   8use dev_container::{
   9    DevContainerConfig, DevContainerContext, find_devcontainer_configs,
  10    start_dev_container_with_config,
  11};
  12use editor::Editor;
  13
  14use futures::{FutureExt, channel::oneshot, future::Shared};
  15use gpui::{
  16    Action, AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity,
  17    EventEmitter, FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task,
  18    WeakEntity, Window, canvas,
  19};
  20use language::Point;
  21use log::{debug, info};
  22use open_path_prompt::OpenPathDelegate;
  23use paths::{global_ssh_config_file, user_ssh_config_file};
  24use picker::{Picker, PickerDelegate};
  25use project::{Fs, Project};
  26use remote::{
  27    RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
  28    remote_client::ConnectionIdentifier,
  29};
  30use settings::{
  31    RemoteProject, RemoteSettingsContent, Settings as _, SettingsStore, update_settings_file,
  32    watch_config_file,
  33};
  34use smol::stream::StreamExt as _;
  35use std::{
  36    borrow::Cow,
  37    collections::BTreeSet,
  38    path::PathBuf,
  39    rc::Rc,
  40    sync::{
  41        Arc,
  42        atomic::{self, AtomicUsize},
  43    },
  44};
  45use ui::{
  46    CommonAnimationExt, IconButtonShape, KeyBinding, List, ListItem, ListSeparator, Modal,
  47    ModalFooter, ModalHeader, Navigable, NavigableEntry, Section, Tooltip, WithScrollbar,
  48    prelude::*,
  49};
  50use util::{
  51    ResultExt,
  52    paths::{PathStyle, RemotePathBuf},
  53    rel_path::RelPath,
  54};
  55use workspace::{
  56    ModalView, MultiWorkspace, OpenLog, OpenOptions, Toast, Workspace,
  57    notifications::{DetachAndPromptErr, NotificationId},
  58    open_remote_project_with_existing_connection,
  59};
  60
  61pub struct RemoteServerProjects {
  62    mode: Mode,
  63    focus_handle: FocusHandle,
  64    workspace: WeakEntity<Workspace>,
  65    retained_connections: Vec<Entity<RemoteClient>>,
  66    ssh_config_updates: Task<()>,
  67    ssh_config_servers: BTreeSet<SharedString>,
  68    create_new_window: bool,
  69    dev_container_picker: Option<Entity<Picker<DevContainerPickerDelegate>>>,
  70    _subscription: Subscription,
  71}
  72
  73struct CreateRemoteServer {
  74    address_editor: Entity<Editor>,
  75    address_error: Option<SharedString>,
  76    ssh_prompt: Option<Entity<RemoteConnectionPrompt>>,
  77    _creating: Option<Task<Option<()>>>,
  78}
  79
  80impl CreateRemoteServer {
  81    fn new(window: &mut Window, cx: &mut App) -> Self {
  82        let address_editor = cx.new(|cx| Editor::single_line(window, cx));
  83        address_editor.update(cx, |this, cx| {
  84            this.focus_handle(cx).focus(window, cx);
  85        });
  86        Self {
  87            address_editor,
  88            address_error: None,
  89            ssh_prompt: None,
  90            _creating: None,
  91        }
  92    }
  93}
  94
  95#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
  96enum DevContainerCreationProgress {
  97    SelectingConfig,
  98    Creating,
  99    Error(String),
 100}
 101
 102#[derive(Clone)]
 103struct CreateRemoteDevContainer {
 104    view_logs_entry: NavigableEntry,
 105    back_entry: NavigableEntry,
 106    progress: DevContainerCreationProgress,
 107}
 108
 109impl CreateRemoteDevContainer {
 110    fn new(progress: DevContainerCreationProgress, cx: &mut Context<RemoteServerProjects>) -> Self {
 111        let view_logs_entry = NavigableEntry::focusable(cx);
 112        let back_entry = NavigableEntry::focusable(cx);
 113        Self {
 114            view_logs_entry,
 115            back_entry,
 116            progress,
 117        }
 118    }
 119}
 120
 121#[cfg(target_os = "windows")]
 122struct AddWslDistro {
 123    picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
 124    connection_prompt: Option<Entity<RemoteConnectionPrompt>>,
 125    _creating: Option<Task<()>>,
 126}
 127
 128#[cfg(target_os = "windows")]
 129impl AddWslDistro {
 130    fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
 131        use crate::wsl_picker::{WslDistroSelected, WslPickerDelegate, WslPickerDismissed};
 132
 133        let delegate = WslPickerDelegate::new();
 134        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
 135
 136        cx.subscribe_in(
 137            &picker,
 138            window,
 139            |this, _, _: &WslDistroSelected, window, cx| {
 140                this.confirm(&menu::Confirm, window, cx);
 141            },
 142        )
 143        .detach();
 144
 145        cx.subscribe_in(
 146            &picker,
 147            window,
 148            |this, _, _: &WslPickerDismissed, window, cx| {
 149                this.cancel(&menu::Cancel, window, cx);
 150            },
 151        )
 152        .detach();
 153
 154        AddWslDistro {
 155            picker,
 156            connection_prompt: None,
 157            _creating: None,
 158        }
 159    }
 160}
 161
 162enum ProjectPickerData {
 163    Ssh {
 164        connection_string: SharedString,
 165        nickname: Option<SharedString>,
 166    },
 167    Wsl {
 168        distro_name: SharedString,
 169    },
 170}
 171
 172struct ProjectPicker {
 173    data: ProjectPickerData,
 174    picker: Entity<Picker<OpenPathDelegate>>,
 175    _path_task: Shared<Task<Option<()>>>,
 176}
 177
 178struct EditNicknameState {
 179    index: SshServerIndex,
 180    editor: Entity<Editor>,
 181}
 182
 183struct DevContainerPickerDelegate {
 184    selected_index: usize,
 185    candidates: Vec<DevContainerConfig>,
 186    matching_candidates: Vec<DevContainerConfig>,
 187    parent_modal: WeakEntity<RemoteServerProjects>,
 188}
 189impl DevContainerPickerDelegate {
 190    fn new(
 191        candidates: Vec<DevContainerConfig>,
 192        parent_modal: WeakEntity<RemoteServerProjects>,
 193    ) -> Self {
 194        Self {
 195            selected_index: 0,
 196            matching_candidates: candidates.clone(),
 197            candidates,
 198            parent_modal,
 199        }
 200    }
 201}
 202
 203impl PickerDelegate for DevContainerPickerDelegate {
 204    type ListItem = AnyElement;
 205
 206    fn match_count(&self) -> usize {
 207        self.matching_candidates.len()
 208    }
 209
 210    fn selected_index(&self) -> usize {
 211        self.selected_index
 212    }
 213
 214    fn set_selected_index(
 215        &mut self,
 216        ix: usize,
 217        _window: &mut Window,
 218        _cx: &mut Context<Picker<Self>>,
 219    ) {
 220        self.selected_index = ix;
 221    }
 222
 223    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 224        "Select Dev Container Configuration".into()
 225    }
 226
 227    fn update_matches(
 228        &mut self,
 229        query: String,
 230        _window: &mut Window,
 231        _cx: &mut Context<Picker<Self>>,
 232    ) -> Task<()> {
 233        let query_lower = query.to_lowercase();
 234        self.matching_candidates = self
 235            .candidates
 236            .iter()
 237            .filter(|c| {
 238                c.name.to_lowercase().contains(&query_lower)
 239                    || c.config_path
 240                        .to_string_lossy()
 241                        .to_lowercase()
 242                        .contains(&query_lower)
 243            })
 244            .cloned()
 245            .collect();
 246
 247        self.selected_index = std::cmp::min(
 248            self.selected_index,
 249            self.matching_candidates.len().saturating_sub(1),
 250        );
 251
 252        Task::ready(())
 253    }
 254
 255    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 256        let selected_config = self.matching_candidates.get(self.selected_index).cloned();
 257        self.parent_modal
 258            .update(cx, move |modal, cx| {
 259                if secondary {
 260                    modal.edit_in_dev_container_json(selected_config.clone(), window, cx);
 261                } else {
 262                    modal.open_dev_container(selected_config, window, cx);
 263                    modal.view_in_progress_dev_container(window, cx);
 264                }
 265            })
 266            .ok();
 267    }
 268
 269    fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 270        self.parent_modal
 271            .update(cx, |modal, cx| {
 272                modal.cancel(&menu::Cancel, window, cx);
 273            })
 274            .ok();
 275    }
 276
 277    fn render_match(
 278        &self,
 279        ix: usize,
 280        selected: bool,
 281        _window: &mut Window,
 282        _cx: &mut Context<Picker<Self>>,
 283    ) -> Option<Self::ListItem> {
 284        let candidate = self.matching_candidates.get(ix)?;
 285        let config_path = candidate.config_path.display().to_string();
 286        Some(
 287            ListItem::new(SharedString::from(format!("li-devcontainer-config-{}", ix)))
 288                .inset(true)
 289                .spacing(ui::ListItemSpacing::Sparse)
 290                .toggle_state(selected)
 291                .start_slot(Icon::new(IconName::FileToml).color(Color::Muted))
 292                .child(
 293                    v_flex().child(Label::new(candidate.name.clone())).child(
 294                        Label::new(config_path)
 295                            .size(ui::LabelSize::Small)
 296                            .color(Color::Muted),
 297                    ),
 298                )
 299                .into_any_element(),
 300        )
 301    }
 302
 303    fn render_footer(
 304        &self,
 305        _window: &mut Window,
 306        cx: &mut Context<Picker<Self>>,
 307    ) -> Option<AnyElement> {
 308        Some(
 309            h_flex()
 310                .w_full()
 311                .p_1p5()
 312                .gap_1()
 313                .justify_start()
 314                .border_t_1()
 315                .border_color(cx.theme().colors().border_variant)
 316                .child(
 317                    Button::new("run-action", "Start Dev Container")
 318                        .key_binding(
 319                            KeyBinding::for_action(&menu::Confirm, cx)
 320                                .map(|kb| kb.size(rems_from_px(12.))),
 321                        )
 322                        .on_click(|_, window, cx| {
 323                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
 324                        }),
 325                )
 326                .child(
 327                    Button::new("run-action-secondary", "Open devcontainer.json")
 328                        .key_binding(
 329                            KeyBinding::for_action(&menu::SecondaryConfirm, cx)
 330                                .map(|kb| kb.size(rems_from_px(12.))),
 331                        )
 332                        .on_click(|_, window, cx| {
 333                            window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
 334                        }),
 335                )
 336                .into_any_element(),
 337        )
 338    }
 339}
 340
 341impl EditNicknameState {
 342    fn new(index: SshServerIndex, window: &mut Window, cx: &mut App) -> Self {
 343        let this = Self {
 344            index,
 345            editor: cx.new(|cx| Editor::single_line(window, cx)),
 346        };
 347        let starting_text = RemoteSettings::get_global(cx)
 348            .ssh_connections()
 349            .nth(index.0)
 350            .and_then(|state| state.nickname)
 351            .filter(|text| !text.is_empty());
 352        this.editor.update(cx, |this, cx| {
 353            this.set_placeholder_text("Add a nickname for this server", window, cx);
 354            if let Some(starting_text) = starting_text {
 355                this.set_text(starting_text, window, cx);
 356            }
 357        });
 358        this.editor.focus_handle(cx).focus(window, cx);
 359        this
 360    }
 361}
 362
 363impl Focusable for ProjectPicker {
 364    fn focus_handle(&self, cx: &App) -> FocusHandle {
 365        self.picker.focus_handle(cx)
 366    }
 367}
 368
 369impl ProjectPicker {
 370    fn new(
 371        create_new_window: bool,
 372        index: ServerIndex,
 373        connection: RemoteConnectionOptions,
 374        project: Entity<Project>,
 375        home_dir: RemotePathBuf,
 376        workspace: WeakEntity<Workspace>,
 377        window: &mut Window,
 378        cx: &mut Context<RemoteServerProjects>,
 379    ) -> Entity<Self> {
 380        let (tx, rx) = oneshot::channel();
 381        let lister = project::DirectoryLister::Project(project.clone());
 382        let delegate = open_path_prompt::OpenPathDelegate::new(tx, lister, false, cx);
 383
 384        let picker = cx.new(|cx| {
 385            let picker = Picker::uniform_list(delegate, window, cx)
 386                .width(rems(34.))
 387                .modal(false);
 388            picker.set_query(&home_dir.to_string(), window, cx);
 389            picker
 390        });
 391
 392        let data = match &connection {
 393            RemoteConnectionOptions::Ssh(connection) => ProjectPickerData::Ssh {
 394                connection_string: connection.connection_string().into(),
 395                nickname: connection.nickname.clone().map(|nick| nick.into()),
 396            },
 397            RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
 398                distro_name: connection.distro_name.clone().into(),
 399            },
 400            RemoteConnectionOptions::Docker(_) => ProjectPickerData::Ssh {
 401                // Not implemented as a project picker at this time
 402                connection_string: "".into(),
 403                nickname: None,
 404            },
 405            #[cfg(any(test, feature = "test-support"))]
 406            RemoteConnectionOptions::Mock(options) => ProjectPickerData::Ssh {
 407                connection_string: format!("mock-{}", options.id).into(),
 408                nickname: None,
 409            },
 410        };
 411        let _path_task = cx
 412            .spawn_in(window, {
 413                let workspace = workspace;
 414                async move |this, cx| {
 415                    let Ok(Some(paths)) = rx.await else {
 416                        workspace
 417                            .update_in(cx, |workspace, window, cx| {
 418                                let fs = workspace.project().read(cx).fs().clone();
 419                                let weak = cx.entity().downgrade();
 420                                workspace.toggle_modal(window, cx, |window, cx| {
 421                                    RemoteServerProjects::new(
 422                                        create_new_window,
 423                                        fs,
 424                                        window,
 425                                        weak,
 426                                        cx,
 427                                    )
 428                                });
 429                            })
 430                            .log_err()?;
 431                        return None;
 432                    };
 433
 434                    let app_state = workspace
 435                        .read_with(cx, |workspace, _| workspace.app_state().clone())
 436                        .ok()?;
 437
 438                    let remote_connection = project.read_with(cx, |project, cx| {
 439                        project.remote_client()?.read(cx).connection()
 440                    })?;
 441
 442                    let (paths, paths_with_positions) =
 443                        determine_paths_with_positions(&remote_connection, paths).await;
 444
 445                    cx.update(|_, cx| {
 446                        let fs = app_state.fs.clone();
 447                        update_settings_file(fs, cx, {
 448                            let paths = paths
 449                                .iter()
 450                                .map(|path| path.to_string_lossy().into_owned())
 451                                .collect();
 452                            move |settings, _| match index {
 453                                ServerIndex::Ssh(index) => {
 454                                    if let Some(server) = settings
 455                                        .remote
 456                                        .ssh_connections
 457                                        .as_mut()
 458                                        .and_then(|connections| connections.get_mut(index.0))
 459                                    {
 460                                        server.projects.insert(RemoteProject { paths });
 461                                    };
 462                                }
 463                                ServerIndex::Wsl(index) => {
 464                                    if let Some(server) = settings
 465                                        .remote
 466                                        .wsl_connections
 467                                        .as_mut()
 468                                        .and_then(|connections| connections.get_mut(index.0))
 469                                    {
 470                                        server.projects.insert(RemoteProject { paths });
 471                                    };
 472                                }
 473                            }
 474                        });
 475                    })
 476                    .log_err();
 477
 478                    let options = cx
 479                        .update(|_, cx| (app_state.build_window_options)(None, cx))
 480                        .log_err()?;
 481                    let window = cx
 482                        .open_window(options, |window, cx| {
 483                            let workspace = cx.new(|cx| {
 484                                telemetry::event!("SSH Project Created");
 485                                Workspace::new(None, project.clone(), app_state.clone(), window, cx)
 486                            });
 487                            cx.new(|cx| MultiWorkspace::new(workspace, cx))
 488                        })
 489                        .log_err()?;
 490
 491                    let items = open_remote_project_with_existing_connection(
 492                        connection, project, paths, app_state, window, cx,
 493                    )
 494                    .await
 495                    .log_err();
 496
 497                    if let Some(items) = items {
 498                        for (item, path) in items.into_iter().zip(paths_with_positions) {
 499                            let Some(item) = item else {
 500                                continue;
 501                            };
 502                            let Some(row) = path.row else {
 503                                continue;
 504                            };
 505                            if let Some(active_editor) = item.downcast::<Editor>() {
 506                                window
 507                                    .update(cx, |_, window, cx| {
 508                                        active_editor.update(cx, |editor, cx| {
 509                                            let row = row.saturating_sub(1);
 510                                            let col = path.column.unwrap_or(0).saturating_sub(1);
 511                                            editor.go_to_singleton_buffer_point(
 512                                                Point::new(row, col),
 513                                                window,
 514                                                cx,
 515                                            );
 516                                        });
 517                                    })
 518                                    .ok();
 519                            }
 520                        }
 521                    }
 522
 523                    this.update(cx, |_, cx| {
 524                        cx.emit(DismissEvent);
 525                    })
 526                    .ok();
 527                    Some(())
 528                }
 529            })
 530            .shared();
 531        cx.new(|_| Self {
 532            _path_task,
 533            picker,
 534            data,
 535        })
 536    }
 537}
 538
 539impl gpui::Render for ProjectPicker {
 540    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 541        v_flex()
 542            .child(match &self.data {
 543                ProjectPickerData::Ssh {
 544                    connection_string,
 545                    nickname,
 546                } => SshConnectionHeader {
 547                    connection_string: connection_string.clone(),
 548                    paths: Default::default(),
 549                    nickname: nickname.clone(),
 550                    is_wsl: false,
 551                    is_devcontainer: false,
 552                }
 553                .render(window, cx),
 554                ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
 555                    connection_string: distro_name.clone(),
 556                    paths: Default::default(),
 557                    nickname: None,
 558                    is_wsl: true,
 559                    is_devcontainer: false,
 560                }
 561                .render(window, cx),
 562            })
 563            .child(
 564                div()
 565                    .border_t_1()
 566                    .border_color(cx.theme().colors().border_variant)
 567                    .child(self.picker.clone()),
 568            )
 569    }
 570}
 571
 572#[repr(transparent)]
 573#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
 574struct SshServerIndex(usize);
 575impl std::fmt::Display for SshServerIndex {
 576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 577        self.0.fmt(f)
 578    }
 579}
 580
 581#[repr(transparent)]
 582#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
 583struct WslServerIndex(usize);
 584impl std::fmt::Display for WslServerIndex {
 585    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 586        self.0.fmt(f)
 587    }
 588}
 589
 590#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
 591enum ServerIndex {
 592    Ssh(SshServerIndex),
 593    Wsl(WslServerIndex),
 594}
 595impl From<SshServerIndex> for ServerIndex {
 596    fn from(index: SshServerIndex) -> Self {
 597        Self::Ssh(index)
 598    }
 599}
 600impl From<WslServerIndex> for ServerIndex {
 601    fn from(index: WslServerIndex) -> Self {
 602        Self::Wsl(index)
 603    }
 604}
 605
 606#[derive(Clone)]
 607enum RemoteEntry {
 608    Project {
 609        open_folder: NavigableEntry,
 610        projects: Vec<(NavigableEntry, RemoteProject)>,
 611        configure: NavigableEntry,
 612        connection: Connection,
 613        index: ServerIndex,
 614    },
 615    SshConfig {
 616        open_folder: NavigableEntry,
 617        host: SharedString,
 618    },
 619}
 620
 621impl RemoteEntry {
 622    fn is_from_zed(&self) -> bool {
 623        matches!(self, Self::Project { .. })
 624    }
 625
 626    fn connection(&self) -> Cow<'_, Connection> {
 627        match self {
 628            Self::Project { connection, .. } => Cow::Borrowed(connection),
 629            Self::SshConfig { host, .. } => Cow::Owned(
 630                SshConnection {
 631                    host: host.to_string(),
 632                    ..SshConnection::default()
 633                }
 634                .into(),
 635            ),
 636        }
 637    }
 638}
 639
 640#[derive(Clone)]
 641struct DefaultState {
 642    scroll_handle: ScrollHandle,
 643    add_new_server: NavigableEntry,
 644    add_new_devcontainer: NavigableEntry,
 645    add_new_wsl: NavigableEntry,
 646    servers: Vec<RemoteEntry>,
 647}
 648
 649impl DefaultState {
 650    fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
 651        let handle = ScrollHandle::new();
 652        let add_new_server = NavigableEntry::new(&handle, cx);
 653        let add_new_devcontainer = NavigableEntry::new(&handle, cx);
 654        let add_new_wsl = NavigableEntry::new(&handle, cx);
 655
 656        let ssh_settings = RemoteSettings::get_global(cx);
 657        let read_ssh_config = ssh_settings.read_ssh_config;
 658
 659        let ssh_servers = ssh_settings
 660            .ssh_connections()
 661            .enumerate()
 662            .map(|(index, connection)| {
 663                let open_folder = NavigableEntry::new(&handle, cx);
 664                let configure = NavigableEntry::new(&handle, cx);
 665                let projects = connection
 666                    .projects
 667                    .iter()
 668                    .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
 669                    .collect();
 670                RemoteEntry::Project {
 671                    open_folder,
 672                    configure,
 673                    projects,
 674                    index: ServerIndex::Ssh(SshServerIndex(index)),
 675                    connection: connection.into(),
 676                }
 677            });
 678
 679        let wsl_servers = ssh_settings
 680            .wsl_connections()
 681            .enumerate()
 682            .map(|(index, connection)| {
 683                let open_folder = NavigableEntry::new(&handle, cx);
 684                let configure = NavigableEntry::new(&handle, cx);
 685                let projects = connection
 686                    .projects
 687                    .iter()
 688                    .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
 689                    .collect();
 690                RemoteEntry::Project {
 691                    open_folder,
 692                    configure,
 693                    projects,
 694                    index: ServerIndex::Wsl(WslServerIndex(index)),
 695                    connection: connection.into(),
 696                }
 697            });
 698
 699        let mut servers = ssh_servers.chain(wsl_servers).collect::<Vec<RemoteEntry>>();
 700
 701        if read_ssh_config {
 702            let mut extra_servers_from_config = ssh_config_servers.clone();
 703            for server in &servers {
 704                if let RemoteEntry::Project {
 705                    connection: Connection::Ssh(ssh_options),
 706                    ..
 707                } = server
 708                {
 709                    extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone()));
 710                }
 711            }
 712            servers.extend(extra_servers_from_config.into_iter().map(|host| {
 713                RemoteEntry::SshConfig {
 714                    open_folder: NavigableEntry::new(&handle, cx),
 715                    host,
 716                }
 717            }));
 718        }
 719
 720        Self {
 721            scroll_handle: handle,
 722            add_new_server,
 723            add_new_devcontainer,
 724            add_new_wsl,
 725            servers,
 726        }
 727    }
 728}
 729
 730#[derive(Clone)]
 731enum ViewServerOptionsState {
 732    Ssh {
 733        connection: SshConnectionOptions,
 734        server_index: SshServerIndex,
 735        entries: [NavigableEntry; 4],
 736    },
 737    Wsl {
 738        connection: WslConnectionOptions,
 739        server_index: WslServerIndex,
 740        entries: [NavigableEntry; 2],
 741    },
 742}
 743
 744impl ViewServerOptionsState {
 745    fn entries(&self) -> &[NavigableEntry] {
 746        match self {
 747            Self::Ssh { entries, .. } => entries,
 748            Self::Wsl { entries, .. } => entries,
 749        }
 750    }
 751}
 752
 753enum Mode {
 754    Default(DefaultState),
 755    ViewServerOptions(ViewServerOptionsState),
 756    EditNickname(EditNicknameState),
 757    ProjectPicker(Entity<ProjectPicker>),
 758    CreateRemoteServer(CreateRemoteServer),
 759    CreateRemoteDevContainer(CreateRemoteDevContainer),
 760    #[cfg(target_os = "windows")]
 761    AddWslDistro(AddWslDistro),
 762}
 763
 764impl Mode {
 765    fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
 766        Self::Default(DefaultState::new(ssh_config_servers, cx))
 767    }
 768}
 769
 770impl RemoteServerProjects {
 771    #[cfg(target_os = "windows")]
 772    pub fn wsl(
 773        create_new_window: bool,
 774        fs: Arc<dyn Fs>,
 775        window: &mut Window,
 776        workspace: WeakEntity<Workspace>,
 777        cx: &mut Context<Self>,
 778    ) -> Self {
 779        Self::new_inner(
 780            Mode::AddWslDistro(AddWslDistro::new(window, cx)),
 781            create_new_window,
 782            fs,
 783            window,
 784            workspace,
 785            cx,
 786        )
 787    }
 788
 789    pub fn new(
 790        create_new_window: bool,
 791        fs: Arc<dyn Fs>,
 792        window: &mut Window,
 793        workspace: WeakEntity<Workspace>,
 794        cx: &mut Context<Self>,
 795    ) -> Self {
 796        Self::new_inner(
 797            Mode::default_mode(&BTreeSet::new(), cx),
 798            create_new_window,
 799            fs,
 800            window,
 801            workspace,
 802            cx,
 803        )
 804    }
 805
 806    /// Creates a new RemoteServerProjects modal that opens directly in dev container creation mode.
 807    /// Used when suggesting dev container connection from toast notification.
 808    pub fn new_dev_container(
 809        fs: Arc<dyn Fs>,
 810        window: &mut Window,
 811        workspace: WeakEntity<Workspace>,
 812        cx: &mut Context<Self>,
 813    ) -> Self {
 814        let configs = workspace
 815            .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
 816            .unwrap_or_default();
 817
 818        let initial_mode = if configs.len() > 1 {
 819            DevContainerCreationProgress::SelectingConfig
 820        } else {
 821            DevContainerCreationProgress::Creating
 822        };
 823
 824        let mut this = Self::new_inner(
 825            Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(initial_mode, cx)),
 826            false,
 827            fs,
 828            window,
 829            workspace,
 830            cx,
 831        );
 832
 833        if configs.len() > 1 {
 834            let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
 835            this.dev_container_picker =
 836                Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
 837        } else {
 838            let config = configs.into_iter().next();
 839            this.open_dev_container(config, window, cx);
 840            this.view_in_progress_dev_container(window, cx);
 841        }
 842
 843        this
 844    }
 845
 846    pub fn popover(
 847        fs: Arc<dyn Fs>,
 848        workspace: WeakEntity<Workspace>,
 849        create_new_window: bool,
 850        window: &mut Window,
 851        cx: &mut App,
 852    ) -> Entity<Self> {
 853        cx.new(|cx| {
 854            let server = Self::new(create_new_window, fs, window, workspace, cx);
 855            server.focus_handle(cx).focus(window, cx);
 856            server
 857        })
 858    }
 859
 860    fn new_inner(
 861        mode: Mode,
 862        create_new_window: bool,
 863        fs: Arc<dyn Fs>,
 864        window: &mut Window,
 865        workspace: WeakEntity<Workspace>,
 866        cx: &mut Context<Self>,
 867    ) -> Self {
 868        let focus_handle = cx.focus_handle();
 869        let mut read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config;
 870        let ssh_config_updates = if read_ssh_config {
 871            spawn_ssh_config_watch(fs.clone(), cx)
 872        } else {
 873            Task::ready(())
 874        };
 875
 876        let mut base_style = window.text_style();
 877        base_style.refine(&gpui::TextStyleRefinement {
 878            color: Some(cx.theme().colors().editor_foreground),
 879            ..Default::default()
 880        });
 881
 882        let _subscription =
 883            cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
 884                let new_read_ssh_config = RemoteSettings::get_global(cx).read_ssh_config;
 885                if read_ssh_config != new_read_ssh_config {
 886                    read_ssh_config = new_read_ssh_config;
 887                    if read_ssh_config {
 888                        recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
 889                    } else {
 890                        recent_projects.ssh_config_servers.clear();
 891                        recent_projects.ssh_config_updates = Task::ready(());
 892                    }
 893                }
 894            });
 895
 896        Self {
 897            mode,
 898            focus_handle,
 899            workspace,
 900            retained_connections: Vec::new(),
 901            ssh_config_updates,
 902            ssh_config_servers: BTreeSet::new(),
 903            create_new_window,
 904            dev_container_picker: None,
 905            _subscription,
 906        }
 907    }
 908
 909    fn project_picker(
 910        create_new_window: bool,
 911        index: ServerIndex,
 912        connection_options: remote::RemoteConnectionOptions,
 913        project: Entity<Project>,
 914        home_dir: RemotePathBuf,
 915        window: &mut Window,
 916        cx: &mut Context<Self>,
 917        workspace: WeakEntity<Workspace>,
 918    ) -> Self {
 919        let fs = project.read(cx).fs().clone();
 920        let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
 921        this.mode = Mode::ProjectPicker(ProjectPicker::new(
 922            create_new_window,
 923            index,
 924            connection_options,
 925            project,
 926            home_dir,
 927            workspace,
 928            window,
 929            cx,
 930        ));
 931        cx.notify();
 932
 933        this
 934    }
 935
 936    fn create_ssh_server(
 937        &mut self,
 938        editor: Entity<Editor>,
 939        window: &mut Window,
 940        cx: &mut Context<Self>,
 941    ) {
 942        let input = get_text(&editor, cx);
 943        if input.is_empty() {
 944            return;
 945        }
 946
 947        let connection_options = match SshConnectionOptions::parse_command_line(&input) {
 948            Ok(c) => c,
 949            Err(e) => {
 950                self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 951                    address_editor: editor,
 952                    address_error: Some(format!("could not parse: {:?}", e).into()),
 953                    ssh_prompt: None,
 954                    _creating: None,
 955                });
 956                return;
 957            }
 958        };
 959        let ssh_prompt = cx.new(|cx| {
 960            RemoteConnectionPrompt::new(
 961                connection_options.connection_string(),
 962                connection_options.nickname.clone(),
 963                false,
 964                false,
 965                window,
 966                cx,
 967            )
 968        });
 969
 970        let connection = connect(
 971            ConnectionIdentifier::setup(),
 972            RemoteConnectionOptions::Ssh(connection_options.clone()),
 973            ssh_prompt.clone(),
 974            window,
 975            cx,
 976        )
 977        .prompt_err("Failed to connect", window, cx, |_, _, _| None);
 978
 979        let address_editor = editor.clone();
 980        let creating = cx.spawn_in(window, async move |this, cx| {
 981            match connection.await {
 982                Some(Some(client)) => this
 983                    .update_in(cx, |this, window, cx| {
 984                        info!("ssh server created");
 985                        telemetry::event!("SSH Server Created");
 986                        this.retained_connections.push(client);
 987                        this.add_ssh_server(connection_options, cx);
 988                        this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
 989                        this.focus_handle(cx).focus(window, cx);
 990                        cx.notify()
 991                    })
 992                    .log_err(),
 993                _ => this
 994                    .update(cx, |this, cx| {
 995                        address_editor.update(cx, |this, _| {
 996                            this.set_read_only(false);
 997                        });
 998                        this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 999                            address_editor,
1000                            address_error: None,
1001                            ssh_prompt: None,
1002                            _creating: None,
1003                        });
1004                        cx.notify()
1005                    })
1006                    .log_err(),
1007            };
1008            None
1009        });
1010
1011        editor.update(cx, |this, _| {
1012            this.set_read_only(true);
1013        });
1014        self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
1015            address_editor: editor,
1016            address_error: None,
1017            ssh_prompt: Some(ssh_prompt),
1018            _creating: Some(creating),
1019        });
1020    }
1021
1022    #[cfg(target_os = "windows")]
1023    fn connect_wsl_distro(
1024        &mut self,
1025        picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
1026        distro: String,
1027        window: &mut Window,
1028        cx: &mut Context<Self>,
1029    ) {
1030        let connection_options = WslConnectionOptions {
1031            distro_name: distro,
1032            user: None,
1033        };
1034
1035        let prompt = cx.new(|cx| {
1036            RemoteConnectionPrompt::new(
1037                connection_options.distro_name.clone(),
1038                None,
1039                true,
1040                false,
1041                window,
1042                cx,
1043            )
1044        });
1045        let connection = connect(
1046            ConnectionIdentifier::setup(),
1047            connection_options.clone().into(),
1048            prompt.clone(),
1049            window,
1050            cx,
1051        )
1052        .prompt_err("Failed to connect", window, cx, |_, _, _| None);
1053
1054        let wsl_picker = picker.clone();
1055        let creating = cx.spawn_in(window, async move |this, cx| {
1056            match connection.await {
1057                Some(Some(client)) => this.update_in(cx, |this, window, cx| {
1058                    telemetry::event!("WSL Distro Added");
1059                    this.retained_connections.push(client);
1060                    let Some(fs) = this
1061                        .workspace
1062                        .read_with(cx, |workspace, cx| {
1063                            workspace.project().read(cx).fs().clone()
1064                        })
1065                        .log_err()
1066                    else {
1067                        return;
1068                    };
1069
1070                    crate::add_wsl_distro(fs, &connection_options, cx);
1071                    this.mode = Mode::default_mode(&BTreeSet::new(), cx);
1072                    this.focus_handle(cx).focus(window, cx);
1073                    cx.notify();
1074                }),
1075                _ => this.update(cx, |this, cx| {
1076                    this.mode = Mode::AddWslDistro(AddWslDistro {
1077                        picker: wsl_picker,
1078                        connection_prompt: None,
1079                        _creating: None,
1080                    });
1081                    cx.notify();
1082                }),
1083            }
1084            .log_err();
1085        });
1086
1087        self.mode = Mode::AddWslDistro(AddWslDistro {
1088            picker,
1089            connection_prompt: Some(prompt),
1090            _creating: Some(creating),
1091        });
1092    }
1093
1094    fn view_server_options(
1095        &mut self,
1096        (server_index, connection): (ServerIndex, RemoteConnectionOptions),
1097        window: &mut Window,
1098        cx: &mut Context<Self>,
1099    ) {
1100        self.mode = Mode::ViewServerOptions(match (server_index, connection) {
1101            (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => {
1102                ViewServerOptionsState::Ssh {
1103                    connection,
1104                    server_index,
1105                    entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
1106                }
1107            }
1108            (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => {
1109                ViewServerOptionsState::Wsl {
1110                    connection,
1111                    server_index,
1112                    entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
1113                }
1114            }
1115            _ => {
1116                log::error!("server index and connection options mismatch");
1117                self.mode = Mode::default_mode(&BTreeSet::default(), cx);
1118                return;
1119            }
1120        });
1121        self.focus_handle(cx).focus(window, cx);
1122        cx.notify();
1123    }
1124
1125    fn view_in_progress_dev_container(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1126        self.mode = Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(
1127            DevContainerCreationProgress::Creating,
1128            cx,
1129        ));
1130        self.focus_handle(cx).focus(window, cx);
1131        cx.notify();
1132    }
1133
1134    fn create_remote_project(
1135        &mut self,
1136        index: ServerIndex,
1137        connection_options: RemoteConnectionOptions,
1138        window: &mut Window,
1139        cx: &mut Context<Self>,
1140    ) {
1141        let Some(workspace) = self.workspace.upgrade() else {
1142            return;
1143        };
1144
1145        let create_new_window = self.create_new_window;
1146        workspace.update(cx, |_, cx| {
1147            cx.defer_in(window, move |workspace, window, cx| {
1148                let app_state = workspace.app_state().clone();
1149                workspace.toggle_modal(window, cx, |window, cx| {
1150                    RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
1151                });
1152                let prompt = workspace
1153                    .active_modal::<RemoteConnectionModal>(cx)
1154                    .unwrap()
1155                    .read(cx)
1156                    .prompt
1157                    .clone();
1158
1159                let connect = connect(
1160                    ConnectionIdentifier::setup(),
1161                    connection_options.clone(),
1162                    prompt,
1163                    window,
1164                    cx,
1165                )
1166                .prompt_err("Failed to connect", window, cx, |_, _, _| None);
1167
1168                cx.spawn_in(window, async move |workspace, cx| {
1169                    let session = connect.await;
1170
1171                    workspace.update(cx, |workspace, cx| {
1172                        if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
1173                            prompt.update(cx, |prompt, cx| prompt.finished(cx))
1174                        }
1175                    })?;
1176
1177                    let Some(Some(session)) = session else {
1178                        return workspace.update_in(cx, |workspace, window, cx| {
1179                            let weak = cx.entity().downgrade();
1180                            let fs = workspace.project().read(cx).fs().clone();
1181                            workspace.toggle_modal(window, cx, |window, cx| {
1182                                RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
1183                            });
1184                        });
1185                    };
1186
1187                    let (path_style, project) = cx.update(|_, cx| {
1188                        (
1189                            session.read(cx).path_style(),
1190                            project::Project::remote(
1191                                session,
1192                                app_state.client.clone(),
1193                                app_state.node_runtime.clone(),
1194                                app_state.user_store.clone(),
1195                                app_state.languages.clone(),
1196                                app_state.fs.clone(),
1197                                true,
1198                                cx,
1199                            ),
1200                        )
1201                    })?;
1202
1203                    let home_dir = project
1204                        .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))
1205                        .await
1206                        .and_then(|path| path.into_abs_path())
1207                        .map(|path| RemotePathBuf::new(path, path_style))
1208                        .unwrap_or_else(|| match path_style {
1209                            PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
1210                            PathStyle::Windows => {
1211                                RemotePathBuf::from_str("C:\\", PathStyle::Windows)
1212                            }
1213                        });
1214
1215                    workspace
1216                        .update_in(cx, |workspace, window, cx| {
1217                            let weak = cx.entity().downgrade();
1218                            workspace.toggle_modal(window, cx, |window, cx| {
1219                                RemoteServerProjects::project_picker(
1220                                    create_new_window,
1221                                    index,
1222                                    connection_options,
1223                                    project,
1224                                    home_dir,
1225                                    window,
1226                                    cx,
1227                                    weak,
1228                                )
1229                            });
1230                        })
1231                        .ok();
1232                    Ok(())
1233                })
1234                .detach();
1235            })
1236        })
1237    }
1238
1239    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1240        match &self.mode {
1241            Mode::Default(_) | Mode::ViewServerOptions(_) => {}
1242            Mode::ProjectPicker(_) => {}
1243            Mode::CreateRemoteServer(state) => {
1244                if let Some(prompt) = state.ssh_prompt.as_ref() {
1245                    prompt.update(cx, |prompt, cx| {
1246                        prompt.confirm(window, cx);
1247                    });
1248                    return;
1249                }
1250
1251                self.create_ssh_server(state.address_editor.clone(), window, cx);
1252            }
1253            Mode::CreateRemoteDevContainer(_) => {}
1254            Mode::EditNickname(state) => {
1255                let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
1256                let index = state.index;
1257                self.update_settings_file(cx, move |setting, _| {
1258                    if let Some(connections) = setting.ssh_connections.as_mut()
1259                        && let Some(connection) = connections.get_mut(index.0)
1260                    {
1261                        connection.nickname = text;
1262                    }
1263                });
1264                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1265                self.focus_handle.focus(window, cx);
1266            }
1267            #[cfg(target_os = "windows")]
1268            Mode::AddWslDistro(state) => {
1269                let delegate = &state.picker.read(cx).delegate;
1270                let distro = delegate.selected_distro().unwrap();
1271                self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
1272            }
1273        }
1274    }
1275
1276    fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1277        match &self.mode {
1278            Mode::Default(_) => cx.emit(DismissEvent),
1279            Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
1280                let new_state = CreateRemoteServer::new(window, cx);
1281                let old_prompt = state.address_editor.read(cx).text(cx);
1282                new_state.address_editor.update(cx, |this, cx| {
1283                    this.set_text(old_prompt, window, cx);
1284                });
1285
1286                self.mode = Mode::CreateRemoteServer(new_state);
1287                cx.notify();
1288            }
1289            Mode::CreateRemoteDevContainer(CreateRemoteDevContainer {
1290                progress: DevContainerCreationProgress::Error(_),
1291                ..
1292            }) => {
1293                cx.emit(DismissEvent);
1294            }
1295            _ => {
1296                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1297                self.focus_handle(cx).focus(window, cx);
1298                cx.notify();
1299            }
1300        }
1301    }
1302
1303    fn render_remote_connection(
1304        &mut self,
1305        ix: usize,
1306        remote_server: RemoteEntry,
1307        window: &mut Window,
1308        cx: &mut Context<Self>,
1309    ) -> impl IntoElement {
1310        let connection = remote_server.connection().into_owned();
1311
1312        let (main_label, aux_label, is_wsl) = match &connection {
1313            Connection::Ssh(connection) => {
1314                if let Some(nickname) = connection.nickname.clone() {
1315                    let aux_label = SharedString::from(format!("({})", connection.host));
1316                    (nickname, Some(aux_label), false)
1317                } else {
1318                    (connection.host.clone(), None, false)
1319                }
1320            }
1321            Connection::Wsl(wsl_connection_options) => {
1322                (wsl_connection_options.distro_name.clone(), None, true)
1323            }
1324            Connection::DevContainer(dev_container_options) => {
1325                (dev_container_options.name.clone(), None, false)
1326            }
1327        };
1328        v_flex()
1329            .w_full()
1330            .child(ListSeparator)
1331            .child(
1332                h_flex()
1333                    .group("ssh-server")
1334                    .w_full()
1335                    .pt_0p5()
1336                    .px_3()
1337                    .gap_1()
1338                    .overflow_hidden()
1339                    .child(
1340                        h_flex()
1341                            .gap_1()
1342                            .max_w_96()
1343                            .overflow_hidden()
1344                            .text_ellipsis()
1345                            .when(is_wsl, |this| {
1346                                this.child(
1347                                    Label::new("WSL:")
1348                                        .size(LabelSize::Small)
1349                                        .color(Color::Muted),
1350                                )
1351                            })
1352                            .child(
1353                                Label::new(main_label)
1354                                    .size(LabelSize::Small)
1355                                    .color(Color::Muted),
1356                            ),
1357                    )
1358                    .children(
1359                        aux_label.map(|label| {
1360                            Label::new(label).size(LabelSize::Small).color(Color::Muted)
1361                        }),
1362                    ),
1363            )
1364            .child(match &remote_server {
1365                RemoteEntry::Project {
1366                    open_folder,
1367                    projects,
1368                    configure,
1369                    connection,
1370                    index,
1371                } => {
1372                    let index = *index;
1373                    List::new()
1374                        .empty_message("No projects.")
1375                        .children(projects.iter().enumerate().map(|(pix, p)| {
1376                            v_flex().gap_0p5().child(self.render_remote_project(
1377                                index,
1378                                remote_server.clone(),
1379                                pix,
1380                                p,
1381                                window,
1382                                cx,
1383                            ))
1384                        }))
1385                        .child(
1386                            h_flex()
1387                                .id(("new-remote-project-container", ix))
1388                                .track_focus(&open_folder.focus_handle)
1389                                .anchor_scroll(open_folder.scroll_anchor.clone())
1390                                .on_action(cx.listener({
1391                                    let connection = connection.clone();
1392                                    move |this, _: &menu::Confirm, window, cx| {
1393                                        this.create_remote_project(
1394                                            index,
1395                                            connection.clone().into(),
1396                                            window,
1397                                            cx,
1398                                        );
1399                                    }
1400                                }))
1401                                .child(
1402                                    ListItem::new(("new-remote-project", ix))
1403                                        .toggle_state(
1404                                            open_folder.focus_handle.contains_focused(window, cx),
1405                                        )
1406                                        .inset(true)
1407                                        .spacing(ui::ListItemSpacing::Sparse)
1408                                        .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1409                                        .child(Label::new("Open Folder"))
1410                                        .on_click(cx.listener({
1411                                            let connection = connection.clone();
1412                                            move |this, _, window, cx| {
1413                                                this.create_remote_project(
1414                                                    index,
1415                                                    connection.clone().into(),
1416                                                    window,
1417                                                    cx,
1418                                                );
1419                                            }
1420                                        })),
1421                                ),
1422                        )
1423                        .child(
1424                            h_flex()
1425                                .id(("server-options-container", ix))
1426                                .track_focus(&configure.focus_handle)
1427                                .anchor_scroll(configure.scroll_anchor.clone())
1428                                .on_action(cx.listener({
1429                                    let connection = connection.clone();
1430                                    move |this, _: &menu::Confirm, window, cx| {
1431                                        this.view_server_options(
1432                                            (index, connection.clone().into()),
1433                                            window,
1434                                            cx,
1435                                        );
1436                                    }
1437                                }))
1438                                .child(
1439                                    ListItem::new(("server-options", ix))
1440                                        .toggle_state(
1441                                            configure.focus_handle.contains_focused(window, cx),
1442                                        )
1443                                        .inset(true)
1444                                        .spacing(ui::ListItemSpacing::Sparse)
1445                                        .start_slot(
1446                                            Icon::new(IconName::Settings).color(Color::Muted),
1447                                        )
1448                                        .child(Label::new("View Server Options"))
1449                                        .on_click(cx.listener({
1450                                            let ssh_connection = connection.clone();
1451                                            move |this, _, window, cx| {
1452                                                this.view_server_options(
1453                                                    (index, ssh_connection.clone().into()),
1454                                                    window,
1455                                                    cx,
1456                                                );
1457                                            }
1458                                        })),
1459                                ),
1460                        )
1461                }
1462                RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1463                    h_flex()
1464                        .id(("new-remote-project-container", ix))
1465                        .track_focus(&open_folder.focus_handle)
1466                        .anchor_scroll(open_folder.scroll_anchor.clone())
1467                        .on_action(cx.listener({
1468                            let connection = connection.clone();
1469                            let host = host.clone();
1470                            move |this, _: &menu::Confirm, window, cx| {
1471                                let new_ix = this.create_host_from_ssh_config(&host, cx);
1472                                this.create_remote_project(
1473                                    new_ix.into(),
1474                                    connection.clone().into(),
1475                                    window,
1476                                    cx,
1477                                );
1478                            }
1479                        }))
1480                        .child(
1481                            ListItem::new(("new-remote-project", ix))
1482                                .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1483                                .inset(true)
1484                                .spacing(ui::ListItemSpacing::Sparse)
1485                                .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1486                                .child(Label::new("Open Folder"))
1487                                .on_click(cx.listener({
1488                                    let host = host.clone();
1489                                    move |this, _, window, cx| {
1490                                        let new_ix = this.create_host_from_ssh_config(&host, cx);
1491                                        this.create_remote_project(
1492                                            new_ix.into(),
1493                                            connection.clone().into(),
1494                                            window,
1495                                            cx,
1496                                        );
1497                                    }
1498                                })),
1499                        ),
1500                ),
1501            })
1502    }
1503
1504    fn render_remote_project(
1505        &mut self,
1506        server_ix: ServerIndex,
1507        server: RemoteEntry,
1508        ix: usize,
1509        (navigation, project): &(NavigableEntry, RemoteProject),
1510        window: &mut Window,
1511        cx: &mut Context<Self>,
1512    ) -> impl IntoElement {
1513        let create_new_window = self.create_new_window;
1514        let is_from_zed = server.is_from_zed();
1515        let element_id_base = SharedString::from(format!(
1516            "remote-project-{}",
1517            match server_ix {
1518                ServerIndex::Ssh(index) => format!("ssh-{index}"),
1519                ServerIndex::Wsl(index) => format!("wsl-{index}"),
1520            }
1521        ));
1522        let container_element_id_base =
1523            SharedString::from(format!("remote-project-container-{element_id_base}"));
1524
1525        let callback = Rc::new({
1526            let project = project.clone();
1527            move |remote_server_projects: &mut Self,
1528                  secondary_confirm: bool,
1529                  window: &mut Window,
1530                  cx: &mut Context<Self>| {
1531                let Some(app_state) = remote_server_projects
1532                    .workspace
1533                    .read_with(cx, |workspace, _| workspace.app_state().clone())
1534                    .log_err()
1535                else {
1536                    return;
1537                };
1538                let project = project.clone();
1539                let server = server.connection().into_owned();
1540                cx.emit(DismissEvent);
1541
1542                let replace_window = match (create_new_window, secondary_confirm) {
1543                    (true, false) | (false, true) => None,
1544                    (true, true) | (false, false) => {
1545                        window.window_handle().downcast::<MultiWorkspace>()
1546                    }
1547                };
1548
1549                cx.spawn_in(window, async move |_, cx| {
1550                    let result = open_remote_project(
1551                        server.into(),
1552                        project.paths.into_iter().map(PathBuf::from).collect(),
1553                        app_state,
1554                        OpenOptions {
1555                            replace_window,
1556                            ..OpenOptions::default()
1557                        },
1558                        cx,
1559                    )
1560                    .await;
1561                    if let Err(e) = result {
1562                        log::error!("Failed to connect: {e:#}");
1563                        cx.prompt(
1564                            gpui::PromptLevel::Critical,
1565                            "Failed to connect",
1566                            Some(&e.to_string()),
1567                            &["Ok"],
1568                        )
1569                        .await
1570                        .ok();
1571                    }
1572                })
1573                .detach();
1574            }
1575        });
1576
1577        div()
1578            .id((container_element_id_base, ix))
1579            .track_focus(&navigation.focus_handle)
1580            .anchor_scroll(navigation.scroll_anchor.clone())
1581            .on_action(cx.listener({
1582                let callback = callback.clone();
1583                move |this, _: &menu::Confirm, window, cx| {
1584                    callback(this, false, window, cx);
1585                }
1586            }))
1587            .on_action(cx.listener({
1588                let callback = callback.clone();
1589                move |this, _: &menu::SecondaryConfirm, window, cx| {
1590                    callback(this, true, window, cx);
1591                }
1592            }))
1593            .child(
1594                ListItem::new((element_id_base, ix))
1595                    .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1596                    .inset(true)
1597                    .spacing(ui::ListItemSpacing::Sparse)
1598                    .start_slot(
1599                        Icon::new(IconName::Folder)
1600                            .color(Color::Muted)
1601                            .size(IconSize::Small),
1602                    )
1603                    .child(Label::new(project.paths.join(", ")).truncate_start())
1604                    .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1605                        let secondary_confirm = e.modifiers().platform;
1606                        callback(this, secondary_confirm, window, cx)
1607                    }))
1608                    .tooltip(Tooltip::text(project.paths.join("\n")))
1609                    .when(is_from_zed, |server_list_item| {
1610                        server_list_item.end_hover_slot::<AnyElement>(Some(
1611                            div()
1612                                .mr_2()
1613                                .child({
1614                                    let project = project.clone();
1615                                    // Right-margin to offset it from the Scrollbar
1616                                    IconButton::new("remove-remote-project", IconName::Trash)
1617                                        .icon_size(IconSize::Small)
1618                                        .shape(IconButtonShape::Square)
1619                                        .size(ButtonSize::Large)
1620                                        .tooltip(Tooltip::text("Delete Remote Project"))
1621                                        .on_click(cx.listener(move |this, _, _, cx| {
1622                                            this.delete_remote_project(server_ix, &project, cx)
1623                                        }))
1624                                })
1625                                .into_any_element(),
1626                        ))
1627                    }),
1628            )
1629    }
1630
1631    fn update_settings_file(
1632        &mut self,
1633        cx: &mut Context<Self>,
1634        f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1635    ) {
1636        let Some(fs) = self
1637            .workspace
1638            .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1639            .log_err()
1640        else {
1641            return;
1642        };
1643        update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
1644    }
1645
1646    fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1647        self.update_settings_file(cx, move |setting, _| {
1648            if let Some(connections) = setting.ssh_connections.as_mut() {
1649                connections.remove(server.0);
1650            }
1651        });
1652    }
1653
1654    fn delete_remote_project(
1655        &mut self,
1656        server: ServerIndex,
1657        project: &RemoteProject,
1658        cx: &mut Context<Self>,
1659    ) {
1660        match server {
1661            ServerIndex::Ssh(server) => {
1662                self.delete_ssh_project(server, project, cx);
1663            }
1664            ServerIndex::Wsl(server) => {
1665                self.delete_wsl_project(server, project, cx);
1666            }
1667        }
1668    }
1669
1670    fn delete_ssh_project(
1671        &mut self,
1672        server: SshServerIndex,
1673        project: &RemoteProject,
1674        cx: &mut Context<Self>,
1675    ) {
1676        let project = project.clone();
1677        self.update_settings_file(cx, move |setting, _| {
1678            if let Some(server) = setting
1679                .ssh_connections
1680                .as_mut()
1681                .and_then(|connections| connections.get_mut(server.0))
1682            {
1683                server.projects.remove(&project);
1684            }
1685        });
1686    }
1687
1688    fn delete_wsl_project(
1689        &mut self,
1690        server: WslServerIndex,
1691        project: &RemoteProject,
1692        cx: &mut Context<Self>,
1693    ) {
1694        let project = project.clone();
1695        self.update_settings_file(cx, move |setting, _| {
1696            if let Some(server) = setting
1697                .wsl_connections
1698                .as_mut()
1699                .and_then(|connections| connections.get_mut(server.0))
1700            {
1701                server.projects.remove(&project);
1702            }
1703        });
1704    }
1705
1706    fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1707        self.update_settings_file(cx, move |setting, _| {
1708            if let Some(connections) = setting.wsl_connections.as_mut() {
1709                connections.remove(server.0);
1710            }
1711        });
1712    }
1713
1714    fn add_ssh_server(
1715        &mut self,
1716        connection_options: remote::SshConnectionOptions,
1717        cx: &mut Context<Self>,
1718    ) {
1719        self.update_settings_file(cx, move |setting, _| {
1720            setting
1721                .ssh_connections
1722                .get_or_insert(Default::default())
1723                .push(SshConnection {
1724                    host: connection_options.host.to_string(),
1725                    username: connection_options.username,
1726                    port: connection_options.port,
1727                    projects: BTreeSet::new(),
1728                    nickname: None,
1729                    args: connection_options.args.unwrap_or_default(),
1730                    upload_binary_over_ssh: None,
1731                    port_forwards: connection_options.port_forwards,
1732                    connection_timeout: connection_options.connection_timeout,
1733                })
1734        });
1735    }
1736
1737    fn edit_in_dev_container_json(
1738        &mut self,
1739        config: Option<DevContainerConfig>,
1740        window: &mut Window,
1741        cx: &mut Context<Self>,
1742    ) {
1743        let Some(workspace) = self.workspace.upgrade() else {
1744            cx.emit(DismissEvent);
1745            cx.notify();
1746            return;
1747        };
1748
1749        let config_path = config
1750            .map(|c| c.config_path)
1751            .unwrap_or_else(|| PathBuf::from(".devcontainer/devcontainer.json"));
1752
1753        workspace.update(cx, |workspace, cx| {
1754            let project = workspace.project().clone();
1755
1756            let worktree = project
1757                .read(cx)
1758                .visible_worktrees(cx)
1759                .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
1760
1761            if let Some(worktree) = worktree {
1762                let tree_id = worktree.read(cx).id();
1763                let devcontainer_path =
1764                    match RelPath::new(&config_path, util::paths::PathStyle::Posix) {
1765                        Ok(path) => path.into_owned(),
1766                        Err(error) => {
1767                            log::error!(
1768                                "Invalid devcontainer path: {} - {}",
1769                                config_path.display(),
1770                                error
1771                            );
1772                            return;
1773                        }
1774                    };
1775                cx.spawn_in(window, async move |workspace, cx| {
1776                    workspace
1777                        .update_in(cx, |workspace, window, cx| {
1778                            workspace.open_path(
1779                                (tree_id, devcontainer_path),
1780                                None,
1781                                true,
1782                                window,
1783                                cx,
1784                            )
1785                        })?
1786                        .await
1787                })
1788                .detach();
1789            } else {
1790                return;
1791            }
1792        });
1793        cx.emit(DismissEvent);
1794        cx.notify();
1795    }
1796
1797    fn init_dev_container_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1798        let configs = self
1799            .workspace
1800            .read_with(cx, |workspace, cx| find_devcontainer_configs(workspace, cx))
1801            .unwrap_or_default();
1802
1803        if configs.len() > 1 {
1804            let delegate = DevContainerPickerDelegate::new(configs, cx.weak_entity());
1805            self.dev_container_picker =
1806                Some(cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false)));
1807
1808            let state =
1809                CreateRemoteDevContainer::new(DevContainerCreationProgress::SelectingConfig, cx);
1810            self.mode = Mode::CreateRemoteDevContainer(state);
1811            cx.notify();
1812        } else {
1813            let config = configs.into_iter().next();
1814            self.open_dev_container(config, window, cx);
1815            self.view_in_progress_dev_container(window, cx);
1816        }
1817    }
1818
1819    fn open_dev_container(
1820        &self,
1821        config: Option<DevContainerConfig>,
1822        window: &mut Window,
1823        cx: &mut Context<Self>,
1824    ) {
1825        let Some((app_state, context)) = self
1826            .workspace
1827            .read_with(cx, |workspace, cx| {
1828                let app_state = workspace.app_state().clone();
1829                let context = DevContainerContext::from_workspace(workspace, cx)?;
1830                Some((app_state, context))
1831            })
1832            .log_err()
1833            .flatten()
1834        else {
1835            log::error!("No active project directory for Dev Container");
1836            return;
1837        };
1838
1839        let replace_window = window.window_handle().downcast::<MultiWorkspace>();
1840
1841        cx.spawn_in(window, async move |entity, cx| {
1842            let (connection, starting_dir) =
1843                match start_dev_container_with_config(context, config).await {
1844                    Ok((c, s)) => (Connection::DevContainer(c), s),
1845                    Err(e) => {
1846                        log::error!("Failed to start dev container: {:?}", e);
1847                        cx.prompt(
1848                            gpui::PromptLevel::Critical,
1849                            "Failed to start Dev Container. See logs for details",
1850                            Some(&format!("{e}")),
1851                            &["Ok"],
1852                        )
1853                        .await
1854                        .ok();
1855                        entity
1856                            .update_in(cx, |remote_server_projects, window, cx| {
1857                                remote_server_projects.mode =
1858                                    Mode::CreateRemoteDevContainer(CreateRemoteDevContainer::new(
1859                                        DevContainerCreationProgress::Error(format!("{e}")),
1860                                        cx,
1861                                    ));
1862                                remote_server_projects.focus_handle(cx).focus(window, cx);
1863                            })
1864                            .ok();
1865                        return;
1866                    }
1867                };
1868            entity
1869                .update(cx, |_, cx| {
1870                    cx.emit(DismissEvent);
1871                })
1872                .log_err();
1873
1874            let result = open_remote_project(
1875                connection.into(),
1876                vec![starting_dir].into_iter().map(PathBuf::from).collect(),
1877                app_state,
1878                OpenOptions {
1879                    replace_window,
1880                    ..OpenOptions::default()
1881                },
1882                cx,
1883            )
1884            .await;
1885            if let Err(e) = result {
1886                log::error!("Failed to connect: {e:#}");
1887                cx.prompt(
1888                    gpui::PromptLevel::Critical,
1889                    "Failed to connect",
1890                    Some(&e.to_string()),
1891                    &["Ok"],
1892                )
1893                .await
1894                .ok();
1895            }
1896        })
1897        .detach();
1898    }
1899
1900    fn render_create_dev_container(
1901        &self,
1902        state: &CreateRemoteDevContainer,
1903        window: &mut Window,
1904        cx: &mut Context<Self>,
1905    ) -> impl IntoElement {
1906        match &state.progress {
1907            DevContainerCreationProgress::Error(message) => {
1908                let view = Navigable::new(
1909                    div()
1910                        .child(
1911                            div().track_focus(&self.focus_handle(cx)).size_full().child(
1912                                v_flex().py_1().child(
1913                                    ListItem::new("Error")
1914                                        .inset(true)
1915                                        .selectable(false)
1916                                        .spacing(ui::ListItemSpacing::Sparse)
1917                                        .start_slot(
1918                                            Icon::new(IconName::XCircle).color(Color::Error),
1919                                        )
1920                                        .child(Label::new("Error Creating Dev Container:"))
1921                                        .child(Label::new(message).buffer_font(cx)),
1922                                ),
1923                            ),
1924                        )
1925                        .child(ListSeparator)
1926                        .child(
1927                            div()
1928                                .id("devcontainer-see-log")
1929                                .track_focus(&state.view_logs_entry.focus_handle)
1930                                .on_action(cx.listener(|_, _: &menu::Confirm, window, cx| {
1931                                    window.dispatch_action(Box::new(OpenLog), cx);
1932                                    cx.emit(DismissEvent);
1933                                    cx.notify();
1934                                }))
1935                                .child(
1936                                    ListItem::new("li-devcontainer-see-log")
1937                                        .toggle_state(
1938                                            state
1939                                                .view_logs_entry
1940                                                .focus_handle
1941                                                .contains_focused(window, cx),
1942                                        )
1943                                        .inset(true)
1944                                        .spacing(ui::ListItemSpacing::Sparse)
1945                                        .start_slot(Icon::new(IconName::File).color(Color::Muted))
1946                                        .child(Label::new("Open Zed Log"))
1947                                        .on_click(cx.listener(|_, _, window, cx| {
1948                                            window.dispatch_action(Box::new(OpenLog), cx);
1949                                            cx.emit(DismissEvent);
1950                                            cx.notify();
1951                                        })),
1952                                ),
1953                        )
1954                        .child(
1955                            div()
1956                                .id("devcontainer-go-back")
1957                                .track_focus(&state.back_entry.focus_handle)
1958                                .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1959                                    this.cancel(&menu::Cancel, window, cx);
1960                                    cx.notify();
1961                                }))
1962                                .child(
1963                                    ListItem::new("li-devcontainer-go-back")
1964                                        .toggle_state(
1965                                            state
1966                                                .back_entry
1967                                                .focus_handle
1968                                                .contains_focused(window, cx),
1969                                        )
1970                                        .inset(true)
1971                                        .spacing(ui::ListItemSpacing::Sparse)
1972                                        .start_slot(Icon::new(IconName::Exit).color(Color::Muted))
1973                                        .child(Label::new("Exit"))
1974                                        .on_click(cx.listener(|this, _, window, cx| {
1975                                            this.cancel(&menu::Cancel, window, cx);
1976                                            cx.notify();
1977                                        })),
1978                                ),
1979                        )
1980                        .into_any_element(),
1981                )
1982                .entry(state.view_logs_entry.clone())
1983                .entry(state.back_entry.clone());
1984                view.render(window, cx).into_any_element()
1985            }
1986            DevContainerCreationProgress::SelectingConfig => {
1987                self.render_config_selection(window, cx).into_any_element()
1988            }
1989            DevContainerCreationProgress::Creating => {
1990                self.focus_handle(cx).focus(window, cx);
1991                div()
1992                    .track_focus(&self.focus_handle(cx))
1993                    .size_full()
1994                    .child(
1995                        v_flex()
1996                            .pb_1()
1997                            .child(
1998                                ModalHeader::new().child(
1999                                    Headline::new("Dev Containers").size(HeadlineSize::XSmall),
2000                                ),
2001                            )
2002                            .child(ListSeparator)
2003                            .child(
2004                                ListItem::new("creating")
2005                                    .inset(true)
2006                                    .spacing(ui::ListItemSpacing::Sparse)
2007                                    .disabled(true)
2008                                    .start_slot(
2009                                        Icon::new(IconName::ArrowCircle)
2010                                            .color(Color::Muted)
2011                                            .with_rotate_animation(2),
2012                                    )
2013                                    .child(
2014                                        h_flex()
2015                                            .opacity(0.6)
2016                                            .gap_1()
2017                                            .child(Label::new("Creating Dev Container"))
2018                                            .child(LoadingLabel::new("")),
2019                                    ),
2020                            ),
2021                    )
2022                    .into_any_element()
2023            }
2024        }
2025    }
2026
2027    fn render_config_selection(
2028        &self,
2029        window: &mut Window,
2030        cx: &mut Context<Self>,
2031    ) -> impl IntoElement {
2032        let Some(picker) = &self.dev_container_picker else {
2033            return div().into_any_element();
2034        };
2035
2036        let content = v_flex().pb_1().child(picker.clone().into_any_element());
2037
2038        picker.focus_handle(cx).focus(window, cx);
2039
2040        content.into_any_element()
2041    }
2042
2043    fn render_create_remote_server(
2044        &self,
2045        state: &CreateRemoteServer,
2046        window: &mut Window,
2047        cx: &mut Context<Self>,
2048    ) -> impl IntoElement {
2049        let ssh_prompt = state.ssh_prompt.clone();
2050
2051        state.address_editor.update(cx, |editor, cx| {
2052            if editor.text(cx).is_empty() {
2053                editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
2054            }
2055        });
2056
2057        let theme = cx.theme();
2058
2059        v_flex()
2060            .track_focus(&self.focus_handle(cx))
2061            .id("create-remote-server")
2062            .overflow_hidden()
2063            .size_full()
2064            .flex_1()
2065            .child(
2066                div()
2067                    .p_2()
2068                    .border_b_1()
2069                    .border_color(theme.colors().border_variant)
2070                    .child(state.address_editor.clone()),
2071            )
2072            .child(
2073                h_flex()
2074                    .bg(theme.colors().editor_background)
2075                    .rounded_b_sm()
2076                    .w_full()
2077                    .map(|this| {
2078                        if let Some(ssh_prompt) = ssh_prompt {
2079                            this.child(h_flex().w_full().child(ssh_prompt))
2080                        } else if let Some(address_error) = &state.address_error {
2081                            this.child(
2082                                h_flex().p_2().w_full().gap_2().child(
2083                                    Label::new(address_error.clone())
2084                                        .size(LabelSize::Small)
2085                                        .color(Color::Error),
2086                                ),
2087                            )
2088                        } else {
2089                            this.child(
2090                                h_flex()
2091                                    .p_2()
2092                                    .w_full()
2093                                    .gap_1()
2094                                    .child(
2095                                        Label::new(
2096                                            "Enter the command you use to SSH into this server.",
2097                                        )
2098                                        .color(Color::Muted)
2099                                        .size(LabelSize::Small),
2100                                    )
2101                                    .child(
2102                                        Button::new("learn-more", "Learn More")
2103                                            .label_size(LabelSize::Small)
2104                                            .icon(IconName::ArrowUpRight)
2105                                            .icon_size(IconSize::XSmall)
2106                                            .on_click(|_, _, cx| {
2107                                                cx.open_url(
2108                                                    "https://zed.dev/docs/remote-development",
2109                                                );
2110                                            }),
2111                                    ),
2112                            )
2113                        }
2114                    }),
2115            )
2116    }
2117
2118    #[cfg(target_os = "windows")]
2119    fn render_add_wsl_distro(
2120        &self,
2121        state: &AddWslDistro,
2122        window: &mut Window,
2123        cx: &mut Context<Self>,
2124    ) -> impl IntoElement {
2125        let connection_prompt = state.connection_prompt.clone();
2126
2127        state.picker.update(cx, |picker, cx| {
2128            picker.focus_handle(cx).focus(window, cx);
2129        });
2130
2131        v_flex()
2132            .id("add-wsl-distro")
2133            .overflow_hidden()
2134            .size_full()
2135            .flex_1()
2136            .map(|this| {
2137                if let Some(connection_prompt) = connection_prompt {
2138                    this.child(connection_prompt)
2139                } else {
2140                    this.child(state.picker.clone())
2141                }
2142            })
2143    }
2144
2145    fn render_view_options(
2146        &mut self,
2147        options: ViewServerOptionsState,
2148        window: &mut Window,
2149        cx: &mut Context<Self>,
2150    ) -> impl IntoElement {
2151        let last_entry = options.entries().last().unwrap();
2152
2153        let mut view = Navigable::new(
2154            div()
2155                .track_focus(&self.focus_handle(cx))
2156                .size_full()
2157                .child(match &options {
2158                    ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
2159                        connection_string: connection.host.to_string().into(),
2160                        paths: Default::default(),
2161                        nickname: connection.nickname.clone().map(|s| s.into()),
2162                        is_wsl: false,
2163                        is_devcontainer: false,
2164                    }
2165                    .render(window, cx)
2166                    .into_any_element(),
2167                    ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
2168                        connection_string: connection.distro_name.clone().into(),
2169                        paths: Default::default(),
2170                        nickname: None,
2171                        is_wsl: true,
2172                        is_devcontainer: false,
2173                    }
2174                    .render(window, cx)
2175                    .into_any_element(),
2176                })
2177                .child(
2178                    v_flex()
2179                        .pb_1()
2180                        .child(ListSeparator)
2181                        .map(|this| match &options {
2182                            ViewServerOptionsState::Ssh {
2183                                connection,
2184                                entries,
2185                                server_index,
2186                            } => this.child(self.render_edit_ssh(
2187                                connection,
2188                                *server_index,
2189                                entries,
2190                                window,
2191                                cx,
2192                            )),
2193                            ViewServerOptionsState::Wsl {
2194                                connection,
2195                                entries,
2196                                server_index,
2197                            } => this.child(self.render_edit_wsl(
2198                                connection,
2199                                *server_index,
2200                                entries,
2201                                window,
2202                                cx,
2203                            )),
2204                        })
2205                        .child(ListSeparator)
2206                        .child({
2207                            div()
2208                                .id("ssh-options-copy-server-address")
2209                                .track_focus(&last_entry.focus_handle)
2210                                .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2211                                    this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2212                                    cx.focus_self(window);
2213                                    cx.notify();
2214                                }))
2215                                .child(
2216                                    ListItem::new("go-back")
2217                                        .toggle_state(
2218                                            last_entry.focus_handle.contains_focused(window, cx),
2219                                        )
2220                                        .inset(true)
2221                                        .spacing(ui::ListItemSpacing::Sparse)
2222                                        .start_slot(
2223                                            Icon::new(IconName::ArrowLeft).color(Color::Muted),
2224                                        )
2225                                        .child(Label::new("Go Back"))
2226                                        .on_click(cx.listener(|this, _, window, cx| {
2227                                            this.mode =
2228                                                Mode::default_mode(&this.ssh_config_servers, cx);
2229                                            cx.focus_self(window);
2230                                            cx.notify()
2231                                        })),
2232                                )
2233                        }),
2234                )
2235                .into_any_element(),
2236        );
2237
2238        for entry in options.entries() {
2239            view = view.entry(entry.clone());
2240        }
2241
2242        view.render(window, cx).into_any_element()
2243    }
2244
2245    fn render_edit_wsl(
2246        &self,
2247        connection: &WslConnectionOptions,
2248        index: WslServerIndex,
2249        entries: &[NavigableEntry],
2250        window: &mut Window,
2251        cx: &mut Context<Self>,
2252    ) -> impl IntoElement {
2253        let distro_name = SharedString::new(connection.distro_name.clone());
2254
2255        v_flex().child({
2256            fn remove_wsl_distro(
2257                remote_servers: Entity<RemoteServerProjects>,
2258                index: WslServerIndex,
2259                distro_name: SharedString,
2260                window: &mut Window,
2261                cx: &mut App,
2262            ) {
2263                let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
2264
2265                let confirmation = window.prompt(
2266                    PromptLevel::Warning,
2267                    &prompt_message,
2268                    None,
2269                    &["Yes, remove it", "No, keep it"],
2270                    cx,
2271                );
2272
2273                cx.spawn(async move |cx| {
2274                    if confirmation.await.ok() == Some(0) {
2275                        remote_servers.update(cx, |this, cx| {
2276                            this.delete_wsl_distro(index, cx);
2277                        });
2278                        remote_servers.update(cx, |this, cx| {
2279                            this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2280                            cx.notify();
2281                        });
2282                    }
2283                    anyhow::Ok(())
2284                })
2285                .detach_and_log_err(cx);
2286            }
2287            div()
2288                .id("wsl-options-remove-distro")
2289                .track_focus(&entries[0].focus_handle)
2290                .on_action(cx.listener({
2291                    let distro_name = distro_name.clone();
2292                    move |_, _: &menu::Confirm, window, cx| {
2293                        remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2294                        cx.focus_self(window);
2295                    }
2296                }))
2297                .child(
2298                    ListItem::new("remove-distro")
2299                        .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2300                        .inset(true)
2301                        .spacing(ui::ListItemSpacing::Sparse)
2302                        .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2303                        .child(Label::new("Remove Distro").color(Color::Error))
2304                        .on_click(cx.listener(move |_, _, window, cx| {
2305                            remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2306                            cx.focus_self(window);
2307                        })),
2308                )
2309        })
2310    }
2311
2312    fn render_edit_ssh(
2313        &self,
2314        connection: &SshConnectionOptions,
2315        index: SshServerIndex,
2316        entries: &[NavigableEntry],
2317        window: &mut Window,
2318        cx: &mut Context<Self>,
2319    ) -> impl IntoElement {
2320        let connection_string = SharedString::new(connection.host.to_string());
2321
2322        v_flex()
2323            .child({
2324                let label = if connection.nickname.is_some() {
2325                    "Edit Nickname"
2326                } else {
2327                    "Add Nickname to Server"
2328                };
2329                div()
2330                    .id("ssh-options-add-nickname")
2331                    .track_focus(&entries[0].focus_handle)
2332                    .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
2333                        this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
2334                        cx.notify();
2335                    }))
2336                    .child(
2337                        ListItem::new("add-nickname")
2338                            .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2339                            .inset(true)
2340                            .spacing(ui::ListItemSpacing::Sparse)
2341                            .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
2342                            .child(Label::new(label))
2343                            .on_click(cx.listener(move |this, _, window, cx| {
2344                                this.mode =
2345                                    Mode::EditNickname(EditNicknameState::new(index, window, cx));
2346                                cx.notify();
2347                            })),
2348                    )
2349            })
2350            .child({
2351                let workspace = self.workspace.clone();
2352                fn callback(
2353                    workspace: WeakEntity<Workspace>,
2354                    connection_string: SharedString,
2355                    cx: &mut App,
2356                ) {
2357                    cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
2358                    workspace
2359                        .update(cx, |this, cx| {
2360                            struct SshServerAddressCopiedToClipboard;
2361                            let notification = format!(
2362                                "Copied server address ({}) to clipboard",
2363                                connection_string
2364                            );
2365
2366                            this.show_toast(
2367                                Toast::new(
2368                                    NotificationId::composite::<SshServerAddressCopiedToClipboard>(
2369                                        connection_string.clone(),
2370                                    ),
2371                                    notification,
2372                                )
2373                                .autohide(),
2374                                cx,
2375                            );
2376                        })
2377                        .ok();
2378                }
2379                div()
2380                    .id("ssh-options-copy-server-address")
2381                    .track_focus(&entries[1].focus_handle)
2382                    .on_action({
2383                        let connection_string = connection_string.clone();
2384                        let workspace = self.workspace.clone();
2385                        move |_: &menu::Confirm, _, cx| {
2386                            callback(workspace.clone(), connection_string.clone(), cx);
2387                        }
2388                    })
2389                    .child(
2390                        ListItem::new("copy-server-address")
2391                            .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
2392                            .inset(true)
2393                            .spacing(ui::ListItemSpacing::Sparse)
2394                            .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
2395                            .child(Label::new("Copy Server Address"))
2396                            .end_hover_slot(
2397                                Label::new(connection_string.clone()).color(Color::Muted),
2398                            )
2399                            .on_click({
2400                                let connection_string = connection_string.clone();
2401                                move |_, _, cx| {
2402                                    callback(workspace.clone(), connection_string.clone(), cx);
2403                                }
2404                            }),
2405                    )
2406            })
2407            .child({
2408                fn remove_ssh_server(
2409                    remote_servers: Entity<RemoteServerProjects>,
2410                    index: SshServerIndex,
2411                    connection_string: SharedString,
2412                    window: &mut Window,
2413                    cx: &mut App,
2414                ) {
2415                    let prompt_message = format!("Remove server `{}`?", connection_string);
2416
2417                    let confirmation = window.prompt(
2418                        PromptLevel::Warning,
2419                        &prompt_message,
2420                        None,
2421                        &["Yes, remove it", "No, keep it"],
2422                        cx,
2423                    );
2424
2425                    cx.spawn(async move |cx| {
2426                        if confirmation.await.ok() == Some(0) {
2427                            remote_servers.update(cx, |this, cx| {
2428                                this.delete_ssh_server(index, cx);
2429                            });
2430                            remote_servers.update(cx, |this, cx| {
2431                                this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2432                                cx.notify();
2433                            });
2434                        }
2435                        anyhow::Ok(())
2436                    })
2437                    .detach_and_log_err(cx);
2438                }
2439                div()
2440                    .id("ssh-options-copy-server-address")
2441                    .track_focus(&entries[2].focus_handle)
2442                    .on_action(cx.listener({
2443                        let connection_string = connection_string.clone();
2444                        move |_, _: &menu::Confirm, window, cx| {
2445                            remove_ssh_server(
2446                                cx.entity(),
2447                                index,
2448                                connection_string.clone(),
2449                                window,
2450                                cx,
2451                            );
2452                            cx.focus_self(window);
2453                        }
2454                    }))
2455                    .child(
2456                        ListItem::new("remove-server")
2457                            .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
2458                            .inset(true)
2459                            .spacing(ui::ListItemSpacing::Sparse)
2460                            .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2461                            .child(Label::new("Remove Server").color(Color::Error))
2462                            .on_click(cx.listener(move |_, _, window, cx| {
2463                                remove_ssh_server(
2464                                    cx.entity(),
2465                                    index,
2466                                    connection_string.clone(),
2467                                    window,
2468                                    cx,
2469                                );
2470                                cx.focus_self(window);
2471                            })),
2472                    )
2473            })
2474    }
2475
2476    fn render_edit_nickname(
2477        &self,
2478        state: &EditNicknameState,
2479        window: &mut Window,
2480        cx: &mut Context<Self>,
2481    ) -> impl IntoElement {
2482        let Some(connection) = RemoteSettings::get_global(cx)
2483            .ssh_connections()
2484            .nth(state.index.0)
2485        else {
2486            return v_flex()
2487                .id("ssh-edit-nickname")
2488                .track_focus(&self.focus_handle(cx));
2489        };
2490
2491        let connection_string = connection.host.clone();
2492        let nickname = connection.nickname.map(|s| s.into());
2493
2494        v_flex()
2495            .id("ssh-edit-nickname")
2496            .track_focus(&self.focus_handle(cx))
2497            .child(
2498                SshConnectionHeader {
2499                    connection_string: connection_string.into(),
2500                    paths: Default::default(),
2501                    nickname,
2502                    is_wsl: false,
2503                    is_devcontainer: false,
2504                }
2505                .render(window, cx),
2506            )
2507            .child(
2508                h_flex()
2509                    .p_2()
2510                    .border_t_1()
2511                    .border_color(cx.theme().colors().border_variant)
2512                    .child(state.editor.clone()),
2513            )
2514    }
2515
2516    fn render_default(
2517        &mut self,
2518        mut state: DefaultState,
2519        window: &mut Window,
2520        cx: &mut Context<Self>,
2521    ) -> impl IntoElement {
2522        let ssh_settings = RemoteSettings::get_global(cx);
2523        let mut should_rebuild = false;
2524
2525        let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
2526            .servers
2527            .iter()
2528            .filter_map(|server| match server {
2529                RemoteEntry::Project {
2530                    connection: Connection::Ssh(connection),
2531                    ..
2532                } => Some(connection),
2533                _ => None,
2534            }));
2535
2536        let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
2537            .servers
2538            .iter()
2539            .filter_map(|server| match server {
2540                RemoteEntry::Project {
2541                    connection: Connection::Wsl(connection),
2542                    ..
2543                } => Some(connection),
2544                _ => None,
2545            }));
2546
2547        if ssh_connections_changed || wsl_connections_changed {
2548            should_rebuild = true;
2549        };
2550
2551        if !should_rebuild && ssh_settings.read_ssh_config {
2552            let current_ssh_hosts: BTreeSet<SharedString> = state
2553                .servers
2554                .iter()
2555                .filter_map(|server| match server {
2556                    RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2557                    _ => None,
2558                })
2559                .collect();
2560            let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2561            for server in &state.servers {
2562                if let RemoteEntry::Project {
2563                    connection: Connection::Ssh(connection),
2564                    ..
2565                } = server
2566                {
2567                    expected_ssh_hosts.remove(connection.host.as_str());
2568                }
2569            }
2570            should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2571        }
2572
2573        if should_rebuild {
2574            self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2575            if let Mode::Default(new_state) = &self.mode {
2576                state = new_state.clone();
2577            }
2578        }
2579
2580        let connect_button = div()
2581            .id("ssh-connect-new-server-container")
2582            .track_focus(&state.add_new_server.focus_handle)
2583            .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2584            .child(
2585                ListItem::new("register-remote-server-button")
2586                    .toggle_state(
2587                        state
2588                            .add_new_server
2589                            .focus_handle
2590                            .contains_focused(window, cx),
2591                    )
2592                    .inset(true)
2593                    .spacing(ui::ListItemSpacing::Sparse)
2594                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2595                    .child(Label::new("Connect SSH Server"))
2596                    .on_click(cx.listener(|this, _, window, cx| {
2597                        let state = CreateRemoteServer::new(window, cx);
2598                        this.mode = Mode::CreateRemoteServer(state);
2599
2600                        cx.notify();
2601                    })),
2602            )
2603            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2604                let state = CreateRemoteServer::new(window, cx);
2605                this.mode = Mode::CreateRemoteServer(state);
2606
2607                cx.notify();
2608            }));
2609
2610        let connect_dev_container_button = div()
2611            .id("connect-new-dev-container")
2612            .track_focus(&state.add_new_devcontainer.focus_handle)
2613            .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
2614            .child(
2615                ListItem::new("register-dev-container-button")
2616                    .toggle_state(
2617                        state
2618                            .add_new_devcontainer
2619                            .focus_handle
2620                            .contains_focused(window, cx),
2621                    )
2622                    .inset(true)
2623                    .spacing(ui::ListItemSpacing::Sparse)
2624                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2625                    .child(Label::new("Connect Dev Container"))
2626                    .on_click(cx.listener(|this, _, window, cx| {
2627                        this.init_dev_container_mode(window, cx);
2628                    })),
2629            )
2630            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2631                this.init_dev_container_mode(window, cx);
2632            }));
2633
2634        #[cfg(target_os = "windows")]
2635        let wsl_connect_button = div()
2636            .id("wsl-connect-new-server")
2637            .track_focus(&state.add_new_wsl.focus_handle)
2638            .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2639            .child(
2640                ListItem::new("wsl-add-new-server")
2641                    .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2642                    .inset(true)
2643                    .spacing(ui::ListItemSpacing::Sparse)
2644                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2645                    .child(Label::new("Add WSL Distro"))
2646                    .on_click(cx.listener(|this, _, window, cx| {
2647                        let state = AddWslDistro::new(window, cx);
2648                        this.mode = Mode::AddWslDistro(state);
2649
2650                        cx.notify();
2651                    })),
2652            )
2653            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2654                let state = AddWslDistro::new(window, cx);
2655                this.mode = Mode::AddWslDistro(state);
2656
2657                cx.notify();
2658            }));
2659
2660        let has_open_project = self
2661            .workspace
2662            .upgrade()
2663            .map(|workspace| {
2664                workspace
2665                    .read(cx)
2666                    .project()
2667                    .read(cx)
2668                    .visible_worktrees(cx)
2669                    .next()
2670                    .is_some()
2671            })
2672            .unwrap_or(false);
2673
2674        // We cannot currently connect a dev container from within a remote server due to the remote_server architecture
2675        let is_local = self
2676            .workspace
2677            .upgrade()
2678            .map(|workspace| workspace.read(cx).project().read(cx).is_local())
2679            .unwrap_or(true);
2680
2681        let modal_section = v_flex()
2682            .track_focus(&self.focus_handle(cx))
2683            .id("ssh-server-list")
2684            .overflow_y_scroll()
2685            .track_scroll(&state.scroll_handle)
2686            .size_full()
2687            .child(connect_button)
2688            .when(has_open_project && is_local, |this| {
2689                this.child(connect_dev_container_button)
2690            });
2691
2692        #[cfg(target_os = "windows")]
2693        let modal_section = modal_section.child(wsl_connect_button);
2694        #[cfg(not(target_os = "windows"))]
2695        let modal_section = modal_section;
2696
2697        let mut modal_section = Navigable::new(
2698            modal_section
2699                .child(
2700                    List::new()
2701                        .empty_message(
2702                            h_flex()
2703                                .size_full()
2704                                .p_2()
2705                                .justify_center()
2706                                .border_t_1()
2707                                .border_color(cx.theme().colors().border_variant)
2708                                .child(
2709                                    Label::new("No remote servers registered yet.")
2710                                        .color(Color::Muted),
2711                                )
2712                                .into_any_element(),
2713                        )
2714                        .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2715                            self.render_remote_connection(ix, connection.clone(), window, cx)
2716                                .into_any_element()
2717                        })),
2718                )
2719                .into_any_element(),
2720        )
2721        .entry(state.add_new_server.clone());
2722
2723        if has_open_project && is_local {
2724            modal_section = modal_section.entry(state.add_new_devcontainer.clone());
2725        }
2726
2727        if cfg!(target_os = "windows") {
2728            modal_section = modal_section.entry(state.add_new_wsl.clone());
2729        }
2730
2731        for server in &state.servers {
2732            match server {
2733                RemoteEntry::Project {
2734                    open_folder,
2735                    projects,
2736                    configure,
2737                    ..
2738                } => {
2739                    for (navigation_state, _) in projects {
2740                        modal_section = modal_section.entry(navigation_state.clone());
2741                    }
2742                    modal_section = modal_section
2743                        .entry(open_folder.clone())
2744                        .entry(configure.clone());
2745                }
2746                RemoteEntry::SshConfig { open_folder, .. } => {
2747                    modal_section = modal_section.entry(open_folder.clone());
2748                }
2749            }
2750        }
2751        let mut modal_section = modal_section.render(window, cx).into_any_element();
2752
2753        let is_project_selected = state.servers.iter().any(|server| match server {
2754            RemoteEntry::Project { projects, .. } => projects
2755                .iter()
2756                .any(|(entry, _)| entry.focus_handle.contains_focused(window, cx)),
2757            RemoteEntry::SshConfig { .. } => false,
2758        });
2759
2760        Modal::new("remote-projects", None)
2761            .header(ModalHeader::new().headline("Remote Projects"))
2762            .section(
2763                Section::new().padded(false).child(
2764                    v_flex()
2765                        .min_h(rems(20.))
2766                        .size_full()
2767                        .relative()
2768                        .child(ListSeparator)
2769                        .child(
2770                            canvas(
2771                                |bounds, window, cx| {
2772                                    modal_section.prepaint_as_root(
2773                                        bounds.origin,
2774                                        bounds.size.into(),
2775                                        window,
2776                                        cx,
2777                                    );
2778                                    modal_section
2779                                },
2780                                |_, mut modal_section, window, cx| {
2781                                    modal_section.paint(window, cx);
2782                                },
2783                            )
2784                            .size_full(),
2785                        )
2786                        .vertical_scrollbar_for(&state.scroll_handle, window, cx),
2787                ),
2788            )
2789            .footer(ModalFooter::new().end_slot({
2790                let confirm_button = |label: SharedString| {
2791                    Button::new("select", label)
2792                        .key_binding(KeyBinding::for_action(&menu::Confirm, cx))
2793                        .on_click(|_, window, cx| {
2794                            window.dispatch_action(menu::Confirm.boxed_clone(), cx)
2795                        })
2796                };
2797
2798                if is_project_selected {
2799                    h_flex()
2800                        .gap_1()
2801                        .child(
2802                            Button::new("open_new_window", "New Window")
2803                                .key_binding(KeyBinding::for_action(&menu::SecondaryConfirm, cx))
2804                                .on_click(|_, window, cx| {
2805                                    window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
2806                                }),
2807                        )
2808                        .child(confirm_button("Open".into()))
2809                        .into_any_element()
2810                } else {
2811                    confirm_button("Select".into()).into_any_element()
2812                }
2813            }))
2814            .into_any_element()
2815    }
2816
2817    fn create_host_from_ssh_config(
2818        &mut self,
2819        ssh_config_host: &SharedString,
2820        cx: &mut Context<'_, Self>,
2821    ) -> SshServerIndex {
2822        let new_ix = Arc::new(AtomicUsize::new(0));
2823
2824        let update_new_ix = new_ix.clone();
2825        self.update_settings_file(cx, move |settings, _| {
2826            update_new_ix.store(
2827                settings
2828                    .ssh_connections
2829                    .as_ref()
2830                    .map_or(0, |connections| connections.len()),
2831                atomic::Ordering::Release,
2832            );
2833        });
2834
2835        self.add_ssh_server(
2836            SshConnectionOptions {
2837                host: ssh_config_host.to_string().into(),
2838                ..SshConnectionOptions::default()
2839            },
2840            cx,
2841        );
2842        self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2843        SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2844    }
2845}
2846
2847fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2848    enum ConfigSource {
2849        User(String),
2850        Global(String),
2851    }
2852
2853    let mut streams = Vec::new();
2854    let mut tasks = Vec::new();
2855
2856    // Setup User Watcher
2857    let user_path = user_ssh_config_file();
2858    info!("SSH: Watching User Config at: {:?}", user_path);
2859
2860    // We clone 'fs' here because we might need it again for the global watcher.
2861    let (user_s, user_t) = watch_config_file(cx.background_executor(), fs.clone(), user_path);
2862    streams.push(user_s.map(ConfigSource::User).boxed());
2863    tasks.push(user_t);
2864
2865    // Setup Global Watcher
2866    if let Some(gp) = global_ssh_config_file() {
2867        info!("SSH: Watching Global Config at: {:?}", gp);
2868        let (global_s, global_t) =
2869            watch_config_file(cx.background_executor(), fs, gp.to_path_buf());
2870        streams.push(global_s.map(ConfigSource::Global).boxed());
2871        tasks.push(global_t);
2872    } else {
2873        debug!("SSH: No Global Config defined.");
2874    }
2875
2876    // Combine into a single stream so that only one is parsed at once.
2877    let mut merged_stream = futures::stream::select_all(streams);
2878
2879    cx.spawn(async move |remote_server_projects, cx| {
2880        let _tasks = tasks; // Keeps the background watchers alive
2881        let mut global_hosts = BTreeSet::default();
2882        let mut user_hosts = BTreeSet::default();
2883
2884        while let Some(event) = merged_stream.next().await {
2885            match event {
2886                ConfigSource::Global(content) => {
2887                    global_hosts = parse_ssh_config_hosts(&content);
2888                }
2889                ConfigSource::User(content) => {
2890                    user_hosts = parse_ssh_config_hosts(&content);
2891                }
2892            }
2893
2894            // Sync to Model
2895            if remote_server_projects
2896                .update(cx, |project, cx| {
2897                    project.ssh_config_servers = global_hosts
2898                        .iter()
2899                        .chain(user_hosts.iter())
2900                        .map(SharedString::from)
2901                        .collect();
2902                    cx.notify();
2903                })
2904                .is_err()
2905            {
2906                return;
2907            }
2908        }
2909    })
2910}
2911
2912fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2913    element.read(cx).text(cx).trim().to_string()
2914}
2915
2916impl ModalView for RemoteServerProjects {}
2917
2918impl Focusable for RemoteServerProjects {
2919    fn focus_handle(&self, cx: &App) -> FocusHandle {
2920        match &self.mode {
2921            Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2922            _ => self.focus_handle.clone(),
2923        }
2924    }
2925}
2926
2927impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2928
2929impl Render for RemoteServerProjects {
2930    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2931        div()
2932            .elevation_3(cx)
2933            .w(rems(34.))
2934            .key_context("RemoteServerModal")
2935            .on_action(cx.listener(Self::cancel))
2936            .on_action(cx.listener(Self::confirm))
2937            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2938                this.focus_handle(cx).focus(window, cx);
2939            }))
2940            .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2941                if matches!(this.mode, Mode::Default(_)) {
2942                    cx.emit(DismissEvent)
2943                }
2944            }))
2945            .child(match &self.mode {
2946                Mode::Default(state) => self
2947                    .render_default(state.clone(), window, cx)
2948                    .into_any_element(),
2949                Mode::ViewServerOptions(state) => self
2950                    .render_view_options(state.clone(), window, cx)
2951                    .into_any_element(),
2952                Mode::ProjectPicker(element) => element.clone().into_any_element(),
2953                Mode::CreateRemoteServer(state) => self
2954                    .render_create_remote_server(state, window, cx)
2955                    .into_any_element(),
2956                Mode::CreateRemoteDevContainer(state) => self
2957                    .render_create_dev_container(state, window, cx)
2958                    .into_any_element(),
2959                Mode::EditNickname(state) => self
2960                    .render_edit_nickname(state, window, cx)
2961                    .into_any_element(),
2962                #[cfg(target_os = "windows")]
2963                Mode::AddWslDistro(state) => self
2964                    .render_add_wsl_distro(state, window, cx)
2965                    .into_any_element(),
2966            })
2967    }
2968}