remote_servers.rs

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