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| SshConnectionModal::new(&connection_options, cx));
 485                let prompt = workspace
 486                    .active_modal::<SshConnectionModal>(cx)
 487                    .unwrap()
 488                    .read(cx)
 489                    .prompt
 490                    .clone();
 491
 492                let connect = connect_over_ssh(
 493                    connection_options.remote_server_identifier(),
 494                    connection_options.clone(),
 495                    prompt,
 496                    cx,
 497                )
 498                .prompt_err("Failed to connect", cx, |_, _| None);
 499
 500                cx.spawn(move |workspace, mut cx| async move {
 501                    let session = connect.await;
 502
 503                    workspace
 504                        .update(&mut cx, |workspace, cx| {
 505                            if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
 506                                prompt.update(cx, |prompt, cx| prompt.finished(cx))
 507                            }
 508                        })
 509                        .ok();
 510
 511                    let Some(session) = session else {
 512                        workspace
 513                            .update(&mut cx, |workspace, cx| {
 514                                let weak = cx.view().downgrade();
 515                                workspace
 516                                    .toggle_modal(cx, |cx| RemoteServerProjects::new(cx, weak));
 517                            })
 518                            .log_err();
 519                        return;
 520                    };
 521
 522                    workspace
 523                        .update(&mut cx, |workspace, cx| {
 524                            let app_state = workspace.app_state().clone();
 525                            let weak = cx.view().downgrade();
 526                            let project = project::Project::ssh(
 527                                session,
 528                                app_state.client.clone(),
 529                                app_state.node_runtime.clone(),
 530                                app_state.user_store.clone(),
 531                                app_state.languages.clone(),
 532                                app_state.fs.clone(),
 533                                cx,
 534                            );
 535                            workspace.toggle_modal(cx, |cx| {
 536                                RemoteServerProjects::project_picker(
 537                                    ix,
 538                                    connection_options,
 539                                    project,
 540                                    cx,
 541                                    weak,
 542                                )
 543                            });
 544                        })
 545                        .ok();
 546                })
 547                .detach()
 548            })
 549        })
 550    }
 551
 552    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 553        match &self.mode {
 554            Mode::Default(_) | Mode::ViewServerOptions(_, _) => {
 555                let items = std::mem::take(&mut self.selectable_items);
 556                items.confirm(self, cx);
 557                self.selectable_items = items;
 558            }
 559            Mode::ProjectPicker(_) => {}
 560            Mode::CreateRemoteServer(state) => {
 561                if let Some(prompt) = state.ssh_prompt.as_ref() {
 562                    prompt.update(cx, |prompt, cx| {
 563                        prompt.confirm(cx);
 564                    });
 565                    return;
 566                }
 567
 568                self.create_ssh_server(state.address_editor.clone(), cx);
 569            }
 570            Mode::EditNickname(state) => {
 571                let text = Some(state.editor.read(cx).text(cx))
 572                    .filter(|text| !text.is_empty())
 573                    .map(SharedString::from);
 574                let index = state.index;
 575                self.update_settings_file(cx, move |setting, _| {
 576                    if let Some(connections) = setting.ssh_connections.as_mut() {
 577                        if let Some(connection) = connections.get_mut(index) {
 578                            connection.nickname = text;
 579                        }
 580                    }
 581                });
 582                self.mode = Mode::default_mode();
 583                self.selectable_items.reset_selection();
 584                self.focus_handle.focus(cx);
 585            }
 586        }
 587    }
 588
 589    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 590        match &self.mode {
 591            Mode::Default(_) => cx.emit(DismissEvent),
 592            Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
 593                self.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(cx));
 594                self.selectable_items.reset_selection();
 595                cx.notify();
 596            }
 597            _ => {
 598                self.mode = Mode::default_mode();
 599                self.selectable_items.reset_selection();
 600                self.focus_handle(cx).focus(cx);
 601                cx.notify();
 602            }
 603        }
 604    }
 605
 606    fn render_ssh_connection(
 607        &mut self,
 608        ix: usize,
 609        ssh_connection: SshConnection,
 610        cx: &mut ViewContext<Self>,
 611    ) -> impl IntoElement {
 612        let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
 613            let aux_label = SharedString::from(format!("({})", ssh_connection.host));
 614            (nickname, Some(aux_label))
 615        } else {
 616            (ssh_connection.host.clone(), None)
 617        };
 618        v_flex()
 619            .w_full()
 620            .child(ListSeparator)
 621            .child(
 622                h_flex()
 623                    .group("ssh-server")
 624                    .w_full()
 625                    .pt_0p5()
 626                    .px_3()
 627                    .gap_1()
 628                    .overflow_hidden()
 629                    .whitespace_nowrap()
 630                    .child(
 631                        Label::new(main_label)
 632                            .size(LabelSize::Small)
 633                            .weight(FontWeight::SEMIBOLD)
 634                            .color(Color::Muted),
 635                    )
 636                    .children(
 637                        aux_label.map(|label| {
 638                            Label::new(label).size(LabelSize::Small).color(Color::Muted)
 639                        }),
 640                    ),
 641            )
 642            .child(
 643                List::new()
 644                    .empty_message("No projects.")
 645                    .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 646                        v_flex().gap_0p5().child(self.render_ssh_project(
 647                            ix,
 648                            &ssh_connection,
 649                            pix,
 650                            p,
 651                            cx,
 652                        ))
 653                    }))
 654                    .child(h_flex().map(|this| {
 655                        self.selectable_items.add_item(Box::new({
 656                            let ssh_connection = ssh_connection.clone();
 657                            move |this, cx| {
 658                                this.create_ssh_project(ix, ssh_connection.clone(), cx);
 659                            }
 660                        }));
 661                        let is_selected = self.selectable_items.is_selected();
 662                        this.child(
 663                            ListItem::new(("new-remote-project", ix))
 664                                .selected(is_selected)
 665                                .inset(true)
 666                                .spacing(ui::ListItemSpacing::Sparse)
 667                                .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
 668                                .child(Label::new("Open Folder"))
 669                                .on_click(cx.listener({
 670                                    let ssh_connection = ssh_connection.clone();
 671                                    move |this, _, cx| {
 672                                        this.create_ssh_project(ix, ssh_connection.clone(), cx);
 673                                    }
 674                                })),
 675                        )
 676                    }))
 677                    .child(h_flex().map(|this| {
 678                        self.selectable_items.add_item(Box::new({
 679                            let ssh_connection = ssh_connection.clone();
 680                            move |this, cx| {
 681                                this.view_server_options((ix, ssh_connection.clone()), cx);
 682                            }
 683                        }));
 684                        let is_selected = self.selectable_items.is_selected();
 685                        this.child(
 686                            ListItem::new(("server-options", ix))
 687                                .selected(is_selected)
 688                                .inset(true)
 689                                .spacing(ui::ListItemSpacing::Sparse)
 690                                .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
 691                                .child(Label::new("View Server Options"))
 692                                .on_click(cx.listener({
 693                                    let ssh_connection = ssh_connection.clone();
 694                                    move |this, _, cx| {
 695                                        this.view_server_options((ix, ssh_connection.clone()), cx);
 696                                    }
 697                                })),
 698                        )
 699                    })),
 700            )
 701    }
 702
 703    fn render_ssh_project(
 704        &mut self,
 705        server_ix: usize,
 706        server: &SshConnection,
 707        ix: usize,
 708        project: &SshProject,
 709        cx: &ViewContext<Self>,
 710    ) -> impl IntoElement {
 711        let server = server.clone();
 712
 713        let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
 714        let callback = Arc::new({
 715            let project = project.clone();
 716            move |this: &mut Self, cx: &mut ViewContext<Self>| {
 717                let Some(app_state) = this
 718                    .workspace
 719                    .update(cx, |workspace, _| workspace.app_state().clone())
 720                    .log_err()
 721                else {
 722                    return;
 723                };
 724                let project = project.clone();
 725                let server = server.clone();
 726                cx.spawn(|_, mut cx| async move {
 727                    let result = open_ssh_project(
 728                        server.into(),
 729                        project.paths.into_iter().map(PathBuf::from).collect(),
 730                        app_state,
 731                        OpenOptions::default(),
 732                        &mut cx,
 733                    )
 734                    .await;
 735                    if let Err(e) = result {
 736                        log::error!("Failed to connect: {:?}", e);
 737                        cx.prompt(
 738                            gpui::PromptLevel::Critical,
 739                            "Failed to connect",
 740                            Some(&e.to_string()),
 741                            &["Ok"],
 742                        )
 743                        .await
 744                        .ok();
 745                    }
 746                })
 747                .detach();
 748            }
 749        });
 750        self.selectable_items.add_item(Box::new({
 751            let callback = callback.clone();
 752            move |this, cx| callback(this, cx)
 753        }));
 754        let is_selected = self.selectable_items.is_selected();
 755
 756        ListItem::new((element_id_base, ix))
 757            .inset(true)
 758            .selected(is_selected)
 759            .spacing(ui::ListItemSpacing::Sparse)
 760            .start_slot(
 761                Icon::new(IconName::Folder)
 762                    .color(Color::Muted)
 763                    .size(IconSize::Small),
 764            )
 765            .child(Label::new(project.paths.join(", ")))
 766            .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
 767            .end_hover_slot::<AnyElement>(Some(
 768                IconButton::new("remove-remote-project", IconName::TrashAlt)
 769                    .icon_size(IconSize::Small)
 770                    .shape(IconButtonShape::Square)
 771                    .on_click(
 772                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
 773                    )
 774                    .size(ButtonSize::Large)
 775                    .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
 776                    .into_any_element(),
 777            ))
 778    }
 779
 780    fn update_settings_file(
 781        &mut self,
 782        cx: &mut ViewContext<Self>,
 783        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 784    ) {
 785        let Some(fs) = self
 786            .workspace
 787            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 788            .log_err()
 789        else {
 790            return;
 791        };
 792        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 793    }
 794
 795    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 796        self.update_settings_file(cx, move |setting, _| {
 797            if let Some(connections) = setting.ssh_connections.as_mut() {
 798                connections.remove(server);
 799            }
 800        });
 801    }
 802
 803    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 804        self.update_settings_file(cx, move |setting, _| {
 805            if let Some(server) = setting
 806                .ssh_connections
 807                .as_mut()
 808                .and_then(|connections| connections.get_mut(server))
 809            {
 810                server.projects.remove(project);
 811            }
 812        });
 813    }
 814
 815    fn add_ssh_server(
 816        &mut self,
 817        connection_options: remote::SshConnectionOptions,
 818        cx: &mut ViewContext<Self>,
 819    ) {
 820        self.update_settings_file(cx, move |setting, _| {
 821            setting
 822                .ssh_connections
 823                .get_or_insert(Default::default())
 824                .push(SshConnection {
 825                    host: SharedString::from(connection_options.host),
 826                    username: connection_options.username,
 827                    port: connection_options.port,
 828                    projects: vec![],
 829                    nickname: None,
 830                    args: connection_options.args.unwrap_or_default(),
 831                })
 832        });
 833    }
 834
 835    fn render_create_remote_server(
 836        &self,
 837        state: &CreateRemoteServer,
 838        cx: &mut ViewContext<Self>,
 839    ) -> impl IntoElement {
 840        let ssh_prompt = state.ssh_prompt.clone();
 841
 842        state.address_editor.update(cx, |editor, cx| {
 843            if editor.text(cx).is_empty() {
 844                editor.set_placeholder_text("ssh user@example -p 2222", cx);
 845            }
 846        });
 847
 848        let theme = cx.theme();
 849
 850        v_flex()
 851            .id("create-remote-server")
 852            .overflow_hidden()
 853            .size_full()
 854            .flex_1()
 855            .child(
 856                div()
 857                    .p_2()
 858                    .border_b_1()
 859                    .border_color(theme.colors().border_variant)
 860                    .child(state.address_editor.clone()),
 861            )
 862            .child(
 863                h_flex()
 864                    .bg(theme.colors().editor_background)
 865                    .rounded_b_md()
 866                    .w_full()
 867                    .map(|this| {
 868                        if let Some(ssh_prompt) = ssh_prompt {
 869                            this.child(h_flex().w_full().child(ssh_prompt))
 870                        } else if let Some(address_error) = &state.address_error {
 871                            this.child(
 872                                h_flex().p_2().w_full().gap_2().child(
 873                                    Label::new(address_error.clone())
 874                                        .size(LabelSize::Small)
 875                                        .color(Color::Error),
 876                                ),
 877                            )
 878                        } else {
 879                            this.child(
 880                                h_flex()
 881                                    .p_2()
 882                                    .w_full()
 883                                    .gap_1()
 884                                    .child(
 885                                        Label::new(
 886                                            "Enter the command you use to SSH into this server.",
 887                                        )
 888                                        .color(Color::Muted)
 889                                        .size(LabelSize::Small),
 890                                    )
 891                                    .child(
 892                                        Button::new("learn-more", "Learn more…")
 893                                            .label_size(LabelSize::Small)
 894                                            .size(ButtonSize::None)
 895                                            .color(Color::Accent)
 896                                            .style(ButtonStyle::Transparent)
 897                                            .on_click(|_, cx| {
 898                                                cx.open_url(
 899                                                    "https://zed.dev/docs/remote-development",
 900                                                );
 901                                            }),
 902                                    ),
 903                            )
 904                        }
 905                    }),
 906            )
 907    }
 908
 909    fn render_view_options(
 910        &mut self,
 911        index: usize,
 912        connection: SshConnection,
 913        cx: &mut ViewContext<Self>,
 914    ) -> impl IntoElement {
 915        let connection_string = connection.host.clone();
 916
 917        div()
 918            .size_full()
 919            .child(
 920                SshConnectionHeader {
 921                    connection_string: connection_string.clone(),
 922                    nickname: connection.nickname.clone(),
 923                }
 924                .render(cx),
 925            )
 926            .child(
 927                v_flex()
 928                    .py_1()
 929                    .child({
 930                        self.selectable_items.add_item(Box::new({
 931                            move |this, cx| {
 932                                this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
 933                                cx.notify();
 934                            }
 935                        }));
 936                        let is_selected = self.selectable_items.is_selected();
 937                        let label = if connection.nickname.is_some() {
 938                            "Edit Nickname"
 939                        } else {
 940                            "Add Nickname to Server"
 941                        };
 942                        ListItem::new("add-nickname")
 943                            .selected(is_selected)
 944                            .inset(true)
 945                            .spacing(ui::ListItemSpacing::Sparse)
 946                            .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
 947                            .child(Label::new(label))
 948                            .on_click(cx.listener(move |this, _, cx| {
 949                                this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
 950                                cx.notify();
 951                            }))
 952                    })
 953                    .child({
 954                        let workspace = self.workspace.clone();
 955                        fn callback(
 956                            workspace: WeakView<Workspace>,
 957                            connection_string: SharedString,
 958                            cx: &mut WindowContext<'_>,
 959                        ) {
 960                            cx.write_to_clipboard(ClipboardItem::new_string(
 961                                connection_string.to_string(),
 962                            ));
 963                            workspace
 964                                .update(cx, |this, cx| {
 965                                    struct SshServerAddressCopiedToClipboard;
 966                                    let notification = format!(
 967                                        "Copied server address ({}) to clipboard",
 968                                        connection_string
 969                                    );
 970
 971                                    this.show_toast(
 972                                        Toast::new(
 973                                            NotificationId::composite::<
 974                                                SshServerAddressCopiedToClipboard,
 975                                            >(
 976                                                connection_string.clone()
 977                                            ),
 978                                            notification,
 979                                        )
 980                                        .autohide(),
 981                                        cx,
 982                                    );
 983                                })
 984                                .ok();
 985                        }
 986                        self.selectable_items.add_item(Box::new({
 987                            let workspace = workspace.clone();
 988                            let connection_string = connection_string.clone();
 989                            move |_, cx| {
 990                                callback(workspace.clone(), connection_string.clone(), cx);
 991                            }
 992                        }));
 993                        let is_selected = self.selectable_items.is_selected();
 994                        ListItem::new("copy-server-address")
 995                            .selected(is_selected)
 996                            .inset(true)
 997                            .spacing(ui::ListItemSpacing::Sparse)
 998                            .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
 999                            .child(Label::new("Copy Server Address"))
1000                            .end_hover_slot(
1001                                Label::new(connection_string.clone()).color(Color::Muted),
1002                            )
1003                            .on_click({
1004                                let connection_string = connection_string.clone();
1005                                move |_, cx| {
1006                                    callback(workspace.clone(), connection_string.clone(), cx);
1007                                }
1008                            })
1009                    })
1010                    .child({
1011                        fn remove_ssh_server(
1012                            remote_servers: View<RemoteServerProjects>,
1013                            index: usize,
1014                            connection_string: SharedString,
1015                            cx: &mut WindowContext<'_>,
1016                        ) {
1017                            let prompt_message = format!("Remove server `{}`?", connection_string);
1018
1019                            let confirmation = cx.prompt(
1020                                PromptLevel::Warning,
1021                                &prompt_message,
1022                                None,
1023                                &["Yes, remove it", "No, keep it"],
1024                            );
1025
1026                            cx.spawn(|mut cx| async move {
1027                                if confirmation.await.ok() == Some(0) {
1028                                    remote_servers
1029                                        .update(&mut cx, |this, cx| {
1030                                            this.delete_ssh_server(index, cx);
1031                                            this.mode = Mode::default_mode();
1032                                            cx.notify();
1033                                        })
1034                                        .ok();
1035                                }
1036                                anyhow::Ok(())
1037                            })
1038                            .detach_and_log_err(cx);
1039                        }
1040                        self.selectable_items.add_item(Box::new({
1041                            let connection_string = connection_string.clone();
1042                            move |_, cx| {
1043                                remove_ssh_server(
1044                                    cx.view().clone(),
1045                                    index,
1046                                    connection_string.clone(),
1047                                    cx,
1048                                );
1049                            }
1050                        }));
1051                        let is_selected = self.selectable_items.is_selected();
1052                        ListItem::new("remove-server")
1053                            .selected(is_selected)
1054                            .inset(true)
1055                            .spacing(ui::ListItemSpacing::Sparse)
1056                            .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1057                            .child(Label::new("Remove Server").color(Color::Error))
1058                            .on_click(cx.listener(move |_, _, cx| {
1059                                remove_ssh_server(
1060                                    cx.view().clone(),
1061                                    index,
1062                                    connection_string.clone(),
1063                                    cx,
1064                                );
1065                            }))
1066                    })
1067                    .child(ListSeparator)
1068                    .child({
1069                        self.selectable_items.add_item(Box::new({
1070                            move |this, cx| {
1071                                this.mode = Mode::default_mode();
1072                                cx.notify();
1073                            }
1074                        }));
1075                        let is_selected = self.selectable_items.is_selected();
1076                        ListItem::new("go-back")
1077                            .selected(is_selected)
1078                            .inset(true)
1079                            .spacing(ui::ListItemSpacing::Sparse)
1080                            .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
1081                            .child(Label::new("Go Back"))
1082                            .on_click(cx.listener(|this, _, cx| {
1083                                this.mode = Mode::default_mode();
1084                                cx.notify()
1085                            }))
1086                    }),
1087            )
1088    }
1089
1090    fn render_edit_nickname(
1091        &self,
1092        state: &EditNicknameState,
1093        cx: &mut ViewContext<Self>,
1094    ) -> impl IntoElement {
1095        let Some(connection) = SshSettings::get_global(cx)
1096            .ssh_connections()
1097            .nth(state.index)
1098        else {
1099            return v_flex();
1100        };
1101
1102        let connection_string = connection.host.clone();
1103
1104        v_flex()
1105            .child(
1106                SshConnectionHeader {
1107                    connection_string,
1108                    nickname: connection.nickname.clone(),
1109                }
1110                .render(cx),
1111            )
1112            .child(h_flex().p_2().child(state.editor.clone()))
1113    }
1114
1115    fn render_default(
1116        &mut self,
1117        scroll_state: ScrollbarState,
1118        cx: &mut ViewContext<Self>,
1119    ) -> impl IntoElement {
1120        let scroll_state = scroll_state.parent_view(cx.view());
1121        let ssh_connections = SshSettings::get_global(cx)
1122            .ssh_connections()
1123            .collect::<Vec<_>>();
1124        self.selectable_items.add_item(Box::new(|this, cx| {
1125            this.mode = Mode::CreateRemoteServer(CreateRemoteServer::new(cx));
1126            cx.notify();
1127        }));
1128
1129        let is_selected = self.selectable_items.is_selected();
1130
1131        let connect_button = ListItem::new("register-remove-server-button")
1132            .selected(is_selected)
1133            .inset(true)
1134            .spacing(ui::ListItemSpacing::Sparse)
1135            .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1136            .child(Label::new("Connect New Server"))
1137            .on_click(cx.listener(|this, _, cx| {
1138                let state = CreateRemoteServer::new(cx);
1139                this.mode = Mode::CreateRemoteServer(state);
1140
1141                cx.notify();
1142            }));
1143
1144        let ui::ScrollableHandle::NonUniform(scroll_handle) = scroll_state.scroll_handle() else {
1145            unreachable!()
1146        };
1147
1148        let mut modal_section = v_flex()
1149            .id("ssh-server-list")
1150            .overflow_y_scroll()
1151            .track_scroll(&scroll_handle)
1152            .size_full()
1153            .child(connect_button)
1154            .child(
1155                h_flex().child(
1156                    List::new()
1157                        .empty_message(
1158                            v_flex()
1159                                .child(ListSeparator)
1160                                .child(
1161                                    div().px_3().child(
1162                                        Label::new("No remote servers registered yet.")
1163                                            .color(Color::Muted),
1164                                    ),
1165                                )
1166                                .into_any_element(),
1167                        )
1168                        .children(ssh_connections.iter().cloned().enumerate().map(
1169                            |(ix, connection)| {
1170                                self.render_ssh_connection(ix, connection, cx)
1171                                    .into_any_element()
1172                            },
1173                        )),
1174                ),
1175            )
1176            .into_any_element();
1177
1178        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1179            .header(
1180                ModalHeader::new()
1181                    .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)),
1182            )
1183            .section(
1184                Section::new().padded(false).child(
1185                    h_flex()
1186                        .min_h(rems(20.))
1187                        .size_full()
1188                        .child(
1189                            v_flex().size_full().child(ListSeparator).child(
1190                                canvas(
1191                                    |bounds, cx| {
1192                                        modal_section.prepaint_as_root(
1193                                            bounds.origin,
1194                                            bounds.size.into(),
1195                                            cx,
1196                                        );
1197                                        modal_section
1198                                    },
1199                                    |_, mut modal_section, cx| {
1200                                        modal_section.paint(cx);
1201                                    },
1202                                )
1203                                .size_full(),
1204                            ),
1205                        )
1206                        .child(
1207                            div()
1208                                .occlude()
1209                                .h_full()
1210                                .absolute()
1211                                .right_1()
1212                                .top_1()
1213                                .bottom_1()
1214                                .w(px(12.))
1215                                .children(Scrollbar::vertical(scroll_state)),
1216                        ),
1217                ),
1218            )
1219    }
1220}
1221
1222fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1223    element.read(cx).text(cx).trim().to_string()
1224}
1225
1226impl ModalView for RemoteServerProjects {}
1227
1228impl FocusableView for RemoteServerProjects {
1229    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1230        self.focus_handle.clone()
1231    }
1232}
1233
1234impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1235
1236impl Render for RemoteServerProjects {
1237    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1238        self.selectable_items.reset();
1239        div()
1240            .track_focus(&self.focus_handle)
1241            .elevation_3(cx)
1242            .key_context("RemoteServerModal")
1243            .on_action(cx.listener(Self::cancel))
1244            .on_action(cx.listener(Self::confirm))
1245            .on_action(cx.listener(Self::prev_item))
1246            .on_action(cx.listener(Self::next_item))
1247            .capture_any_mouse_down(cx.listener(|this, _, cx| {
1248                this.focus_handle(cx).focus(cx);
1249            }))
1250            .on_mouse_down_out(cx.listener(|this, _, cx| {
1251                if matches!(this.mode, Mode::Default(_)) {
1252                    cx.emit(DismissEvent)
1253                }
1254            }))
1255            .w(rems(34.))
1256            .child(match &self.mode {
1257                Mode::Default(state) => self.render_default(state.clone(), cx).into_any_element(),
1258                Mode::ViewServerOptions(index, connection) => self
1259                    .render_view_options(*index, connection.clone(), cx)
1260                    .into_any_element(),
1261                Mode::ProjectPicker(element) => element.clone().into_any_element(),
1262                Mode::CreateRemoteServer(state) => self
1263                    .render_create_remote_server(state, cx)
1264                    .into_any_element(),
1265                Mode::EditNickname(state) => {
1266                    self.render_edit_nickname(state, cx).into_any_element()
1267                }
1268            })
1269    }
1270}
1271
1272pub fn reconnect_to_dev_server_project(
1273    workspace: View<Workspace>,
1274    dev_server: DevServer,
1275    dev_server_project_id: DevServerProjectId,
1276    replace_current_window: bool,
1277    cx: &mut WindowContext,
1278) -> Task<Result<()>> {
1279    let store = dev_server_projects::Store::global(cx);
1280    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1281    cx.spawn(|mut cx| async move {
1282        reconnect.await?;
1283
1284        cx.background_executor()
1285            .timer(Duration::from_millis(1000))
1286            .await;
1287
1288        if let Some(project_id) = store.update(&mut cx, |store, _| {
1289            store
1290                .dev_server_project(dev_server_project_id)
1291                .and_then(|p| p.project_id)
1292        })? {
1293            workspace
1294                .update(&mut cx, move |_, cx| {
1295                    open_dev_server_project(
1296                        replace_current_window,
1297                        dev_server_project_id,
1298                        project_id,
1299                        cx,
1300                    )
1301                })?
1302                .await?;
1303        }
1304
1305        Ok(())
1306    })
1307}
1308
1309pub fn reconnect_to_dev_server(
1310    workspace: View<Workspace>,
1311    dev_server: DevServer,
1312    cx: &mut WindowContext,
1313) -> Task<Result<()>> {
1314    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1315        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1316    };
1317    let dev_server_store = dev_server_projects::Store::global(cx);
1318    let get_access_token = dev_server_store.update(cx, |store, cx| {
1319        store.regenerate_dev_server_token(dev_server.id, cx)
1320    });
1321
1322    cx.spawn(|mut cx| async move {
1323        let access_token = get_access_token.await?.access_token;
1324
1325        spawn_ssh_task(
1326            workspace,
1327            dev_server_store,
1328            dev_server.id,
1329            ssh_connection_string.to_string(),
1330            access_token,
1331            &mut cx,
1332        )
1333        .await
1334    })
1335}
1336
1337pub async fn spawn_ssh_task(
1338    workspace: View<Workspace>,
1339    dev_server_store: Model<dev_server_projects::Store>,
1340    dev_server_id: DevServerId,
1341    ssh_connection_string: String,
1342    access_token: String,
1343    cx: &mut AsyncWindowContext,
1344) -> Result<()> {
1345    let terminal_panel = workspace
1346        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1347        .ok()
1348        .flatten()
1349        .with_context(|| anyhow!("No terminal panel"))?;
1350
1351    let command = "sh".to_string();
1352    let args = vec![
1353        "-x".to_string(),
1354        "-c".to_string(),
1355        format!(
1356            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 {}"#,
1357            access_token
1358        ),
1359    ];
1360
1361    let ssh_connection_string = ssh_connection_string.to_string();
1362    let (command, args) = wrap_for_ssh(
1363        &SshCommand::DevServer(ssh_connection_string.clone()),
1364        Some((&command, &args)),
1365        None,
1366        HashMap::default(),
1367        None,
1368    );
1369
1370    let terminal = terminal_panel
1371        .update(cx, |terminal_panel, cx| {
1372            terminal_panel.spawn_in_new_terminal(
1373                SpawnInTerminal {
1374                    id: task::TaskId("ssh-remote".into()),
1375                    full_label: "Install zed over ssh".into(),
1376                    label: "Install zed over ssh".into(),
1377                    command,
1378                    args,
1379                    command_label: ssh_connection_string.clone(),
1380                    cwd: None,
1381                    use_new_terminal: true,
1382                    allow_concurrent_runs: false,
1383                    reveal: RevealStrategy::Always,
1384                    hide: HideStrategy::Never,
1385                    env: Default::default(),
1386                    shell: Default::default(),
1387                },
1388                cx,
1389            )
1390        })?
1391        .await?;
1392
1393    terminal
1394        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1395        .await;
1396
1397    // 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.
1398    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1399        == DevServerStatus::Offline
1400    {
1401        cx.background_executor()
1402            .timer(Duration::from_millis(200))
1403            .await
1404    }
1405
1406    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1407        == DevServerStatus::Offline
1408    {
1409        return Err(anyhow!("couldn't reconnect"))?;
1410    }
1411
1412    Ok(())
1413}