remote_servers.rs

   1use std::any::Any;
   2use std::borrow::Cow;
   3use std::collections::BTreeSet;
   4use std::path::PathBuf;
   5use std::rc::Rc;
   6use std::sync::Arc;
   7use std::sync::atomic;
   8use std::sync::atomic::AtomicUsize;
   9
  10use editor::Editor;
  11use file_finder::OpenPathDelegate;
  12use futures::FutureExt;
  13use futures::channel::oneshot;
  14use futures::future::Shared;
  15use futures::select;
  16use gpui::ClickEvent;
  17use gpui::ClipboardItem;
  18use gpui::Subscription;
  19use gpui::Task;
  20use gpui::WeakEntity;
  21use gpui::canvas;
  22use gpui::{
  23    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
  24    PromptLevel, ScrollHandle, Window,
  25};
  26use paths::global_ssh_config_file;
  27use paths::user_ssh_config_file;
  28use picker::Picker;
  29use project::Fs;
  30use project::Project;
  31use remote::SshConnectionOptions;
  32use remote::SshRemoteClient;
  33use remote::ssh_session::ConnectionIdentifier;
  34use settings::Settings;
  35use settings::SettingsStore;
  36use settings::update_settings_file;
  37use settings::watch_config_file;
  38use smol::stream::StreamExt as _;
  39use ui::Navigable;
  40use ui::NavigableEntry;
  41use ui::{
  42    IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState,
  43    Section, Tooltip, prelude::*,
  44};
  45use util::ResultExt;
  46use workspace::OpenOptions;
  47use workspace::Toast;
  48use workspace::notifications::NotificationId;
  49use workspace::{
  50    ModalView, Workspace, notifications::DetachAndPromptErr,
  51    open_ssh_project_with_existing_connection,
  52};
  53
  54use crate::ssh_config::parse_ssh_config_hosts;
  55use crate::ssh_connections::RemoteSettingsContent;
  56use crate::ssh_connections::SshConnection;
  57use crate::ssh_connections::SshConnectionHeader;
  58use crate::ssh_connections::SshConnectionModal;
  59use crate::ssh_connections::SshProject;
  60use crate::ssh_connections::SshPrompt;
  61use crate::ssh_connections::SshSettings;
  62use crate::ssh_connections::connect_over_ssh;
  63use crate::ssh_connections::open_ssh_project;
  64
  65mod navigation_base {}
  66pub struct RemoteServerProjects {
  67    mode: Mode,
  68    focus_handle: FocusHandle,
  69    workspace: WeakEntity<Workspace>,
  70    retained_connections: Vec<Entity<SshRemoteClient>>,
  71    ssh_config_updates: Task<()>,
  72    ssh_config_servers: BTreeSet<SharedString>,
  73    create_new_window: bool,
  74    _subscription: Subscription,
  75}
  76
  77struct CreateRemoteServer {
  78    address_editor: Entity<Editor>,
  79    address_error: Option<SharedString>,
  80    ssh_prompt: Option<Entity<SshPrompt>>,
  81    _creating: Option<Task<Option<()>>>,
  82}
  83
  84impl CreateRemoteServer {
  85    fn new(window: &mut Window, cx: &mut App) -> Self {
  86        let address_editor = cx.new(|cx| Editor::single_line(window, cx));
  87        address_editor.update(cx, |this, cx| {
  88            this.focus_handle(cx).focus(window);
  89        });
  90        Self {
  91            address_editor,
  92            address_error: None,
  93            ssh_prompt: None,
  94            _creating: None,
  95        }
  96    }
  97}
  98
  99struct ProjectPicker {
 100    connection_string: SharedString,
 101    nickname: Option<SharedString>,
 102    picker: Entity<Picker<OpenPathDelegate>>,
 103    _path_task: Shared<Task<Option<()>>>,
 104}
 105
 106struct EditNicknameState {
 107    index: usize,
 108    editor: Entity<Editor>,
 109}
 110
 111impl EditNicknameState {
 112    fn new(index: usize, window: &mut Window, cx: &mut App) -> Self {
 113        let this = Self {
 114            index,
 115            editor: cx.new(|cx| Editor::single_line(window, cx)),
 116        };
 117        let starting_text = SshSettings::get_global(cx)
 118            .ssh_connections()
 119            .nth(index)
 120            .and_then(|state| state.nickname.clone())
 121            .filter(|text| !text.is_empty());
 122        this.editor.update(cx, |this, cx| {
 123            this.set_placeholder_text("Add a nickname for this server", cx);
 124            if let Some(starting_text) = starting_text {
 125                this.set_text(starting_text, window, cx);
 126            }
 127        });
 128        this.editor.focus_handle(cx).focus(window);
 129        this
 130    }
 131}
 132
 133impl Focusable for ProjectPicker {
 134    fn focus_handle(&self, cx: &App) -> FocusHandle {
 135        self.picker.focus_handle(cx)
 136    }
 137}
 138
 139impl ProjectPicker {
 140    fn new(
 141        create_new_window: bool,
 142        ix: usize,
 143        connection: SshConnectionOptions,
 144        project: Entity<Project>,
 145        home_dir: PathBuf,
 146        workspace: WeakEntity<Workspace>,
 147        window: &mut Window,
 148        cx: &mut Context<RemoteServerProjects>,
 149    ) -> Entity<Self> {
 150        let (tx, rx) = oneshot::channel();
 151        let lister = project::DirectoryLister::Project(project.clone());
 152        let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
 153
 154        let picker = cx.new(|cx| {
 155            let picker = Picker::uniform_list(delegate, window, cx)
 156                .width(rems(34.))
 157                .modal(false);
 158            picker.set_query(home_dir.to_string_lossy().to_string(), window, cx);
 159            picker
 160        });
 161        let connection_string = connection.connection_string().into();
 162        let nickname = connection.nickname.clone().map(|nick| nick.into());
 163        let _path_task = cx
 164            .spawn_in(window, {
 165                let workspace = workspace.clone();
 166                async move |this, cx| {
 167                    let Ok(Some(paths)) = rx.await else {
 168                        workspace
 169                            .update_in(cx, |workspace, window, cx| {
 170                                let fs = workspace.project().read(cx).fs().clone();
 171                                let weak = cx.entity().downgrade();
 172                                workspace.toggle_modal(window, cx, |window, cx| {
 173                                    RemoteServerProjects::new(
 174                                        create_new_window,
 175                                        fs,
 176                                        window,
 177                                        weak,
 178                                        cx,
 179                                    )
 180                                });
 181                            })
 182                            .log_err()?;
 183                        return None;
 184                    };
 185
 186                    let app_state = workspace
 187                        .read_with(cx, |workspace, _| workspace.app_state().clone())
 188                        .ok()?;
 189
 190                    cx.update(|_, cx| {
 191                        let fs = app_state.fs.clone();
 192                        update_settings_file::<SshSettings>(fs, cx, {
 193                            let paths = paths
 194                                .iter()
 195                                .map(|path| path.to_string_lossy().to_string())
 196                                .collect();
 197                            move |setting, _| {
 198                                if let Some(server) = setting
 199                                    .ssh_connections
 200                                    .as_mut()
 201                                    .and_then(|connections| connections.get_mut(ix))
 202                                {
 203                                    server.projects.insert(SshProject { paths });
 204                                }
 205                            }
 206                        });
 207                    })
 208                    .log_err();
 209
 210                    let options = cx
 211                        .update(|_, cx| (app_state.build_window_options)(None, cx))
 212                        .log_err()?;
 213                    let window = cx
 214                        .open_window(options, |window, cx| {
 215                            cx.new(|cx| {
 216                                telemetry::event!("SSH Project Created");
 217                                Workspace::new(None, project.clone(), app_state.clone(), window, cx)
 218                            })
 219                        })
 220                        .log_err()?;
 221
 222                    open_ssh_project_with_existing_connection(
 223                        connection, project, paths, app_state, window, cx,
 224                    )
 225                    .await
 226                    .log_err();
 227
 228                    this.update(cx, |_, cx| {
 229                        cx.emit(DismissEvent);
 230                    })
 231                    .ok();
 232                    Some(())
 233                }
 234            })
 235            .shared();
 236        cx.new(|_| Self {
 237            _path_task,
 238            picker,
 239            connection_string,
 240            nickname,
 241        })
 242    }
 243}
 244
 245impl gpui::Render for ProjectPicker {
 246    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 247        v_flex()
 248            .child(
 249                SshConnectionHeader {
 250                    connection_string: self.connection_string.clone(),
 251                    paths: Default::default(),
 252                    nickname: self.nickname.clone(),
 253                }
 254                .render(window, cx),
 255            )
 256            .child(
 257                div()
 258                    .border_t_1()
 259                    .border_color(cx.theme().colors().border_variant)
 260                    .child(self.picker.clone()),
 261            )
 262    }
 263}
 264
 265#[derive(Clone)]
 266enum RemoteEntry {
 267    Project {
 268        open_folder: NavigableEntry,
 269        projects: Vec<(NavigableEntry, SshProject)>,
 270        configure: NavigableEntry,
 271        connection: SshConnection,
 272    },
 273    SshConfig {
 274        open_folder: NavigableEntry,
 275        host: SharedString,
 276    },
 277}
 278
 279impl RemoteEntry {
 280    fn is_from_zed(&self) -> bool {
 281        matches!(self, Self::Project { .. })
 282    }
 283
 284    fn connection(&self) -> Cow<'_, SshConnection> {
 285        match self {
 286            Self::Project { connection, .. } => Cow::Borrowed(connection),
 287            Self::SshConfig { host, .. } => Cow::Owned(SshConnection {
 288                host: host.clone(),
 289                ..SshConnection::default()
 290            }),
 291        }
 292    }
 293}
 294
 295#[derive(Clone)]
 296struct DefaultState {
 297    scrollbar: ScrollbarState,
 298    add_new_server: NavigableEntry,
 299    servers: Vec<RemoteEntry>,
 300}
 301
 302impl DefaultState {
 303    fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
 304        let handle = ScrollHandle::new();
 305        let scrollbar = ScrollbarState::new(handle.clone());
 306        let add_new_server = NavigableEntry::new(&handle, cx);
 307
 308        let ssh_settings = SshSettings::get_global(cx);
 309        let read_ssh_config = ssh_settings.read_ssh_config;
 310
 311        let mut servers: Vec<RemoteEntry> = ssh_settings
 312            .ssh_connections()
 313            .map(|connection| {
 314                let open_folder = NavigableEntry::new(&handle, cx);
 315                let configure = NavigableEntry::new(&handle, cx);
 316                let projects = connection
 317                    .projects
 318                    .iter()
 319                    .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
 320                    .collect();
 321                RemoteEntry::Project {
 322                    open_folder,
 323                    configure,
 324                    projects,
 325                    connection,
 326                }
 327            })
 328            .collect();
 329
 330        if read_ssh_config {
 331            let mut extra_servers_from_config = ssh_config_servers.clone();
 332            for server in &servers {
 333                if let RemoteEntry::Project { connection, .. } = server {
 334                    extra_servers_from_config.remove(&connection.host);
 335                }
 336            }
 337            servers.extend(extra_servers_from_config.into_iter().map(|host| {
 338                RemoteEntry::SshConfig {
 339                    open_folder: NavigableEntry::new(&handle, cx),
 340                    host,
 341                }
 342            }));
 343        }
 344
 345        Self {
 346            scrollbar,
 347            add_new_server,
 348            servers,
 349        }
 350    }
 351}
 352
 353#[derive(Clone)]
 354struct ViewServerOptionsState {
 355    server_index: usize,
 356    connection: SshConnection,
 357    entries: [NavigableEntry; 4],
 358}
 359enum Mode {
 360    Default(DefaultState),
 361    ViewServerOptions(ViewServerOptionsState),
 362    EditNickname(EditNicknameState),
 363    ProjectPicker(Entity<ProjectPicker>),
 364    CreateRemoteServer(CreateRemoteServer),
 365}
 366
 367impl Mode {
 368    fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
 369        Self::Default(DefaultState::new(ssh_config_servers, cx))
 370    }
 371}
 372impl RemoteServerProjects {
 373    pub fn new(
 374        create_new_window: bool,
 375        fs: Arc<dyn Fs>,
 376        window: &mut Window,
 377        workspace: WeakEntity<Workspace>,
 378        cx: &mut Context<Self>,
 379    ) -> Self {
 380        let focus_handle = cx.focus_handle();
 381        let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
 382        let ssh_config_updates = if read_ssh_config {
 383            spawn_ssh_config_watch(fs.clone(), cx)
 384        } else {
 385            Task::ready(())
 386        };
 387
 388        let mut base_style = window.text_style();
 389        base_style.refine(&gpui::TextStyleRefinement {
 390            color: Some(cx.theme().colors().editor_foreground),
 391            ..Default::default()
 392        });
 393
 394        let _subscription =
 395            cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
 396                let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
 397                if read_ssh_config != new_read_ssh_config {
 398                    read_ssh_config = new_read_ssh_config;
 399                    if read_ssh_config {
 400                        recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
 401                    } else {
 402                        recent_projects.ssh_config_servers.clear();
 403                        recent_projects.ssh_config_updates = Task::ready(());
 404                    }
 405                }
 406            });
 407
 408        Self {
 409            mode: Mode::default_mode(&BTreeSet::new(), cx),
 410            focus_handle,
 411            workspace,
 412            retained_connections: Vec::new(),
 413            ssh_config_updates,
 414            ssh_config_servers: BTreeSet::new(),
 415            create_new_window,
 416            _subscription,
 417        }
 418    }
 419
 420    pub fn project_picker(
 421        create_new_window: bool,
 422        ix: usize,
 423        connection_options: remote::SshConnectionOptions,
 424        project: Entity<Project>,
 425        home_dir: PathBuf,
 426        window: &mut Window,
 427        cx: &mut Context<Self>,
 428        workspace: WeakEntity<Workspace>,
 429    ) -> Self {
 430        let fs = project.read(cx).fs().clone();
 431        let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
 432        this.mode = Mode::ProjectPicker(ProjectPicker::new(
 433            create_new_window,
 434            ix,
 435            connection_options,
 436            project,
 437            home_dir,
 438            workspace,
 439            window,
 440            cx,
 441        ));
 442        cx.notify();
 443
 444        this
 445    }
 446
 447    fn create_ssh_server(
 448        &mut self,
 449        editor: Entity<Editor>,
 450        window: &mut Window,
 451        cx: &mut Context<Self>,
 452    ) {
 453        let input = get_text(&editor, cx);
 454        if input.is_empty() {
 455            return;
 456        }
 457
 458        let connection_options = match SshConnectionOptions::parse_command_line(&input) {
 459            Ok(c) => c,
 460            Err(e) => {
 461                self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 462                    address_editor: editor,
 463                    address_error: Some(format!("could not parse: {:?}", e).into()),
 464                    ssh_prompt: None,
 465                    _creating: None,
 466                });
 467                return;
 468            }
 469        };
 470        let ssh_prompt = cx.new(|cx| SshPrompt::new(&connection_options, window, cx));
 471
 472        let connection = connect_over_ssh(
 473            ConnectionIdentifier::setup(),
 474            connection_options.clone(),
 475            ssh_prompt.clone(),
 476            window,
 477            cx,
 478        )
 479        .prompt_err("Failed to connect", window, cx, |_, _, _| None);
 480
 481        let address_editor = editor.clone();
 482        let creating = cx.spawn_in(window, async move |this, cx| {
 483            match connection.await {
 484                Some(Some(client)) => this
 485                    .update_in(cx, |this, window, cx| {
 486                        telemetry::event!("SSH Server Created");
 487                        this.retained_connections.push(client);
 488                        this.add_ssh_server(connection_options, cx);
 489                        this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
 490                        this.focus_handle(cx).focus(window);
 491                        cx.notify()
 492                    })
 493                    .log_err(),
 494                _ => this
 495                    .update(cx, |this, cx| {
 496                        address_editor.update(cx, |this, _| {
 497                            this.set_read_only(false);
 498                        });
 499                        this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 500                            address_editor,
 501                            address_error: None,
 502                            ssh_prompt: None,
 503                            _creating: None,
 504                        });
 505                        cx.notify()
 506                    })
 507                    .log_err(),
 508            };
 509            None
 510        });
 511
 512        editor.update(cx, |this, _| {
 513            this.set_read_only(true);
 514        });
 515        self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 516            address_editor: editor,
 517            address_error: None,
 518            ssh_prompt: Some(ssh_prompt.clone()),
 519            _creating: Some(creating),
 520        });
 521    }
 522
 523    fn view_server_options(
 524        &mut self,
 525        (server_index, connection): (usize, SshConnection),
 526        window: &mut Window,
 527        cx: &mut Context<Self>,
 528    ) {
 529        self.mode = Mode::ViewServerOptions(ViewServerOptionsState {
 530            server_index,
 531            connection,
 532            entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
 533        });
 534        self.focus_handle(cx).focus(window);
 535        cx.notify();
 536    }
 537
 538    fn create_ssh_project(
 539        &mut self,
 540        ix: usize,
 541        ssh_connection: SshConnection,
 542        window: &mut Window,
 543        cx: &mut Context<Self>,
 544    ) {
 545        let Some(workspace) = self.workspace.upgrade() else {
 546            return;
 547        };
 548
 549        let create_new_window = self.create_new_window;
 550        let connection_options = ssh_connection.into();
 551        workspace.update(cx, |_, cx| {
 552            cx.defer_in(window, move |workspace, window, cx| {
 553                let app_state = workspace.app_state().clone();
 554                workspace.toggle_modal(window, cx, |window, cx| {
 555                    SshConnectionModal::new(&connection_options, Vec::new(), window, cx)
 556                });
 557                let prompt = workspace
 558                    .active_modal::<SshConnectionModal>(cx)
 559                    .unwrap()
 560                    .read(cx)
 561                    .prompt
 562                    .clone();
 563
 564                let connect = connect_over_ssh(
 565                    ConnectionIdentifier::setup(),
 566                    connection_options.clone(),
 567                    prompt,
 568                    window,
 569                    cx,
 570                )
 571                .prompt_err("Failed to connect", window, cx, |_, _, _| None);
 572
 573                cx.spawn_in(window, async move |workspace, cx| {
 574                    let session = connect.await;
 575
 576                    workspace.update(cx, |workspace, cx| {
 577                        if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
 578                            prompt.update(cx, |prompt, cx| prompt.finished(cx))
 579                        }
 580                    })?;
 581
 582                    let Some(Some(session)) = session else {
 583                        return workspace.update_in(cx, |workspace, window, cx| {
 584                            let weak = cx.entity().downgrade();
 585                            let fs = workspace.project().read(cx).fs().clone();
 586                            workspace.toggle_modal(window, cx, |window, cx| {
 587                                RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
 588                            });
 589                        });
 590                    };
 591
 592                    let project = cx.update(|_, cx| {
 593                        project::Project::ssh(
 594                            session,
 595                            app_state.client.clone(),
 596                            app_state.node_runtime.clone(),
 597                            app_state.user_store.clone(),
 598                            app_state.languages.clone(),
 599                            app_state.fs.clone(),
 600                            cx,
 601                        )
 602                    })?;
 603
 604                    let home_dir = project
 605                        .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
 606                        .await
 607                        .and_then(|path| path.into_abs_path())
 608                        .unwrap_or(PathBuf::from("/"));
 609
 610                    workspace
 611                        .update_in(cx, |workspace, window, cx| {
 612                            let weak = cx.entity().downgrade();
 613                            workspace.toggle_modal(window, cx, |window, cx| {
 614                                RemoteServerProjects::project_picker(
 615                                    create_new_window,
 616                                    ix,
 617                                    connection_options,
 618                                    project,
 619                                    home_dir,
 620                                    window,
 621                                    cx,
 622                                    weak,
 623                                )
 624                            });
 625                        })
 626                        .ok();
 627                    Ok(())
 628                })
 629                .detach();
 630            })
 631        })
 632    }
 633
 634    fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
 635        match &self.mode {
 636            Mode::Default(_) | Mode::ViewServerOptions(_) => {}
 637            Mode::ProjectPicker(_) => {}
 638            Mode::CreateRemoteServer(state) => {
 639                if let Some(prompt) = state.ssh_prompt.as_ref() {
 640                    prompt.update(cx, |prompt, cx| {
 641                        prompt.confirm(window, cx);
 642                    });
 643                    return;
 644                }
 645
 646                self.create_ssh_server(state.address_editor.clone(), window, cx);
 647            }
 648            Mode::EditNickname(state) => {
 649                let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
 650                let index = state.index;
 651                self.update_settings_file(cx, move |setting, _| {
 652                    if let Some(connections) = setting.ssh_connections.as_mut() {
 653                        if let Some(connection) = connections.get_mut(index) {
 654                            connection.nickname = text;
 655                        }
 656                    }
 657                });
 658                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
 659                self.focus_handle.focus(window);
 660            }
 661        }
 662    }
 663
 664    fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
 665        match &self.mode {
 666            Mode::Default(_) => cx.emit(DismissEvent),
 667            Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
 668                let new_state = CreateRemoteServer::new(window, cx);
 669                let old_prompt = state.address_editor.read(cx).text(cx);
 670                new_state.address_editor.update(cx, |this, cx| {
 671                    this.set_text(old_prompt, window, cx);
 672                });
 673
 674                self.mode = Mode::CreateRemoteServer(new_state);
 675                cx.notify();
 676            }
 677            _ => {
 678                self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
 679                self.focus_handle(cx).focus(window);
 680                cx.notify();
 681            }
 682        }
 683    }
 684
 685    fn render_ssh_connection(
 686        &mut self,
 687        ix: usize,
 688        ssh_server: RemoteEntry,
 689        window: &mut Window,
 690        cx: &mut Context<Self>,
 691    ) -> impl IntoElement {
 692        let connection = ssh_server.connection().into_owned();
 693        let (main_label, aux_label) = if let Some(nickname) = connection.nickname.clone() {
 694            let aux_label = SharedString::from(format!("({})", connection.host));
 695            (nickname.into(), Some(aux_label))
 696        } else {
 697            (connection.host.clone(), None)
 698        };
 699        v_flex()
 700            .w_full()
 701            .child(ListSeparator)
 702            .child(
 703                h_flex()
 704                    .group("ssh-server")
 705                    .w_full()
 706                    .pt_0p5()
 707                    .px_3()
 708                    .gap_1()
 709                    .overflow_hidden()
 710                    .child(
 711                        div().max_w_96().overflow_hidden().text_ellipsis().child(
 712                            Label::new(main_label)
 713                                .size(LabelSize::Small)
 714                                .color(Color::Muted),
 715                        ),
 716                    )
 717                    .children(
 718                        aux_label.map(|label| {
 719                            Label::new(label).size(LabelSize::Small).color(Color::Muted)
 720                        }),
 721                    ),
 722            )
 723            .child(match &ssh_server {
 724                RemoteEntry::Project {
 725                    open_folder,
 726                    projects,
 727                    configure,
 728                    connection,
 729                } => List::new()
 730                    .empty_message("No projects.")
 731                    .children(projects.iter().enumerate().map(|(pix, p)| {
 732                        v_flex().gap_0p5().child(self.render_ssh_project(
 733                            ix,
 734                            ssh_server.clone(),
 735                            pix,
 736                            p,
 737                            window,
 738                            cx,
 739                        ))
 740                    }))
 741                    .child(
 742                        h_flex()
 743                            .id(("new-remote-project-container", ix))
 744                            .track_focus(&open_folder.focus_handle)
 745                            .anchor_scroll(open_folder.scroll_anchor.clone())
 746                            .on_action(cx.listener({
 747                                let ssh_connection = connection.clone();
 748                                move |this, _: &menu::Confirm, window, cx| {
 749                                    this.create_ssh_project(ix, ssh_connection.clone(), window, cx);
 750                                }
 751                            }))
 752                            .child(
 753                                ListItem::new(("new-remote-project", ix))
 754                                    .toggle_state(
 755                                        open_folder.focus_handle.contains_focused(window, cx),
 756                                    )
 757                                    .inset(true)
 758                                    .spacing(ui::ListItemSpacing::Sparse)
 759                                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
 760                                    .child(Label::new("Open Folder"))
 761                                    .on_click(cx.listener({
 762                                        let ssh_connection = connection.clone();
 763                                        move |this, _, window, cx| {
 764                                            this.create_ssh_project(
 765                                                ix,
 766                                                ssh_connection.clone(),
 767                                                window,
 768                                                cx,
 769                                            );
 770                                        }
 771                                    })),
 772                            ),
 773                    )
 774                    .child(
 775                        h_flex()
 776                            .id(("server-options-container", ix))
 777                            .track_focus(&configure.focus_handle)
 778                            .anchor_scroll(configure.scroll_anchor.clone())
 779                            .on_action(cx.listener({
 780                                let ssh_connection = connection.clone();
 781                                move |this, _: &menu::Confirm, window, cx| {
 782                                    this.view_server_options(
 783                                        (ix, ssh_connection.clone()),
 784                                        window,
 785                                        cx,
 786                                    );
 787                                }
 788                            }))
 789                            .child(
 790                                ListItem::new(("server-options", ix))
 791                                    .toggle_state(
 792                                        configure.focus_handle.contains_focused(window, cx),
 793                                    )
 794                                    .inset(true)
 795                                    .spacing(ui::ListItemSpacing::Sparse)
 796                                    .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
 797                                    .child(Label::new("View Server Options"))
 798                                    .on_click(cx.listener({
 799                                        let ssh_connection = connection.clone();
 800                                        move |this, _, window, cx| {
 801                                            this.view_server_options(
 802                                                (ix, ssh_connection.clone()),
 803                                                window,
 804                                                cx,
 805                                            );
 806                                        }
 807                                    })),
 808                            ),
 809                    ),
 810                RemoteEntry::SshConfig { open_folder, host } => List::new().child(
 811                    h_flex()
 812                        .id(("new-remote-project-container", ix))
 813                        .track_focus(&open_folder.focus_handle)
 814                        .anchor_scroll(open_folder.scroll_anchor.clone())
 815                        .on_action(cx.listener({
 816                            let ssh_connection = connection.clone();
 817                            let host = host.clone();
 818                            move |this, _: &menu::Confirm, window, cx| {
 819                                let new_ix = this.create_host_from_ssh_config(&host, cx);
 820                                this.create_ssh_project(new_ix, ssh_connection.clone(), window, cx);
 821                            }
 822                        }))
 823                        .child(
 824                            ListItem::new(("new-remote-project", ix))
 825                                .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
 826                                .inset(true)
 827                                .spacing(ui::ListItemSpacing::Sparse)
 828                                .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
 829                                .child(Label::new("Open Folder"))
 830                                .on_click(cx.listener({
 831                                    let ssh_connection = connection.clone();
 832                                    let host = host.clone();
 833                                    move |this, _, window, cx| {
 834                                        let new_ix = this.create_host_from_ssh_config(&host, cx);
 835                                        this.create_ssh_project(
 836                                            new_ix,
 837                                            ssh_connection.clone(),
 838                                            window,
 839                                            cx,
 840                                        );
 841                                    }
 842                                })),
 843                        ),
 844                ),
 845            })
 846    }
 847
 848    fn render_ssh_project(
 849        &mut self,
 850        server_ix: usize,
 851        server: RemoteEntry,
 852        ix: usize,
 853        (navigation, project): &(NavigableEntry, SshProject),
 854        window: &mut Window,
 855        cx: &mut Context<Self>,
 856    ) -> impl IntoElement {
 857        let create_new_window = self.create_new_window;
 858        let is_from_zed = server.is_from_zed();
 859        let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
 860        let container_element_id_base =
 861            SharedString::from(format!("remote-project-container-{element_id_base}"));
 862
 863        let callback = Rc::new({
 864            let project = project.clone();
 865            move |remote_server_projects: &mut Self,
 866                  secondary_confirm: bool,
 867                  window: &mut Window,
 868                  cx: &mut Context<Self>| {
 869                let Some(app_state) = remote_server_projects
 870                    .workspace
 871                    .read_with(cx, |workspace, _| workspace.app_state().clone())
 872                    .log_err()
 873                else {
 874                    return;
 875                };
 876                let project = project.clone();
 877                let server = server.connection().into_owned();
 878                cx.emit(DismissEvent);
 879
 880                let replace_window = match (create_new_window, secondary_confirm) {
 881                    (true, false) | (false, true) => None,
 882                    (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
 883                };
 884
 885                cx.spawn_in(window, async move |_, cx| {
 886                    let result = open_ssh_project(
 887                        server.into(),
 888                        project.paths.into_iter().map(PathBuf::from).collect(),
 889                        app_state,
 890                        OpenOptions {
 891                            replace_window,
 892                            ..OpenOptions::default()
 893                        },
 894                        cx,
 895                    )
 896                    .await;
 897                    if let Err(e) = result {
 898                        log::error!("Failed to connect: {e:#}");
 899                        cx.prompt(
 900                            gpui::PromptLevel::Critical,
 901                            "Failed to connect",
 902                            Some(&e.to_string()),
 903                            &["Ok"],
 904                        )
 905                        .await
 906                        .ok();
 907                    }
 908                })
 909                .detach();
 910            }
 911        });
 912
 913        div()
 914            .id((container_element_id_base, ix))
 915            .track_focus(&navigation.focus_handle)
 916            .anchor_scroll(navigation.scroll_anchor.clone())
 917            .on_action(cx.listener({
 918                let callback = callback.clone();
 919                move |this, _: &menu::Confirm, window, cx| {
 920                    callback(this, false, window, cx);
 921                }
 922            }))
 923            .on_action(cx.listener({
 924                let callback = callback.clone();
 925                move |this, _: &menu::SecondaryConfirm, window, cx| {
 926                    callback(this, true, window, cx);
 927                }
 928            }))
 929            .child(
 930                ListItem::new((element_id_base, ix))
 931                    .toggle_state(navigation.focus_handle.contains_focused(window, cx))
 932                    .inset(true)
 933                    .spacing(ui::ListItemSpacing::Sparse)
 934                    .start_slot(
 935                        Icon::new(IconName::Folder)
 936                            .color(Color::Muted)
 937                            .size(IconSize::Small),
 938                    )
 939                    .child(Label::new(project.paths.join(", ")))
 940                    .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
 941                        let secondary_confirm = e.down.modifiers.platform;
 942                        callback(this, secondary_confirm, window, cx)
 943                    }))
 944                    .when(is_from_zed, |server_list_item| {
 945                        server_list_item.end_hover_slot::<AnyElement>(Some(
 946                            div()
 947                                .mr_2()
 948                                .child({
 949                                    let project = project.clone();
 950                                    // Right-margin to offset it from the Scrollbar
 951                                    IconButton::new("remove-remote-project", IconName::TrashAlt)
 952                                        .icon_size(IconSize::Small)
 953                                        .shape(IconButtonShape::Square)
 954                                        .size(ButtonSize::Large)
 955                                        .tooltip(Tooltip::text("Delete Remote Project"))
 956                                        .on_click(cx.listener(move |this, _, _, cx| {
 957                                            this.delete_ssh_project(server_ix, &project, cx)
 958                                        }))
 959                                })
 960                                .into_any_element(),
 961                        ))
 962                    }),
 963            )
 964    }
 965
 966    fn update_settings_file(
 967        &mut self,
 968        cx: &mut Context<Self>,
 969        f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
 970    ) {
 971        let Some(fs) = self
 972            .workspace
 973            .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
 974            .log_err()
 975        else {
 976            return;
 977        };
 978        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 979    }
 980
 981    fn delete_ssh_server(&mut self, server: usize, cx: &mut Context<Self>) {
 982        self.update_settings_file(cx, move |setting, _| {
 983            if let Some(connections) = setting.ssh_connections.as_mut() {
 984                connections.remove(server);
 985            }
 986        });
 987    }
 988
 989    fn delete_ssh_project(&mut self, server: usize, project: &SshProject, cx: &mut Context<Self>) {
 990        let project = project.clone();
 991        self.update_settings_file(cx, move |setting, _| {
 992            if let Some(server) = setting
 993                .ssh_connections
 994                .as_mut()
 995                .and_then(|connections| connections.get_mut(server))
 996            {
 997                server.projects.remove(&project);
 998            }
 999        });
1000    }
1001
1002    fn add_ssh_server(
1003        &mut self,
1004        connection_options: remote::SshConnectionOptions,
1005        cx: &mut Context<Self>,
1006    ) {
1007        self.update_settings_file(cx, move |setting, _| {
1008            setting
1009                .ssh_connections
1010                .get_or_insert(Default::default())
1011                .push(SshConnection {
1012                    host: SharedString::from(connection_options.host),
1013                    username: connection_options.username,
1014                    port: connection_options.port,
1015                    projects: BTreeSet::new(),
1016                    nickname: None,
1017                    args: connection_options.args.unwrap_or_default(),
1018                    upload_binary_over_ssh: None,
1019                    port_forwards: connection_options.port_forwards,
1020                })
1021        });
1022    }
1023
1024    fn render_create_remote_server(
1025        &self,
1026        state: &CreateRemoteServer,
1027        cx: &mut Context<Self>,
1028    ) -> impl IntoElement {
1029        let ssh_prompt = state.ssh_prompt.clone();
1030
1031        state.address_editor.update(cx, |editor, cx| {
1032            if editor.text(cx).is_empty() {
1033                editor.set_placeholder_text("ssh user@example -p 2222", cx);
1034            }
1035        });
1036
1037        let theme = cx.theme();
1038
1039        v_flex()
1040            .track_focus(&self.focus_handle(cx))
1041            .id("create-remote-server")
1042            .overflow_hidden()
1043            .size_full()
1044            .flex_1()
1045            .child(
1046                div()
1047                    .p_2()
1048                    .border_b_1()
1049                    .border_color(theme.colors().border_variant)
1050                    .child(state.address_editor.clone()),
1051            )
1052            .child(
1053                h_flex()
1054                    .bg(theme.colors().editor_background)
1055                    .rounded_b_sm()
1056                    .w_full()
1057                    .map(|this| {
1058                        if let Some(ssh_prompt) = ssh_prompt {
1059                            this.child(h_flex().w_full().child(ssh_prompt))
1060                        } else if let Some(address_error) = &state.address_error {
1061                            this.child(
1062                                h_flex().p_2().w_full().gap_2().child(
1063                                    Label::new(address_error.clone())
1064                                        .size(LabelSize::Small)
1065                                        .color(Color::Error),
1066                                ),
1067                            )
1068                        } else {
1069                            this.child(
1070                                h_flex()
1071                                    .p_2()
1072                                    .w_full()
1073                                    .gap_1()
1074                                    .child(
1075                                        Label::new(
1076                                            "Enter the command you use to SSH into this server.",
1077                                        )
1078                                        .color(Color::Muted)
1079                                        .size(LabelSize::Small),
1080                                    )
1081                                    .child(
1082                                        Button::new("learn-more", "Learn more…")
1083                                            .label_size(LabelSize::Small)
1084                                            .size(ButtonSize::None)
1085                                            .color(Color::Accent)
1086                                            .style(ButtonStyle::Transparent)
1087                                            .on_click(|_, _, cx| {
1088                                                cx.open_url(
1089                                                    "https://zed.dev/docs/remote-development",
1090                                                );
1091                                            }),
1092                                    ),
1093                            )
1094                        }
1095                    }),
1096            )
1097    }
1098
1099    fn render_view_options(
1100        &mut self,
1101        ViewServerOptionsState {
1102            server_index,
1103            connection,
1104            entries,
1105        }: ViewServerOptionsState,
1106        window: &mut Window,
1107        cx: &mut Context<Self>,
1108    ) -> impl IntoElement {
1109        let connection_string = connection.host.clone();
1110
1111        let mut view = Navigable::new(
1112            div()
1113                .track_focus(&self.focus_handle(cx))
1114                .size_full()
1115                .child(
1116                    SshConnectionHeader {
1117                        connection_string: connection_string.clone(),
1118                        paths: Default::default(),
1119                        nickname: connection.nickname.clone().map(|s| s.into()),
1120                    }
1121                    .render(window, cx),
1122                )
1123                .child(
1124                    v_flex()
1125                        .pb_1()
1126                        .child(ListSeparator)
1127                        .child({
1128                            let label = if connection.nickname.is_some() {
1129                                "Edit Nickname"
1130                            } else {
1131                                "Add Nickname to Server"
1132                            };
1133                            div()
1134                                .id("ssh-options-add-nickname")
1135                                .track_focus(&entries[0].focus_handle)
1136                                .on_action(cx.listener(
1137                                    move |this, _: &menu::Confirm, window, cx| {
1138                                        this.mode = Mode::EditNickname(EditNicknameState::new(
1139                                            server_index,
1140                                            window,
1141                                            cx,
1142                                        ));
1143                                        cx.notify();
1144                                    },
1145                                ))
1146                                .child(
1147                                    ListItem::new("add-nickname")
1148                                        .toggle_state(
1149                                            entries[0].focus_handle.contains_focused(window, cx),
1150                                        )
1151                                        .inset(true)
1152                                        .spacing(ui::ListItemSpacing::Sparse)
1153                                        .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1154                                        .child(Label::new(label))
1155                                        .on_click(cx.listener(move |this, _, window, cx| {
1156                                            this.mode = Mode::EditNickname(EditNicknameState::new(
1157                                                server_index,
1158                                                window,
1159                                                cx,
1160                                            ));
1161                                            cx.notify();
1162                                        })),
1163                                )
1164                        })
1165                        .child({
1166                            let workspace = self.workspace.clone();
1167                            fn callback(
1168                                workspace: WeakEntity<Workspace>,
1169                                connection_string: SharedString,
1170                                cx: &mut App,
1171                            ) {
1172                                cx.write_to_clipboard(ClipboardItem::new_string(
1173                                    connection_string.to_string(),
1174                                ));
1175                                workspace
1176                                    .update(cx, |this, cx| {
1177                                        struct SshServerAddressCopiedToClipboard;
1178                                        let notification = format!(
1179                                            "Copied server address ({}) to clipboard",
1180                                            connection_string
1181                                        );
1182
1183                                        this.show_toast(
1184                                            Toast::new(
1185                                                NotificationId::composite::<
1186                                                    SshServerAddressCopiedToClipboard,
1187                                                >(
1188                                                    connection_string.clone()
1189                                                ),
1190                                                notification,
1191                                            )
1192                                            .autohide(),
1193                                            cx,
1194                                        );
1195                                    })
1196                                    .ok();
1197                            }
1198                            div()
1199                                .id("ssh-options-copy-server-address")
1200                                .track_focus(&entries[1].focus_handle)
1201                                .on_action({
1202                                    let connection_string = connection_string.clone();
1203                                    let workspace = self.workspace.clone();
1204                                    move |_: &menu::Confirm, _, cx| {
1205                                        callback(workspace.clone(), connection_string.clone(), cx);
1206                                    }
1207                                })
1208                                .child(
1209                                    ListItem::new("copy-server-address")
1210                                        .toggle_state(
1211                                            entries[1].focus_handle.contains_focused(window, cx),
1212                                        )
1213                                        .inset(true)
1214                                        .spacing(ui::ListItemSpacing::Sparse)
1215                                        .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1216                                        .child(Label::new("Copy Server Address"))
1217                                        .end_hover_slot(
1218                                            Label::new(connection_string.clone())
1219                                                .color(Color::Muted),
1220                                        )
1221                                        .on_click({
1222                                            let connection_string = connection_string.clone();
1223                                            move |_, _, cx| {
1224                                                callback(
1225                                                    workspace.clone(),
1226                                                    connection_string.clone(),
1227                                                    cx,
1228                                                );
1229                                            }
1230                                        }),
1231                                )
1232                        })
1233                        .child({
1234                            fn remove_ssh_server(
1235                                remote_servers: Entity<RemoteServerProjects>,
1236                                index: usize,
1237                                connection_string: SharedString,
1238                                window: &mut Window,
1239                                cx: &mut App,
1240                            ) {
1241                                let prompt_message =
1242                                    format!("Remove server `{}`?", connection_string);
1243
1244                                let confirmation = window.prompt(
1245                                    PromptLevel::Warning,
1246                                    &prompt_message,
1247                                    None,
1248                                    &["Yes, remove it", "No, keep it"],
1249                                    cx,
1250                                );
1251
1252                                cx.spawn(async move |cx| {
1253                                    if confirmation.await.ok() == Some(0) {
1254                                        remote_servers
1255                                            .update(cx, |this, cx| {
1256                                                this.delete_ssh_server(index, cx);
1257                                            })
1258                                            .ok();
1259                                        remote_servers
1260                                            .update(cx, |this, cx| {
1261                                                this.mode = Mode::default_mode(
1262                                                    &this.ssh_config_servers,
1263                                                    cx,
1264                                                );
1265                                                cx.notify();
1266                                            })
1267                                            .ok();
1268                                    }
1269                                    anyhow::Ok(())
1270                                })
1271                                .detach_and_log_err(cx);
1272                            }
1273                            div()
1274                                .id("ssh-options-copy-server-address")
1275                                .track_focus(&entries[2].focus_handle)
1276                                .on_action(cx.listener({
1277                                    let connection_string = connection_string.clone();
1278                                    move |_, _: &menu::Confirm, window, cx| {
1279                                        remove_ssh_server(
1280                                            cx.entity().clone(),
1281                                            server_index,
1282                                            connection_string.clone(),
1283                                            window,
1284                                            cx,
1285                                        );
1286                                        cx.focus_self(window);
1287                                    }
1288                                }))
1289                                .child(
1290                                    ListItem::new("remove-server")
1291                                        .toggle_state(
1292                                            entries[2].focus_handle.contains_focused(window, cx),
1293                                        )
1294                                        .inset(true)
1295                                        .spacing(ui::ListItemSpacing::Sparse)
1296                                        .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1297                                        .child(Label::new("Remove Server").color(Color::Error))
1298                                        .on_click(cx.listener(move |_, _, window, cx| {
1299                                            remove_ssh_server(
1300                                                cx.entity().clone(),
1301                                                server_index,
1302                                                connection_string.clone(),
1303                                                window,
1304                                                cx,
1305                                            );
1306                                            cx.focus_self(window);
1307                                        })),
1308                                )
1309                        })
1310                        .child(ListSeparator)
1311                        .child({
1312                            div()
1313                                .id("ssh-options-copy-server-address")
1314                                .track_focus(&entries[3].focus_handle)
1315                                .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1316                                    this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1317                                    cx.focus_self(window);
1318                                    cx.notify();
1319                                }))
1320                                .child(
1321                                    ListItem::new("go-back")
1322                                        .toggle_state(
1323                                            entries[3].focus_handle.contains_focused(window, cx),
1324                                        )
1325                                        .inset(true)
1326                                        .spacing(ui::ListItemSpacing::Sparse)
1327                                        .start_slot(
1328                                            Icon::new(IconName::ArrowLeft).color(Color::Muted),
1329                                        )
1330                                        .child(Label::new("Go Back"))
1331                                        .on_click(cx.listener(|this, _, window, cx| {
1332                                            this.mode =
1333                                                Mode::default_mode(&this.ssh_config_servers, cx);
1334                                            cx.focus_self(window);
1335                                            cx.notify()
1336                                        })),
1337                                )
1338                        }),
1339                )
1340                .into_any_element(),
1341        );
1342        for entry in entries {
1343            view = view.entry(entry);
1344        }
1345
1346        view.render(window, cx).into_any_element()
1347    }
1348
1349    fn render_edit_nickname(
1350        &self,
1351        state: &EditNicknameState,
1352        window: &mut Window,
1353        cx: &mut Context<Self>,
1354    ) -> impl IntoElement {
1355        let Some(connection) = SshSettings::get_global(cx)
1356            .ssh_connections()
1357            .nth(state.index)
1358        else {
1359            return v_flex()
1360                .id("ssh-edit-nickname")
1361                .track_focus(&self.focus_handle(cx));
1362        };
1363
1364        let connection_string = connection.host.clone();
1365        let nickname = connection.nickname.clone().map(|s| s.into());
1366
1367        v_flex()
1368            .id("ssh-edit-nickname")
1369            .track_focus(&self.focus_handle(cx))
1370            .child(
1371                SshConnectionHeader {
1372                    connection_string,
1373                    paths: Default::default(),
1374                    nickname,
1375                }
1376                .render(window, cx),
1377            )
1378            .child(
1379                h_flex()
1380                    .p_2()
1381                    .border_t_1()
1382                    .border_color(cx.theme().colors().border_variant)
1383                    .child(state.editor.clone()),
1384            )
1385    }
1386
1387    fn render_default(
1388        &mut self,
1389        mut state: DefaultState,
1390        window: &mut Window,
1391        cx: &mut Context<Self>,
1392    ) -> impl IntoElement {
1393        let ssh_settings = SshSettings::get_global(cx);
1394        let mut should_rebuild = false;
1395
1396        if ssh_settings
1397            .ssh_connections
1398            .as_ref()
1399            .map_or(false, |connections| {
1400                state
1401                    .servers
1402                    .iter()
1403                    .filter_map(|server| match server {
1404                        RemoteEntry::Project { connection, .. } => Some(connection),
1405                        RemoteEntry::SshConfig { .. } => None,
1406                    })
1407                    .ne(connections.iter())
1408            })
1409        {
1410            should_rebuild = true;
1411        };
1412
1413        if !should_rebuild && ssh_settings.read_ssh_config {
1414            let current_ssh_hosts: BTreeSet<SharedString> = state
1415                .servers
1416                .iter()
1417                .filter_map(|server| match server {
1418                    RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1419                    _ => None,
1420                })
1421                .collect();
1422            let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1423            for server in &state.servers {
1424                if let RemoteEntry::Project { connection, .. } = server {
1425                    expected_ssh_hosts.remove(&connection.host);
1426                }
1427            }
1428            should_rebuild = current_ssh_hosts != expected_ssh_hosts;
1429        }
1430
1431        if should_rebuild {
1432            self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1433            if let Mode::Default(new_state) = &self.mode {
1434                state = new_state.clone();
1435            }
1436        }
1437
1438        let scroll_state = state.scrollbar.parent_entity(&cx.entity());
1439        let connect_button = div()
1440            .id("ssh-connect-new-server-container")
1441            .track_focus(&state.add_new_server.focus_handle)
1442            .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1443            .child(
1444                ListItem::new("register-remove-server-button")
1445                    .toggle_state(
1446                        state
1447                            .add_new_server
1448                            .focus_handle
1449                            .contains_focused(window, cx),
1450                    )
1451                    .inset(true)
1452                    .spacing(ui::ListItemSpacing::Sparse)
1453                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1454                    .child(Label::new("Connect New Server"))
1455                    .on_click(cx.listener(|this, _, window, cx| {
1456                        let state = CreateRemoteServer::new(window, cx);
1457                        this.mode = Mode::CreateRemoteServer(state);
1458
1459                        cx.notify();
1460                    })),
1461            )
1462            .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1463                let state = CreateRemoteServer::new(window, cx);
1464                this.mode = Mode::CreateRemoteServer(state);
1465
1466                cx.notify();
1467            }));
1468
1469        let handle = &**scroll_state.scroll_handle() as &dyn Any;
1470        let Some(scroll_handle) = handle.downcast_ref::<ScrollHandle>() else {
1471            unreachable!()
1472        };
1473
1474        let mut modal_section = Navigable::new(
1475            v_flex()
1476                .track_focus(&self.focus_handle(cx))
1477                .id("ssh-server-list")
1478                .overflow_y_scroll()
1479                .track_scroll(&scroll_handle)
1480                .size_full()
1481                .child(connect_button)
1482                .child(
1483                    List::new()
1484                        .empty_message(
1485                            v_flex()
1486                                .child(
1487                                    div().px_3().child(
1488                                        Label::new("No remote servers registered yet.")
1489                                            .color(Color::Muted),
1490                                    ),
1491                                )
1492                                .into_any_element(),
1493                        )
1494                        .children(state.servers.iter().enumerate().map(|(ix, connection)| {
1495                            self.render_ssh_connection(ix, connection.clone(), window, cx)
1496                                .into_any_element()
1497                        })),
1498                )
1499                .into_any_element(),
1500        )
1501        .entry(state.add_new_server.clone());
1502
1503        for server in &state.servers {
1504            match server {
1505                RemoteEntry::Project {
1506                    open_folder,
1507                    projects,
1508                    configure,
1509                    ..
1510                } => {
1511                    for (navigation_state, _) in projects {
1512                        modal_section = modal_section.entry(navigation_state.clone());
1513                    }
1514                    modal_section = modal_section
1515                        .entry(open_folder.clone())
1516                        .entry(configure.clone());
1517                }
1518                RemoteEntry::SshConfig { open_folder, .. } => {
1519                    modal_section = modal_section.entry(open_folder.clone());
1520                }
1521            }
1522        }
1523        let mut modal_section = modal_section.render(window, cx).into_any_element();
1524
1525        let (create_window, reuse_window) = if self.create_new_window {
1526            (
1527                window.keystroke_text_for(&menu::Confirm),
1528                window.keystroke_text_for(&menu::SecondaryConfirm),
1529            )
1530        } else {
1531            (
1532                window.keystroke_text_for(&menu::SecondaryConfirm),
1533                window.keystroke_text_for(&menu::Confirm),
1534            )
1535        };
1536        let placeholder_text = Arc::from(format!(
1537            "{reuse_window} reuses this window, {create_window} opens a new one",
1538        ));
1539
1540        Modal::new("remote-projects", None)
1541            .header(
1542                ModalHeader::new()
1543                    .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
1544                    .child(
1545                        Label::new(placeholder_text)
1546                            .color(Color::Muted)
1547                            .size(LabelSize::XSmall),
1548                    ),
1549            )
1550            .section(
1551                Section::new().padded(false).child(
1552                    v_flex()
1553                        .min_h(rems(20.))
1554                        .size_full()
1555                        .relative()
1556                        .child(ListSeparator)
1557                        .child(
1558                            canvas(
1559                                |bounds, window, cx| {
1560                                    modal_section.prepaint_as_root(
1561                                        bounds.origin,
1562                                        bounds.size.into(),
1563                                        window,
1564                                        cx,
1565                                    );
1566                                    modal_section
1567                                },
1568                                |_, mut modal_section, window, cx| {
1569                                    modal_section.paint(window, cx);
1570                                },
1571                            )
1572                            .size_full(),
1573                        )
1574                        .child(
1575                            div()
1576                                .occlude()
1577                                .h_full()
1578                                .absolute()
1579                                .top_1()
1580                                .bottom_1()
1581                                .right_1()
1582                                .w(px(8.))
1583                                .children(Scrollbar::vertical(scroll_state)),
1584                        ),
1585                ),
1586            )
1587            .into_any_element()
1588    }
1589
1590    fn create_host_from_ssh_config(
1591        &mut self,
1592        ssh_config_host: &SharedString,
1593        cx: &mut Context<'_, Self>,
1594    ) -> usize {
1595        let new_ix = Arc::new(AtomicUsize::new(0));
1596
1597        let update_new_ix = new_ix.clone();
1598        self.update_settings_file(cx, move |settings, _| {
1599            update_new_ix.store(
1600                settings
1601                    .ssh_connections
1602                    .as_ref()
1603                    .map_or(0, |connections| connections.len()),
1604                atomic::Ordering::Release,
1605            );
1606        });
1607
1608        self.add_ssh_server(
1609            SshConnectionOptions {
1610                host: ssh_config_host.to_string(),
1611                ..SshConnectionOptions::default()
1612            },
1613            cx,
1614        );
1615        self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1616        new_ix.load(atomic::Ordering::Acquire)
1617    }
1618}
1619
1620fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
1621    let mut user_ssh_config_watcher =
1622        watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
1623    let mut global_ssh_config_watcher = watch_config_file(
1624        cx.background_executor(),
1625        fs,
1626        global_ssh_config_file().to_owned(),
1627    );
1628
1629    cx.spawn(async move |remote_server_projects, cx| {
1630        let mut global_hosts = BTreeSet::default();
1631        let mut user_hosts = BTreeSet::default();
1632        let mut running_receivers = 2;
1633
1634        loop {
1635            select! {
1636                new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
1637                    match new_global_file_contents {
1638                        Some(new_global_file_contents) => {
1639                            global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
1640                            if remote_server_projects.update(cx, |remote_server_projects, cx| {
1641                                remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1642                                cx.notify();
1643                            }).is_err() {
1644                                return;
1645                            }
1646                        },
1647                        None => {
1648                            running_receivers -= 1;
1649                            if running_receivers == 0 {
1650                                return;
1651                            }
1652                        }
1653                    }
1654                },
1655                new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
1656                    match new_user_file_contents {
1657                        Some(new_user_file_contents) => {
1658                            user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
1659                            if remote_server_projects.update(cx, |remote_server_projects, cx| {
1660                                remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1661                                cx.notify();
1662                            }).is_err() {
1663                                return;
1664                            }
1665                        },
1666                        None => {
1667                            running_receivers -= 1;
1668                            if running_receivers == 0 {
1669                                return;
1670                            }
1671                        }
1672                    }
1673                },
1674            }
1675        }
1676    })
1677}
1678
1679fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
1680    element.read(cx).text(cx).trim().to_string()
1681}
1682
1683impl ModalView for RemoteServerProjects {}
1684
1685impl Focusable for RemoteServerProjects {
1686    fn focus_handle(&self, cx: &App) -> FocusHandle {
1687        match &self.mode {
1688            Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1689            _ => self.focus_handle.clone(),
1690        }
1691    }
1692}
1693
1694impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1695
1696impl Render for RemoteServerProjects {
1697    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1698        div()
1699            .elevation_3(cx)
1700            .w(rems(34.))
1701            .key_context("RemoteServerModal")
1702            .on_action(cx.listener(Self::cancel))
1703            .on_action(cx.listener(Self::confirm))
1704            .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
1705                this.focus_handle(cx).focus(window);
1706            }))
1707            .on_mouse_down_out(cx.listener(|this, _, _, cx| {
1708                if matches!(this.mode, Mode::Default(_)) {
1709                    cx.emit(DismissEvent)
1710                }
1711            }))
1712            .child(match &self.mode {
1713                Mode::Default(state) => self
1714                    .render_default(state.clone(), window, cx)
1715                    .into_any_element(),
1716                Mode::ViewServerOptions(state) => self
1717                    .render_view_options(state.clone(), window, cx)
1718                    .into_any_element(),
1719                Mode::ProjectPicker(element) => element.clone().into_any_element(),
1720                Mode::CreateRemoteServer(state) => self
1721                    .render_create_remote_server(state, cx)
1722                    .into_any_element(),
1723                Mode::EditNickname(state) => self
1724                    .render_edit_nickname(state, window, cx)
1725                    .into_any_element(),
1726            })
1727    }
1728}