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