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