remote_servers.rs

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