remote_servers.rs

   1use std::path::PathBuf;
   2use std::sync::Arc;
   3
   4use editor::Editor;
   5use file_finder::OpenPathDelegate;
   6use futures::channel::oneshot;
   7use futures::future::Shared;
   8use futures::FutureExt;
   9use gpui::canvas;
  10use gpui::ClipboardItem;
  11use gpui::Task;
  12use gpui::WeakView;
  13use gpui::{
  14    AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
  15    PromptLevel, ScrollHandle, View, ViewContext,
  16};
  17use picker::Picker;
  18use project::Project;
  19use remote::SshConnectionOptions;
  20use remote::SshRemoteClient;
  21use settings::update_settings_file;
  22use settings::Settings;
  23use ui::Navigable;
  24use ui::NavigableEntry;
  25use ui::{
  26    prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar,
  27    ScrollbarState, Section, Tooltip,
  28};
  29use util::ResultExt;
  30use workspace::notifications::NotificationId;
  31use workspace::OpenOptions;
  32use workspace::Toast;
  33use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
  34
  35use crate::ssh_connections::connect_over_ssh;
  36use crate::ssh_connections::open_ssh_project;
  37use crate::ssh_connections::RemoteSettingsContent;
  38use crate::ssh_connections::SshConnection;
  39use crate::ssh_connections::SshConnectionHeader;
  40use crate::ssh_connections::SshConnectionModal;
  41use crate::ssh_connections::SshProject;
  42use crate::ssh_connections::SshPrompt;
  43use crate::ssh_connections::SshSettings;
  44use crate::OpenRemote;
  45
  46mod navigation_base {}
  47pub struct RemoteServerProjects {
  48    mode: Mode,
  49    focus_handle: FocusHandle,
  50    workspace: WeakView<Workspace>,
  51    retained_connections: Vec<Model<SshRemoteClient>>,
  52}
  53
  54struct CreateRemoteServer {
  55    address_editor: View<Editor>,
  56    address_error: Option<SharedString>,
  57    ssh_prompt: Option<View<SshPrompt>>,
  58    _creating: Option<Task<Option<()>>>,
  59}
  60
  61impl CreateRemoteServer {
  62    fn new(cx: &mut WindowContext<'_>) -> Self {
  63        let address_editor = cx.new_view(Editor::single_line);
  64        address_editor.update(cx, |this, cx| {
  65            this.focus_handle(cx).focus(cx);
  66        });
  67        Self {
  68            address_editor,
  69            address_error: None,
  70            ssh_prompt: None,
  71            _creating: None,
  72        }
  73    }
  74}
  75
  76struct ProjectPicker {
  77    connection_string: SharedString,
  78    nickname: Option<SharedString>,
  79    picker: View<Picker<OpenPathDelegate>>,
  80    _path_task: Shared<Task<Option<()>>>,
  81}
  82
  83struct EditNicknameState {
  84    index: usize,
  85    editor: View<Editor>,
  86}
  87
  88impl EditNicknameState {
  89    fn new(index: usize, cx: &mut WindowContext<'_>) -> Self {
  90        let this = Self {
  91            index,
  92            editor: cx.new_view(Editor::single_line),
  93        };
  94        let starting_text = SshSettings::get_global(cx)
  95            .ssh_connections()
  96            .nth(index)
  97            .and_then(|state| state.nickname.clone())
  98            .filter(|text| !text.is_empty());
  99        this.editor.update(cx, |this, cx| {
 100            this.set_placeholder_text("Add a nickname for this server", cx);
 101            if let Some(starting_text) = starting_text {
 102                this.set_text(starting_text, cx);
 103            }
 104        });
 105        this.editor.focus_handle(cx).focus(cx);
 106        this
 107    }
 108}
 109
 110impl FocusableView for ProjectPicker {
 111    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 112        self.picker.focus_handle(cx)
 113    }
 114}
 115
 116impl ProjectPicker {
 117    fn new(
 118        ix: usize,
 119        connection: SshConnectionOptions,
 120        project: Model<Project>,
 121        workspace: WeakView<Workspace>,
 122        cx: &mut ViewContext<RemoteServerProjects>,
 123    ) -> View<Self> {
 124        let (tx, rx) = oneshot::channel();
 125        let lister = project::DirectoryLister::Project(project.clone());
 126        let query = lister.default_query(cx);
 127        let delegate = file_finder::OpenPathDelegate::new(tx, lister);
 128
 129        let picker = cx.new_view(|cx| {
 130            let picker = Picker::uniform_list(delegate, cx)
 131                .width(rems(34.))
 132                .modal(false);
 133            picker.set_query(query, cx);
 134            picker
 135        });
 136        let connection_string = connection.connection_string().into();
 137        let nickname = connection.nickname.clone().map(|nick| nick.into());
 138        let _path_task = cx
 139            .spawn({
 140                let workspace = workspace.clone();
 141                move |this, mut cx| async move {
 142                    let Ok(Some(paths)) = rx.await else {
 143                        workspace
 144                            .update(&mut cx, |workspace, cx| {
 145                                let weak = cx.view().downgrade();
 146                                workspace
 147                                    .toggle_modal(cx, |cx| RemoteServerProjects::new(cx, weak));
 148                            })
 149                            .log_err()?;
 150                        return None;
 151                    };
 152
 153                    let app_state = workspace
 154                        .update(&mut cx, |workspace, _| workspace.app_state().clone())
 155                        .ok()?;
 156                    let options = cx
 157                        .update(|cx| (app_state.build_window_options)(None, cx))
 158                        .log_err()?;
 159
 160                    cx.open_window(options, |cx| {
 161                        cx.activate_window();
 162
 163                        let fs = app_state.fs.clone();
 164                        update_settings_file::<SshSettings>(fs, cx, {
 165                            let paths = paths
 166                                .iter()
 167                                .map(|path| path.to_string_lossy().to_string())
 168                                .collect();
 169                            move |setting, _| {
 170                                if let Some(server) = setting
 171                                    .ssh_connections
 172                                    .as_mut()
 173                                    .and_then(|connections| connections.get_mut(ix))
 174                                {
 175                                    server.projects.push(SshProject { paths })
 176                                }
 177                            }
 178                        });
 179
 180                        let tasks = paths
 181                            .into_iter()
 182                            .map(|path| {
 183                                project.update(cx, |project, cx| {
 184                                    project.find_or_create_worktree(&path, true, cx)
 185                                })
 186                            })
 187                            .collect::<Vec<_>>();
 188                        cx.spawn(|_| async move {
 189                            for task in tasks {
 190                                task.await?;
 191                            }
 192                            Ok(())
 193                        })
 194                        .detach_and_prompt_err(
 195                            "Failed to open path",
 196                            cx,
 197                            |_, _| None,
 198                        );
 199
 200                        cx.new_view(|cx| {
 201                            let workspace =
 202                                Workspace::new(None, project.clone(), app_state.clone(), cx);
 203
 204                            workspace
 205                                .client()
 206                                .telemetry()
 207                                .report_app_event("create ssh project".to_string());
 208
 209                            workspace
 210                        })
 211                    })
 212                    .log_err();
 213                    this.update(&mut cx, |_, cx| {
 214                        cx.emit(DismissEvent);
 215                    })
 216                    .ok();
 217                    Some(())
 218                }
 219            })
 220            .shared();
 221        cx.new_view(|_| Self {
 222            _path_task,
 223            picker,
 224            connection_string,
 225            nickname,
 226        })
 227    }
 228}
 229
 230impl gpui::Render for ProjectPicker {
 231    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 232        v_flex()
 233            .child(
 234                SshConnectionHeader {
 235                    connection_string: self.connection_string.clone(),
 236                    paths: Default::default(),
 237                    nickname: self.nickname.clone(),
 238                }
 239                .render(cx),
 240            )
 241            .child(
 242                div()
 243                    .border_t_1()
 244                    .border_color(cx.theme().colors().border_variant)
 245                    .child(self.picker.clone()),
 246            )
 247    }
 248}
 249
 250#[derive(Clone)]
 251struct ProjectEntry {
 252    open_folder: NavigableEntry,
 253    projects: Vec<(NavigableEntry, SshProject)>,
 254    configure: NavigableEntry,
 255    connection: SshConnection,
 256}
 257
 258#[derive(Clone)]
 259struct DefaultState {
 260    scrollbar: ScrollbarState,
 261    add_new_server: NavigableEntry,
 262    servers: Vec<ProjectEntry>,
 263}
 264impl DefaultState {
 265    fn new(cx: &WindowContext<'_>) -> Self {
 266        let handle = ScrollHandle::new();
 267        let scrollbar = ScrollbarState::new(handle.clone());
 268        let add_new_server = NavigableEntry::new(&handle, cx);
 269        let servers = SshSettings::get_global(cx)
 270            .ssh_connections()
 271            .map(|connection| {
 272                let open_folder = NavigableEntry::new(&handle, cx);
 273                let configure = NavigableEntry::new(&handle, cx);
 274                let projects = connection
 275                    .projects
 276                    .iter()
 277                    .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
 278                    .collect();
 279                ProjectEntry {
 280                    open_folder,
 281                    configure,
 282                    projects,
 283                    connection,
 284                }
 285            })
 286            .collect();
 287        Self {
 288            scrollbar,
 289            add_new_server,
 290            servers,
 291        }
 292    }
 293}
 294
 295#[derive(Clone)]
 296struct ViewServerOptionsState {
 297    server_index: usize,
 298    connection: SshConnection,
 299    entries: [NavigableEntry; 4],
 300}
 301enum Mode {
 302    Default(DefaultState),
 303    ViewServerOptions(ViewServerOptionsState),
 304    EditNickname(EditNicknameState),
 305    ProjectPicker(View<ProjectPicker>),
 306    CreateRemoteServer(CreateRemoteServer),
 307}
 308
 309impl Mode {
 310    fn default_mode(cx: &WindowContext<'_>) -> Self {
 311        Self::Default(DefaultState::new(cx))
 312    }
 313}
 314impl RemoteServerProjects {
 315    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
 316        workspace.register_action(|workspace, _: &OpenRemote, cx| {
 317            let handle = cx.view().downgrade();
 318            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 319        });
 320    }
 321
 322    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
 323        workspace.update(cx, |workspace, cx| {
 324            let handle = cx.view().downgrade();
 325            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 326        })
 327    }
 328
 329    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
 330        let focus_handle = cx.focus_handle();
 331
 332        let mut base_style = cx.text_style();
 333        base_style.refine(&gpui::TextStyleRefinement {
 334            color: Some(cx.theme().colors().editor_foreground),
 335            ..Default::default()
 336        });
 337
 338        Self {
 339            mode: Mode::default_mode(cx),
 340            focus_handle,
 341            workspace,
 342            retained_connections: Vec::new(),
 343        }
 344    }
 345
 346    pub fn project_picker(
 347        ix: usize,
 348        connection_options: remote::SshConnectionOptions,
 349        project: Model<Project>,
 350        cx: &mut ViewContext<Self>,
 351        workspace: WeakView<Workspace>,
 352    ) -> Self {
 353        let mut this = Self::new(cx, workspace.clone());
 354        this.mode = Mode::ProjectPicker(ProjectPicker::new(
 355            ix,
 356            connection_options,
 357            project,
 358            workspace,
 359            cx,
 360        ));
 361        cx.notify();
 362
 363        this
 364    }
 365
 366    fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
 367        let input = get_text(&editor, cx);
 368        if input.is_empty() {
 369            return;
 370        }
 371
 372        let connection_options = match SshConnectionOptions::parse_command_line(&input) {
 373            Ok(c) => c,
 374            Err(e) => {
 375                self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 376                    address_editor: editor,
 377                    address_error: Some(format!("could not parse: {:?}", e).into()),
 378                    ssh_prompt: None,
 379                    _creating: None,
 380                });
 381                return;
 382            }
 383        };
 384        let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 385
 386        let connection = connect_over_ssh(
 387            connection_options.remote_server_identifier(),
 388            connection_options.clone(),
 389            ssh_prompt.clone(),
 390            cx,
 391        )
 392        .prompt_err("Failed to connect", cx, |_, _| None);
 393
 394        let address_editor = editor.clone();
 395        let creating = cx.spawn(move |this, mut cx| async move {
 396            match connection.await {
 397                Some(Some(client)) => this
 398                    .update(&mut cx, |this, cx| {
 399                        let _ = this.workspace.update(cx, |workspace, _| {
 400                            workspace
 401                                .client()
 402                                .telemetry()
 403                                .report_app_event("create ssh server".to_string())
 404                        });
 405                        this.retained_connections.push(client);
 406                        this.add_ssh_server(connection_options, cx);
 407                        this.mode = Mode::default_mode(cx);
 408                        cx.notify()
 409                    })
 410                    .log_err(),
 411                _ => this
 412                    .update(&mut cx, |this, cx| {
 413                        address_editor.update(cx, |this, _| {
 414                            this.set_read_only(false);
 415                        });
 416                        this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 417                            address_editor,
 418                            address_error: None,
 419                            ssh_prompt: None,
 420                            _creating: None,
 421                        });
 422                        cx.notify()
 423                    })
 424                    .log_err(),
 425            };
 426            None
 427        });
 428
 429        editor.update(cx, |this, _| {
 430            this.set_read_only(true);
 431        });
 432        self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
 433            address_editor: editor,
 434            address_error: None,
 435            ssh_prompt: Some(ssh_prompt.clone()),
 436            _creating: Some(creating),
 437        });
 438    }
 439
 440    fn view_server_options(
 441        &mut self,
 442        (server_index, connection): (usize, SshConnection),
 443        cx: &mut ViewContext<Self>,
 444    ) {
 445        self.mode = Mode::ViewServerOptions(ViewServerOptionsState {
 446            server_index,
 447            connection,
 448            entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
 449        });
 450        self.focus_handle(cx).focus(cx);
 451        cx.notify();
 452    }
 453
 454    fn create_ssh_project(
 455        &mut self,
 456        ix: usize,
 457        ssh_connection: SshConnection,
 458        cx: &mut ViewContext<Self>,
 459    ) {
 460        let Some(workspace) = self.workspace.upgrade() else {
 461            return;
 462        };
 463
 464        let connection_options = ssh_connection.into();
 465        workspace.update(cx, |_, cx| {
 466            cx.defer(move |workspace, cx| {
 467                workspace.toggle_modal(cx, |cx| {
 468                    SshConnectionModal::new(&connection_options, Vec::new(), cx)
 469                });
 470                let prompt = workspace
 471                    .active_modal::<SshConnectionModal>(cx)
 472                    .unwrap()
 473                    .read(cx)
 474                    .prompt
 475                    .clone();
 476
 477                let connect = connect_over_ssh(
 478                    connection_options.remote_server_identifier(),
 479                    connection_options.clone(),
 480                    prompt,
 481                    cx,
 482                )
 483                .prompt_err("Failed to connect", cx, |_, _| None);
 484
 485                cx.spawn(move |workspace, mut cx| async move {
 486                    let session = connect.await;
 487
 488                    workspace
 489                        .update(&mut cx, |workspace, cx| {
 490                            if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
 491                                prompt.update(cx, |prompt, cx| prompt.finished(cx))
 492                            }
 493                        })
 494                        .ok();
 495
 496                    let Some(Some(session)) = session else {
 497                        workspace
 498                            .update(&mut cx, |workspace, cx| {
 499                                let weak = cx.view().downgrade();
 500                                workspace
 501                                    .toggle_modal(cx, |cx| RemoteServerProjects::new(cx, weak));
 502                            })
 503                            .log_err();
 504                        return;
 505                    };
 506
 507                    workspace
 508                        .update(&mut cx, |workspace, cx| {
 509                            let app_state = workspace.app_state().clone();
 510                            let weak = cx.view().downgrade();
 511                            let project = project::Project::ssh(
 512                                session,
 513                                app_state.client.clone(),
 514                                app_state.node_runtime.clone(),
 515                                app_state.user_store.clone(),
 516                                app_state.languages.clone(),
 517                                app_state.fs.clone(),
 518                                cx,
 519                            );
 520                            workspace.toggle_modal(cx, |cx| {
 521                                RemoteServerProjects::project_picker(
 522                                    ix,
 523                                    connection_options,
 524                                    project,
 525                                    cx,
 526                                    weak,
 527                                )
 528                            });
 529                        })
 530                        .ok();
 531                })
 532                .detach()
 533            })
 534        })
 535    }
 536
 537    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 538        match &self.mode {
 539            Mode::Default(_) | Mode::ViewServerOptions(_) => {}
 540            Mode::ProjectPicker(_) => {}
 541            Mode::CreateRemoteServer(state) => {
 542                if let Some(prompt) = state.ssh_prompt.as_ref() {
 543                    prompt.update(cx, |prompt, cx| {
 544                        prompt.confirm(cx);
 545                    });
 546                    return;
 547                }
 548
 549                self.create_ssh_server(state.address_editor.clone(), cx);
 550            }
 551            Mode::EditNickname(state) => {
 552                let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
 553                let index = state.index;
 554                self.update_settings_file(cx, move |setting, _| {
 555                    if let Some(connections) = setting.ssh_connections.as_mut() {
 556                        if let Some(connection) = connections.get_mut(index) {
 557                            connection.nickname = text;
 558                        }
 559                    }
 560                });
 561                self.mode = Mode::default_mode(cx);
 562                self.focus_handle.focus(cx);
 563            }
 564        }
 565    }
 566
 567    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 568        match &self.mode {
 569            Mode::Default(_) => cx.emit(DismissEvent),
 570            Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
 571                let new_state = CreateRemoteServer::new(cx);
 572                let old_prompt = state.address_editor.read(cx).text(cx);
 573                new_state.address_editor.update(cx, |this, cx| {
 574                    this.set_text(old_prompt, cx);
 575                });
 576
 577                self.mode = Mode::CreateRemoteServer(new_state);
 578                cx.notify();
 579            }
 580            _ => {
 581                self.mode = Mode::default_mode(cx);
 582                self.focus_handle(cx).focus(cx);
 583                cx.notify();
 584            }
 585        }
 586    }
 587
 588    fn render_ssh_connection(
 589        &mut self,
 590        ix: usize,
 591        ssh_server: ProjectEntry,
 592        cx: &mut ViewContext<Self>,
 593    ) -> impl IntoElement {
 594        let (main_label, aux_label) = if let Some(nickname) = ssh_server.connection.nickname.clone()
 595        {
 596            let aux_label = SharedString::from(format!("({})", ssh_server.connection.host));
 597            (nickname.into(), Some(aux_label))
 598        } else {
 599            (ssh_server.connection.host.clone(), None)
 600        };
 601        v_flex()
 602            .w_full()
 603            .child(ListSeparator)
 604            .child(
 605                h_flex()
 606                    .group("ssh-server")
 607                    .w_full()
 608                    .pt_0p5()
 609                    .px_3()
 610                    .gap_1()
 611                    .overflow_hidden()
 612                    .child(
 613                        div().max_w_96().overflow_hidden().text_ellipsis().child(
 614                            Label::new(main_label)
 615                                .size(LabelSize::Small)
 616                                .color(Color::Muted),
 617                        ),
 618                    )
 619                    .children(
 620                        aux_label.map(|label| {
 621                            Label::new(label).size(LabelSize::Small).color(Color::Muted)
 622                        }),
 623                    ),
 624            )
 625            .child(
 626                List::new()
 627                    .empty_message("No projects.")
 628                    .children(ssh_server.projects.iter().enumerate().map(|(pix, p)| {
 629                        v_flex().gap_0p5().child(self.render_ssh_project(
 630                            ix,
 631                            &ssh_server,
 632                            pix,
 633                            p,
 634                            cx,
 635                        ))
 636                    }))
 637                    .child(
 638                        h_flex()
 639                            .id(("new-remote-project-container", ix))
 640                            .track_focus(&ssh_server.open_folder.focus_handle)
 641                            .anchor_scroll(ssh_server.open_folder.scroll_anchor.clone())
 642                            .on_action(cx.listener({
 643                                let ssh_connection = ssh_server.clone();
 644                                move |this, _: &menu::Confirm, cx| {
 645                                    this.create_ssh_project(
 646                                        ix,
 647                                        ssh_connection.connection.clone(),
 648                                        cx,
 649                                    );
 650                                }
 651                            }))
 652                            .child(
 653                                ListItem::new(("new-remote-project", ix))
 654                                    .selected(
 655                                        ssh_server.open_folder.focus_handle.contains_focused(cx),
 656                                    )
 657                                    .inset(true)
 658                                    .spacing(ui::ListItemSpacing::Sparse)
 659                                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
 660                                    .child(Label::new("Open Folder"))
 661                                    .on_click(cx.listener({
 662                                        let ssh_connection = ssh_server.clone();
 663                                        move |this, _, cx| {
 664                                            this.create_ssh_project(
 665                                                ix,
 666                                                ssh_connection.connection.clone(),
 667                                                cx,
 668                                            );
 669                                        }
 670                                    })),
 671                            ),
 672                    )
 673                    .child(
 674                        h_flex()
 675                            .id(("server-options-container", ix))
 676                            .track_focus(&ssh_server.configure.focus_handle)
 677                            .anchor_scroll(ssh_server.configure.scroll_anchor.clone())
 678                            .on_action(cx.listener({
 679                                let ssh_connection = ssh_server.clone();
 680                                move |this, _: &menu::Confirm, cx| {
 681                                    this.view_server_options(
 682                                        (ix, ssh_connection.connection.clone()),
 683                                        cx,
 684                                    );
 685                                }
 686                            }))
 687                            .child(
 688                                ListItem::new(("server-options", ix))
 689                                    .selected(
 690                                        ssh_server.configure.focus_handle.contains_focused(cx),
 691                                    )
 692                                    .inset(true)
 693                                    .spacing(ui::ListItemSpacing::Sparse)
 694                                    .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
 695                                    .child(Label::new("View Server Options"))
 696                                    .on_click(cx.listener({
 697                                        let ssh_connection = ssh_server.clone();
 698                                        move |this, _, cx| {
 699                                            this.view_server_options(
 700                                                (ix, ssh_connection.connection.clone()),
 701                                                cx,
 702                                            );
 703                                        }
 704                                    })),
 705                            ),
 706                    ),
 707            )
 708    }
 709
 710    fn render_ssh_project(
 711        &mut self,
 712        server_ix: usize,
 713        server: &ProjectEntry,
 714        ix: usize,
 715        (navigation, project): &(NavigableEntry, SshProject),
 716        cx: &ViewContext<Self>,
 717    ) -> impl IntoElement {
 718        let server = server.clone();
 719        let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
 720        let container_element_id_base =
 721            SharedString::from(format!("remote-project-container-{element_id_base}"));
 722
 723        let callback = Arc::new({
 724            let project = project.clone();
 725            move |this: &mut Self, cx: &mut ViewContext<Self>| {
 726                let Some(app_state) = this
 727                    .workspace
 728                    .update(cx, |workspace, _| workspace.app_state().clone())
 729                    .log_err()
 730                else {
 731                    return;
 732                };
 733                let project = project.clone();
 734                let server = server.connection.clone();
 735                cx.emit(DismissEvent);
 736                cx.spawn(|_, mut cx| async move {
 737                    let result = open_ssh_project(
 738                        server.into(),
 739                        project.paths.into_iter().map(PathBuf::from).collect(),
 740                        app_state,
 741                        OpenOptions::default(),
 742                        &mut cx,
 743                    )
 744                    .await;
 745                    if let Err(e) = result {
 746                        log::error!("Failed to connect: {:?}", e);
 747                        cx.prompt(
 748                            gpui::PromptLevel::Critical,
 749                            "Failed to connect",
 750                            Some(&e.to_string()),
 751                            &["Ok"],
 752                        )
 753                        .await
 754                        .ok();
 755                    }
 756                })
 757                .detach();
 758            }
 759        });
 760
 761        div()
 762            .id((container_element_id_base, ix))
 763            .track_focus(&navigation.focus_handle)
 764            .anchor_scroll(navigation.scroll_anchor.clone())
 765            .on_action(cx.listener({
 766                let callback = callback.clone();
 767                move |this, _: &menu::Confirm, cx| {
 768                    callback(this, cx);
 769                }
 770            }))
 771            .child(
 772                ListItem::new((element_id_base, ix))
 773                    .selected(navigation.focus_handle.contains_focused(cx))
 774                    .inset(true)
 775                    .spacing(ui::ListItemSpacing::Sparse)
 776                    .start_slot(
 777                        Icon::new(IconName::Folder)
 778                            .color(Color::Muted)
 779                            .size(IconSize::Small),
 780                    )
 781                    .child(Label::new(project.paths.join(", ")))
 782                    .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
 783                    .end_hover_slot::<AnyElement>(Some(
 784                        div()
 785                            .mr_2()
 786                            .child(
 787                                // Right-margin to offset it from the Scrollbar
 788                                IconButton::new("remove-remote-project", IconName::TrashAlt)
 789                                    .icon_size(IconSize::Small)
 790                                    .shape(IconButtonShape::Square)
 791                                    .size(ButtonSize::Large)
 792                                    .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
 793                                    .on_click(cx.listener(move |this, _, cx| {
 794                                        this.delete_ssh_project(server_ix, ix, cx)
 795                                    })),
 796                            )
 797                            .into_any_element(),
 798                    )),
 799            )
 800    }
 801
 802    fn update_settings_file(
 803        &mut self,
 804        cx: &mut ViewContext<Self>,
 805        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 806    ) {
 807        let Some(fs) = self
 808            .workspace
 809            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 810            .log_err()
 811        else {
 812            return;
 813        };
 814        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 815    }
 816
 817    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 818        self.update_settings_file(cx, move |setting, _| {
 819            if let Some(connections) = setting.ssh_connections.as_mut() {
 820                connections.remove(server);
 821            }
 822        });
 823    }
 824
 825    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 826        self.update_settings_file(cx, move |setting, _| {
 827            if let Some(server) = setting
 828                .ssh_connections
 829                .as_mut()
 830                .and_then(|connections| connections.get_mut(server))
 831            {
 832                server.projects.remove(project);
 833            }
 834        });
 835    }
 836
 837    fn add_ssh_server(
 838        &mut self,
 839        connection_options: remote::SshConnectionOptions,
 840        cx: &mut ViewContext<Self>,
 841    ) {
 842        self.update_settings_file(cx, move |setting, _| {
 843            setting
 844                .ssh_connections
 845                .get_or_insert(Default::default())
 846                .push(SshConnection {
 847                    host: SharedString::from(connection_options.host),
 848                    username: connection_options.username,
 849                    port: connection_options.port,
 850                    projects: vec![],
 851                    nickname: None,
 852                    args: connection_options.args.unwrap_or_default(),
 853                    upload_binary_over_ssh: None,
 854                })
 855        });
 856    }
 857
 858    fn render_create_remote_server(
 859        &self,
 860        state: &CreateRemoteServer,
 861        cx: &mut ViewContext<Self>,
 862    ) -> impl IntoElement {
 863        let ssh_prompt = state.ssh_prompt.clone();
 864
 865        state.address_editor.update(cx, |editor, cx| {
 866            if editor.text(cx).is_empty() {
 867                editor.set_placeholder_text("ssh user@example -p 2222", cx);
 868            }
 869        });
 870
 871        let theme = cx.theme();
 872
 873        v_flex()
 874            .track_focus(&self.focus_handle(cx))
 875            .id("create-remote-server")
 876            .overflow_hidden()
 877            .size_full()
 878            .flex_1()
 879            .child(
 880                div()
 881                    .p_2()
 882                    .border_b_1()
 883                    .border_color(theme.colors().border_variant)
 884                    .child(state.address_editor.clone()),
 885            )
 886            .child(
 887                h_flex()
 888                    .bg(theme.colors().editor_background)
 889                    .rounded_b_md()
 890                    .w_full()
 891                    .map(|this| {
 892                        if let Some(ssh_prompt) = ssh_prompt {
 893                            this.child(h_flex().w_full().child(ssh_prompt))
 894                        } else if let Some(address_error) = &state.address_error {
 895                            this.child(
 896                                h_flex().p_2().w_full().gap_2().child(
 897                                    Label::new(address_error.clone())
 898                                        .size(LabelSize::Small)
 899                                        .color(Color::Error),
 900                                ),
 901                            )
 902                        } else {
 903                            this.child(
 904                                h_flex()
 905                                    .p_2()
 906                                    .w_full()
 907                                    .gap_1()
 908                                    .child(
 909                                        Label::new(
 910                                            "Enter the command you use to SSH into this server.",
 911                                        )
 912                                        .color(Color::Muted)
 913                                        .size(LabelSize::Small),
 914                                    )
 915                                    .child(
 916                                        Button::new("learn-more", "Learn more…")
 917                                            .label_size(LabelSize::Small)
 918                                            .size(ButtonSize::None)
 919                                            .color(Color::Accent)
 920                                            .style(ButtonStyle::Transparent)
 921                                            .on_click(|_, cx| {
 922                                                cx.open_url(
 923                                                    "https://zed.dev/docs/remote-development",
 924                                                );
 925                                            }),
 926                                    ),
 927                            )
 928                        }
 929                    }),
 930            )
 931    }
 932
 933    fn render_view_options(
 934        &mut self,
 935        ViewServerOptionsState {
 936            server_index,
 937            connection,
 938            entries,
 939        }: ViewServerOptionsState,
 940        cx: &mut ViewContext<Self>,
 941    ) -> impl IntoElement {
 942        let connection_string = connection.host.clone();
 943
 944        let mut view = Navigable::new(
 945            div()
 946                .track_focus(&self.focus_handle(cx))
 947                .size_full()
 948                .child(
 949                    SshConnectionHeader {
 950                        connection_string: connection_string.clone(),
 951                        paths: Default::default(),
 952                        nickname: connection.nickname.clone().map(|s| s.into()),
 953                    }
 954                    .render(cx),
 955                )
 956                .child(
 957                    v_flex()
 958                        .pb_1()
 959                        .child(ListSeparator)
 960                        .child({
 961                            let label = if connection.nickname.is_some() {
 962                                "Edit Nickname"
 963                            } else {
 964                                "Add Nickname to Server"
 965                            };
 966                            div()
 967                                .id("ssh-options-add-nickname")
 968                                .track_focus(&entries[0].focus_handle)
 969                                .on_action(cx.listener(move |this, _: &menu::Confirm, cx| {
 970                                    this.mode = Mode::EditNickname(EditNicknameState::new(
 971                                        server_index,
 972                                        cx,
 973                                    ));
 974                                    cx.notify();
 975                                }))
 976                                .child(
 977                                    ListItem::new("add-nickname")
 978                                        .selected(entries[0].focus_handle.contains_focused(cx))
 979                                        .inset(true)
 980                                        .spacing(ui::ListItemSpacing::Sparse)
 981                                        .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
 982                                        .child(Label::new(label))
 983                                        .on_click(cx.listener(move |this, _, cx| {
 984                                            this.mode = Mode::EditNickname(EditNicknameState::new(
 985                                                server_index,
 986                                                cx,
 987                                            ));
 988                                            cx.notify();
 989                                        })),
 990                                )
 991                        })
 992                        .child({
 993                            let workspace = self.workspace.clone();
 994                            fn callback(
 995                                workspace: WeakView<Workspace>,
 996                                connection_string: SharedString,
 997                                cx: &mut WindowContext<'_>,
 998                            ) {
 999                                cx.write_to_clipboard(ClipboardItem::new_string(
1000                                    connection_string.to_string(),
1001                                ));
1002                                workspace
1003                                    .update(cx, |this, cx| {
1004                                        struct SshServerAddressCopiedToClipboard;
1005                                        let notification = format!(
1006                                            "Copied server address ({}) to clipboard",
1007                                            connection_string
1008                                        );
1009
1010                                        this.show_toast(
1011                                            Toast::new(
1012                                                NotificationId::composite::<
1013                                                    SshServerAddressCopiedToClipboard,
1014                                                >(
1015                                                    connection_string.clone()
1016                                                ),
1017                                                notification,
1018                                            )
1019                                            .autohide(),
1020                                            cx,
1021                                        );
1022                                    })
1023                                    .ok();
1024                            }
1025                            div()
1026                                .id("ssh-options-copy-server-address")
1027                                .track_focus(&entries[1].focus_handle)
1028                                .on_action({
1029                                    let connection_string = connection_string.clone();
1030                                    let workspace = self.workspace.clone();
1031                                    move |_: &menu::Confirm, cx| {
1032                                        callback(workspace.clone(), connection_string.clone(), cx);
1033                                    }
1034                                })
1035                                .child(
1036                                    ListItem::new("copy-server-address")
1037                                        .selected(entries[1].focus_handle.contains_focused(cx))
1038                                        .inset(true)
1039                                        .spacing(ui::ListItemSpacing::Sparse)
1040                                        .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1041                                        .child(Label::new("Copy Server Address"))
1042                                        .end_hover_slot(
1043                                            Label::new(connection_string.clone())
1044                                                .color(Color::Muted),
1045                                        )
1046                                        .on_click({
1047                                            let connection_string = connection_string.clone();
1048                                            move |_, cx| {
1049                                                callback(
1050                                                    workspace.clone(),
1051                                                    connection_string.clone(),
1052                                                    cx,
1053                                                );
1054                                            }
1055                                        }),
1056                                )
1057                        })
1058                        .child({
1059                            fn remove_ssh_server(
1060                                remote_servers: View<RemoteServerProjects>,
1061                                index: usize,
1062                                connection_string: SharedString,
1063                                cx: &mut WindowContext<'_>,
1064                            ) {
1065                                let prompt_message =
1066                                    format!("Remove server `{}`?", connection_string);
1067
1068                                let confirmation = cx.prompt(
1069                                    PromptLevel::Warning,
1070                                    &prompt_message,
1071                                    None,
1072                                    &["Yes, remove it", "No, keep it"],
1073                                );
1074
1075                                cx.spawn(|mut cx| async move {
1076                                    if confirmation.await.ok() == Some(0) {
1077                                        remote_servers
1078                                            .update(&mut cx, |this, cx| {
1079                                                this.delete_ssh_server(index, cx);
1080                                            })
1081                                            .ok();
1082                                        remote_servers
1083                                            .update(&mut cx, |this, cx| {
1084                                                this.mode = Mode::default_mode(cx);
1085                                                cx.notify();
1086                                            })
1087                                            .ok();
1088                                    }
1089                                    anyhow::Ok(())
1090                                })
1091                                .detach_and_log_err(cx);
1092                            }
1093                            div()
1094                                .id("ssh-options-copy-server-address")
1095                                .track_focus(&entries[2].focus_handle)
1096                                .on_action(cx.listener({
1097                                    let connection_string = connection_string.clone();
1098                                    move |_, _: &menu::Confirm, cx| {
1099                                        remove_ssh_server(
1100                                            cx.view().clone(),
1101                                            server_index,
1102                                            connection_string.clone(),
1103                                            cx,
1104                                        );
1105                                        cx.focus_self();
1106                                    }
1107                                }))
1108                                .child(
1109                                    ListItem::new("remove-server")
1110                                        .selected(entries[2].focus_handle.contains_focused(cx))
1111                                        .inset(true)
1112                                        .spacing(ui::ListItemSpacing::Sparse)
1113                                        .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1114                                        .child(Label::new("Remove Server").color(Color::Error))
1115                                        .on_click(cx.listener(move |_, _, cx| {
1116                                            remove_ssh_server(
1117                                                cx.view().clone(),
1118                                                server_index,
1119                                                connection_string.clone(),
1120                                                cx,
1121                                            );
1122                                            cx.focus_self();
1123                                        })),
1124                                )
1125                        })
1126                        .child(ListSeparator)
1127                        .child({
1128                            div()
1129                                .id("ssh-options-copy-server-address")
1130                                .track_focus(&entries[3].focus_handle)
1131                                .on_action(cx.listener(|this, _: &menu::Confirm, cx| {
1132                                    this.mode = Mode::default_mode(cx);
1133                                    cx.focus_self();
1134                                    cx.notify();
1135                                }))
1136                                .child(
1137                                    ListItem::new("go-back")
1138                                        .selected(entries[3].focus_handle.contains_focused(cx))
1139                                        .inset(true)
1140                                        .spacing(ui::ListItemSpacing::Sparse)
1141                                        .start_slot(
1142                                            Icon::new(IconName::ArrowLeft).color(Color::Muted),
1143                                        )
1144                                        .child(Label::new("Go Back"))
1145                                        .on_click(cx.listener(|this, _, cx| {
1146                                            this.mode = Mode::default_mode(cx);
1147                                            cx.focus_self();
1148                                            cx.notify()
1149                                        })),
1150                                )
1151                        }),
1152                )
1153                .into_any_element(),
1154        );
1155        for entry in entries {
1156            view = view.entry(entry);
1157        }
1158
1159        view.render(cx).into_any_element()
1160    }
1161
1162    fn render_edit_nickname(
1163        &self,
1164        state: &EditNicknameState,
1165        cx: &mut ViewContext<Self>,
1166    ) -> impl IntoElement {
1167        let Some(connection) = SshSettings::get_global(cx)
1168            .ssh_connections()
1169            .nth(state.index)
1170        else {
1171            return v_flex()
1172                .id("ssh-edit-nickname")
1173                .track_focus(&self.focus_handle(cx));
1174        };
1175
1176        let connection_string = connection.host.clone();
1177        let nickname = connection.nickname.clone().map(|s| s.into());
1178
1179        v_flex()
1180            .id("ssh-edit-nickname")
1181            .track_focus(&self.focus_handle(cx))
1182            .child(
1183                SshConnectionHeader {
1184                    connection_string,
1185                    paths: Default::default(),
1186                    nickname,
1187                }
1188                .render(cx),
1189            )
1190            .child(
1191                h_flex()
1192                    .p_2()
1193                    .border_t_1()
1194                    .border_color(cx.theme().colors().border_variant)
1195                    .child(state.editor.clone()),
1196            )
1197    }
1198
1199    fn render_default(
1200        &mut self,
1201        mut state: DefaultState,
1202        cx: &mut ViewContext<Self>,
1203    ) -> impl IntoElement {
1204        if SshSettings::get_global(cx)
1205            .ssh_connections
1206            .as_ref()
1207            .map_or(false, |connections| {
1208                state
1209                    .servers
1210                    .iter()
1211                    .map(|server| &server.connection)
1212                    .ne(connections.iter())
1213            })
1214        {
1215            self.mode = Mode::default_mode(cx);
1216            if let Mode::Default(new_state) = &self.mode {
1217                state = new_state.clone();
1218            }
1219        }
1220        let scroll_state = state.scrollbar.parent_view(cx.view());
1221        let connect_button = div()
1222            .id("ssh-connect-new-server-container")
1223            .track_focus(&state.add_new_server.focus_handle)
1224            .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1225            .child(
1226                ListItem::new("register-remove-server-button")
1227                    .selected(state.add_new_server.focus_handle.contains_focused(cx))
1228                    .inset(true)
1229                    .spacing(ui::ListItemSpacing::Sparse)
1230                    .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1231                    .child(Label::new("Connect New Server"))
1232                    .on_click(cx.listener(|this, _, cx| {
1233                        let state = CreateRemoteServer::new(cx);
1234                        this.mode = Mode::CreateRemoteServer(state);
1235
1236                        cx.notify();
1237                    })),
1238            )
1239            .on_action(cx.listener(|this, _: &menu::Confirm, cx| {
1240                let state = CreateRemoteServer::new(cx);
1241                this.mode = Mode::CreateRemoteServer(state);
1242
1243                cx.notify();
1244            }));
1245
1246        let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
1247            unreachable!()
1248        };
1249
1250        let mut modal_section = Navigable::new(
1251            v_flex()
1252                .track_focus(&self.focus_handle(cx))
1253                .id("ssh-server-list")
1254                .overflow_y_scroll()
1255                .track_scroll(&scroll_handle)
1256                .size_full()
1257                .child(connect_button)
1258                .child(
1259                    List::new()
1260                        .empty_message(
1261                            v_flex()
1262                                .child(
1263                                    div().px_3().child(
1264                                        Label::new("No remote servers registered yet.")
1265                                            .color(Color::Muted),
1266                                    ),
1267                                )
1268                                .into_any_element(),
1269                        )
1270                        .children(state.servers.iter().enumerate().map(|(ix, connection)| {
1271                            self.render_ssh_connection(ix, connection.clone(), cx)
1272                                .into_any_element()
1273                        })),
1274                )
1275                .into_any_element(),
1276        )
1277        .entry(state.add_new_server.clone());
1278
1279        for server in &state.servers {
1280            for (navigation_state, _) in &server.projects {
1281                modal_section = modal_section.entry(navigation_state.clone());
1282            }
1283            modal_section = modal_section
1284                .entry(server.open_folder.clone())
1285                .entry(server.configure.clone());
1286        }
1287        let mut modal_section = modal_section.render(cx).into_any_element();
1288
1289        Modal::new("remote-projects", None)
1290            .header(
1291                ModalHeader::new()
1292                    .child(Headline::new("Remote Projects (beta)").size(HeadlineSize::XSmall)),
1293            )
1294            .section(
1295                Section::new().padded(false).child(
1296                    v_flex()
1297                        .min_h(rems(20.))
1298                        .size_full()
1299                        .relative()
1300                        .child(ListSeparator)
1301                        .child(
1302                            canvas(
1303                                |bounds, cx| {
1304                                    modal_section.prepaint_as_root(
1305                                        bounds.origin,
1306                                        bounds.size.into(),
1307                                        cx,
1308                                    );
1309                                    modal_section
1310                                },
1311                                |_, mut modal_section, cx| {
1312                                    modal_section.paint(cx);
1313                                },
1314                            )
1315                            .size_full(),
1316                        )
1317                        .child(
1318                            div()
1319                                .occlude()
1320                                .h_full()
1321                                .absolute()
1322                                .top_1()
1323                                .bottom_1()
1324                                .right_1()
1325                                .w(px(8.))
1326                                .children(Scrollbar::vertical(scroll_state)),
1327                        ),
1328                ),
1329            )
1330            .into_any_element()
1331    }
1332}
1333
1334fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1335    element.read(cx).text(cx).trim().to_string()
1336}
1337
1338impl ModalView for RemoteServerProjects {}
1339
1340impl FocusableView for RemoteServerProjects {
1341    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
1342        match &self.mode {
1343            Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1344            _ => self.focus_handle.clone(),
1345        }
1346    }
1347}
1348
1349impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1350
1351impl Render for RemoteServerProjects {
1352    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1353        div()
1354            .elevation_3(cx)
1355            .w(rems(34.))
1356            .key_context("RemoteServerModal")
1357            .on_action(cx.listener(Self::cancel))
1358            .on_action(cx.listener(Self::confirm))
1359            .capture_any_mouse_down(cx.listener(|this, _, cx| {
1360                this.focus_handle(cx).focus(cx);
1361            }))
1362            .on_mouse_down_out(cx.listener(|this, _, cx| {
1363                if matches!(this.mode, Mode::Default(_)) {
1364                    cx.emit(DismissEvent)
1365                }
1366            }))
1367            .child(match &self.mode {
1368                Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1369                Mode::ViewServerOptions(state) => self
1370                    .render_view_options(state.clone(), cx)
1371                    .into_any_element(),
1372                Mode::ProjectPicker(element) => element.clone().into_any_element(),
1373                Mode::CreateRemoteServer(state) => self
1374                    .render_create_remote_server(state, cx)
1375                    .into_any_element(),
1376                Mode::EditNickname(state) => {
1377                    self.render_edit_nickname(state, cx).into_any_element()
1378                }
1379            })
1380    }
1381}