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