dev_servers.rs

   1use std::collections::HashMap;
   2use std::path::PathBuf;
   3use std::time::Duration;
   4
   5use anyhow::anyhow;
   6use anyhow::Context;
   7use anyhow::Result;
   8use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
   9use editor::Editor;
  10use gpui::pulsating_between;
  11use gpui::AsyncWindowContext;
  12use gpui::ClipboardItem;
  13use gpui::PathPromptOptions;
  14use gpui::Subscription;
  15use gpui::Task;
  16use gpui::WeakView;
  17use gpui::{
  18    Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
  19    FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext,
  20};
  21use project::terminals::wrap_for_ssh;
  22use project::terminals::SshCommand;
  23use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
  24use settings::update_settings_file;
  25use settings::Settings;
  26use task::HideStrategy;
  27use task::RevealStrategy;
  28use task::SpawnInTerminal;
  29use terminal_view::terminal_panel::TerminalPanel;
  30use ui::ElevationIndex;
  31use ui::Section;
  32use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip};
  33use ui_input::{FieldLabelLayout, TextField};
  34use util::ResultExt;
  35use workspace::OpenOptions;
  36use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
  37
  38use crate::open_dev_server_project;
  39use crate::ssh_connections::connect_over_ssh;
  40use crate::ssh_connections::open_ssh_project;
  41use crate::ssh_connections::RemoteSettingsContent;
  42use crate::ssh_connections::SshConnection;
  43use crate::ssh_connections::SshConnectionModal;
  44use crate::ssh_connections::SshProject;
  45use crate::ssh_connections::SshPrompt;
  46use crate::ssh_connections::SshSettings;
  47use crate::OpenRemote;
  48
  49pub struct DevServerProjects {
  50    mode: Mode,
  51    focus_handle: FocusHandle,
  52    scroll_handle: ScrollHandle,
  53    dev_server_store: Model<dev_server_projects::Store>,
  54    workspace: WeakView<Workspace>,
  55    project_path_input: View<Editor>,
  56    dev_server_name_input: View<TextField>,
  57    _dev_server_subscription: Subscription,
  58}
  59
  60#[derive(Default)]
  61struct CreateDevServer {
  62    creating: Option<Task<Option<()>>>,
  63    ssh_prompt: Option<View<SshPrompt>>,
  64}
  65
  66struct CreateDevServerProject {
  67    dev_server_id: DevServerId,
  68    _opening: Option<Subscription>,
  69}
  70
  71enum Mode {
  72    Default(Option<CreateDevServerProject>),
  73    CreateDevServer(CreateDevServer),
  74}
  75
  76impl DevServerProjects {
  77    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  78        workspace.register_action(|workspace, _: &OpenRemote, cx| {
  79            let handle = cx.view().downgrade();
  80            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  81        });
  82    }
  83
  84    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
  85        workspace.update(cx, |workspace, cx| {
  86            let handle = cx.view().downgrade();
  87            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  88        })
  89    }
  90
  91    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
  92        let project_path_input = cx.new_view(|cx| {
  93            let mut editor = Editor::single_line(cx);
  94            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
  95            editor
  96        });
  97        let dev_server_name_input = cx.new_view(|cx| {
  98            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
  99        });
 100
 101        let focus_handle = cx.focus_handle();
 102        let dev_server_store = dev_server_projects::Store::global(cx);
 103
 104        let subscription = cx.observe(&dev_server_store, |_, _, cx| {
 105            cx.notify();
 106        });
 107
 108        let mut base_style = cx.text_style();
 109        base_style.refine(&gpui::TextStyleRefinement {
 110            color: Some(cx.theme().colors().editor_foreground),
 111            ..Default::default()
 112        });
 113
 114        Self {
 115            mode: Mode::Default(None),
 116            focus_handle,
 117            scroll_handle: ScrollHandle::new(),
 118            dev_server_store,
 119            project_path_input,
 120            dev_server_name_input,
 121            workspace,
 122            _dev_server_subscription: subscription,
 123        }
 124    }
 125
 126    pub fn create_dev_server_project(
 127        &mut self,
 128        dev_server_id: DevServerId,
 129        cx: &mut ViewContext<Self>,
 130    ) {
 131        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
 132
 133        if path.is_empty() {
 134            return;
 135        }
 136
 137        if !path.starts_with('/') && !path.starts_with('~') {
 138            path = format!("~/{}", path);
 139        }
 140
 141        if self
 142            .dev_server_store
 143            .read(cx)
 144            .projects_for_server(dev_server_id)
 145            .iter()
 146            .any(|p| p.paths.iter().any(|p| p == &path))
 147        {
 148            cx.spawn(|_, mut cx| async move {
 149                cx.prompt(
 150                    gpui::PromptLevel::Critical,
 151                    "Failed to create project",
 152                    Some(&format!("{} is already open on this dev server.", path)),
 153                    &["Ok"],
 154                )
 155                .await
 156            })
 157            .detach_and_log_err(cx);
 158            return;
 159        }
 160
 161        let create = {
 162            let path = path.clone();
 163            self.dev_server_store.update(cx, |store, cx| {
 164                store.create_dev_server_project(dev_server_id, path, cx)
 165            })
 166        };
 167
 168        cx.spawn(|this, mut cx| async move {
 169            let result = create.await;
 170            this.update(&mut cx, |this, cx| {
 171                if let Ok(result) = &result {
 172                    if let Some(dev_server_project_id) =
 173                        result.dev_server_project.as_ref().map(|p| p.id)
 174                    {
 175                        let subscription =
 176                            cx.observe(&this.dev_server_store, move |this, store, cx| {
 177                                if let Some(project_id) = store
 178                                    .read(cx)
 179                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
 180                                    .and_then(|p| p.project_id)
 181                                {
 182                                    this.project_path_input.update(cx, |editor, cx| {
 183                                        editor.set_text("", cx);
 184                                    });
 185                                    this.mode = Mode::Default(None);
 186                                    if let Some(app_state) = AppState::global(cx).upgrade() {
 187                                        workspace::join_dev_server_project(
 188                                            DevServerProjectId(dev_server_project_id),
 189                                            project_id,
 190                                            app_state,
 191                                            None,
 192                                            cx,
 193                                        )
 194                                        .detach_and_prompt_err(
 195                                            "Could not join project",
 196                                            cx,
 197                                            |_, _| None,
 198                                        )
 199                                    }
 200                                }
 201                            });
 202
 203                        this.mode = Mode::Default(Some(CreateDevServerProject {
 204                            dev_server_id,
 205                            _opening: Some(subscription),
 206                        }));
 207                    }
 208                } else {
 209                    this.mode = Mode::Default(Some(CreateDevServerProject {
 210                        dev_server_id,
 211                        _opening: None,
 212                    }));
 213                }
 214            })
 215            .log_err();
 216            result
 217        })
 218        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
 219            match e.error_code() {
 220                ErrorCode::DevServerOffline => Some(
 221                    "The dev server is offline. Please log in and check it is connected."
 222                        .to_string(),
 223                ),
 224                ErrorCode::DevServerProjectPathDoesNotExist => {
 225                    Some(format!("The path `{}` does not exist on the server.", path))
 226                }
 227                _ => None,
 228            }
 229        });
 230
 231        self.mode = Mode::Default(Some(CreateDevServerProject {
 232            dev_server_id,
 233
 234            _opening: None,
 235        }));
 236    }
 237
 238    fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
 239        let host = get_text(&self.dev_server_name_input, cx);
 240        if host.is_empty() {
 241            return;
 242        }
 243
 244        let mut host = host.trim_start_matches("ssh ");
 245        let mut username: Option<String> = None;
 246        let mut port: Option<u16> = None;
 247
 248        if let Some((u, rest)) = host.split_once('@') {
 249            host = rest;
 250            username = Some(u.to_string());
 251        }
 252        if let Some((rest, p)) = host.split_once(':') {
 253            host = rest;
 254            port = p.parse().ok()
 255        }
 256
 257        if let Some((rest, p)) = host.split_once(" -p") {
 258            host = rest;
 259            port = p.trim().parse().ok()
 260        }
 261
 262        let connection_options = remote::SshConnectionOptions {
 263            host: host.to_string(),
 264            username: username.clone(),
 265            port,
 266            password: None,
 267        };
 268        let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 269
 270        let connection = connect_over_ssh(
 271            connection_options.dev_server_identifier(),
 272            connection_options.clone(),
 273            ssh_prompt.clone(),
 274            cx,
 275        )
 276        .prompt_err("Failed to connect", cx, |_, _| None);
 277
 278        let creating = cx.spawn(move |this, mut cx| async move {
 279            match connection.await {
 280                Some(_) => this
 281                    .update(&mut cx, |this, cx| {
 282                        this.add_ssh_server(connection_options, cx);
 283                        this.mode = Mode::Default(None);
 284                        cx.notify()
 285                    })
 286                    .log_err(),
 287                None => this
 288                    .update(&mut cx, |this, cx| {
 289                        this.mode = Mode::CreateDevServer(CreateDevServer::default());
 290                        cx.notify()
 291                    })
 292                    .log_err(),
 293            };
 294            None
 295        });
 296        self.mode = Mode::CreateDevServer(CreateDevServer {
 297            ssh_prompt: Some(ssh_prompt.clone()),
 298            creating: Some(creating),
 299        });
 300    }
 301
 302    fn create_ssh_project(
 303        &mut self,
 304        ix: usize,
 305        ssh_connection: SshConnection,
 306        cx: &mut ViewContext<Self>,
 307    ) {
 308        let Some(workspace) = self.workspace.upgrade() else {
 309            return;
 310        };
 311
 312        let connection_options = ssh_connection.into();
 313        workspace.update(cx, |_, cx| {
 314            cx.defer(move |workspace, cx| {
 315                workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
 316                let prompt = workspace
 317                    .active_modal::<SshConnectionModal>(cx)
 318                    .unwrap()
 319                    .read(cx)
 320                    .prompt
 321                    .clone();
 322
 323                let connect = connect_over_ssh(
 324                    connection_options.dev_server_identifier(),
 325                    connection_options,
 326                    prompt,
 327                    cx,
 328                )
 329                .prompt_err("Failed to connect", cx, |_, _| None);
 330                cx.spawn(|workspace, mut cx| async move {
 331                    let Some(session) = connect.await else {
 332                        workspace
 333                            .update(&mut cx, |workspace, cx| {
 334                                let weak = cx.view().downgrade();
 335                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 336                            })
 337                            .log_err();
 338                        return;
 339                    };
 340                    let Ok((app_state, project, paths)) =
 341                        workspace.update(&mut cx, |workspace, cx| {
 342                            let app_state = workspace.app_state().clone();
 343                            let project = project::Project::ssh(
 344                                session,
 345                                app_state.client.clone(),
 346                                app_state.node_runtime.clone(),
 347                                app_state.user_store.clone(),
 348                                app_state.languages.clone(),
 349                                app_state.fs.clone(),
 350                                cx,
 351                            );
 352                            let paths = workspace.prompt_for_open_path(
 353                                PathPromptOptions {
 354                                    files: true,
 355                                    directories: true,
 356                                    multiple: true,
 357                                },
 358                                project::DirectoryLister::Project(project.clone()),
 359                                cx,
 360                            );
 361                            (app_state, project, paths)
 362                        })
 363                    else {
 364                        return;
 365                    };
 366
 367                    let Ok(Some(paths)) = paths.await else {
 368                        workspace
 369                            .update(&mut cx, |workspace, cx| {
 370                                let weak = cx.view().downgrade();
 371                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 372                            })
 373                            .log_err();
 374                        return;
 375                    };
 376
 377                    let Some(options) = cx
 378                        .update(|cx| (app_state.build_window_options)(None, cx))
 379                        .log_err()
 380                    else {
 381                        return;
 382                    };
 383
 384                    cx.open_window(options, |cx| {
 385                        cx.activate_window();
 386
 387                        let fs = app_state.fs.clone();
 388                        update_settings_file::<SshSettings>(fs, cx, {
 389                            let paths = paths
 390                                .iter()
 391                                .map(|path| path.to_string_lossy().to_string())
 392                                .collect();
 393                            move |setting, _| {
 394                                if let Some(server) = setting
 395                                    .ssh_connections
 396                                    .as_mut()
 397                                    .and_then(|connections| connections.get_mut(ix))
 398                                {
 399                                    server.projects.push(SshProject { paths })
 400                                }
 401                            }
 402                        });
 403
 404                        let tasks = paths
 405                            .into_iter()
 406                            .map(|path| {
 407                                project.update(cx, |project, cx| {
 408                                    project.find_or_create_worktree(&path, true, cx)
 409                                })
 410                            })
 411                            .collect::<Vec<_>>();
 412                        cx.spawn(|_| async move {
 413                            for task in tasks {
 414                                task.await?;
 415                            }
 416                            Ok(())
 417                        })
 418                        .detach_and_prompt_err(
 419                            "Failed to open path",
 420                            cx,
 421                            |_, _| None,
 422                        );
 423
 424                        cx.new_view(|cx| {
 425                            Workspace::new(None, project.clone(), app_state.clone(), cx)
 426                        })
 427                    })
 428                    .log_err();
 429                })
 430                .detach()
 431            })
 432        })
 433    }
 434
 435    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 436        match &self.mode {
 437            Mode::Default(None) => {}
 438            Mode::Default(Some(create_project)) => {
 439                self.create_dev_server_project(create_project.dev_server_id, cx);
 440            }
 441            Mode::CreateDevServer(state) => {
 442                if let Some(prompt) = state.ssh_prompt.as_ref() {
 443                    prompt.update(cx, |prompt, cx| {
 444                        prompt.confirm(cx);
 445                    });
 446                    return;
 447                }
 448
 449                self.create_ssh_server(cx);
 450            }
 451        }
 452    }
 453
 454    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 455        match &self.mode {
 456            Mode::Default(None) => cx.emit(DismissEvent),
 457            Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
 458                self.mode = Mode::CreateDevServer(CreateDevServer {
 459                    ..Default::default()
 460                });
 461                cx.notify();
 462            }
 463            _ => {
 464                self.mode = Mode::Default(None);
 465                self.focus_handle(cx).focus(cx);
 466                cx.notify();
 467            }
 468        }
 469    }
 470
 471    fn render_ssh_connection(
 472        &mut self,
 473        ix: usize,
 474        ssh_connection: SshConnection,
 475        cx: &mut ViewContext<Self>,
 476    ) -> impl IntoElement {
 477        v_flex()
 478            .w_full()
 479            .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
 480            .child(
 481                h_flex()
 482                    .w_full()
 483                    .group("ssh-server")
 484                    .justify_between()
 485                    .child(
 486                        h_flex()
 487                            .gap_2()
 488                            .w_full()
 489                            .child(
 490                                div()
 491                                    .id(("status", ix))
 492                                    .relative()
 493                                    .child(Icon::new(IconName::Server).size(IconSize::Small)),
 494                            )
 495                            .child(
 496                                h_flex()
 497                                    .max_w(rems(26.))
 498                                    .overflow_hidden()
 499                                    .whitespace_nowrap()
 500                                    .child(Label::new(ssh_connection.host.clone())),
 501                            ),
 502                    )
 503                    .child(
 504                        h_flex()
 505                            .visible_on_hover("ssh-server")
 506                            .gap_1()
 507                            .child({
 508                                IconButton::new("copy-dev-server-address", IconName::Copy)
 509                                    .icon_size(IconSize::Small)
 510                                    .on_click(cx.listener(move |this, _, cx| {
 511                                        this.update_settings_file(cx, move |servers, cx| {
 512                                            if let Some(content) = servers
 513                                                .ssh_connections
 514                                                .as_ref()
 515                                                .and_then(|connections| {
 516                                                    connections
 517                                                        .get(ix)
 518                                                        .map(|connection| connection.host.clone())
 519                                                })
 520                                            {
 521                                                cx.write_to_clipboard(ClipboardItem::new_string(
 522                                                    content,
 523                                                ));
 524                                            }
 525                                        });
 526                                    }))
 527                                    .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
 528                            })
 529                            .child({
 530                                IconButton::new("remove-dev-server", IconName::TrashAlt)
 531                                    .icon_size(IconSize::Small)
 532                                    .on_click(cx.listener(move |this, _, cx| {
 533                                        this.delete_ssh_server(ix, cx)
 534                                    }))
 535                                    .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
 536                            }),
 537                    ),
 538            )
 539            .child(
 540                v_flex()
 541                    .w_full()
 542                    .border_l_1()
 543                    .border_color(cx.theme().colors().border_variant)
 544                    .my_1()
 545                    .mx_1p5()
 546                    .py_0p5()
 547                    .px_3()
 548                    .child(
 549                        List::new()
 550                            .empty_message("No projects.")
 551                            .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 552                                self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
 553                            }))
 554                            .child(
 555                                h_flex().child(
 556                                    Button::new("new-remote_project", "Open Folder…")
 557                                        .icon(IconName::Plus)
 558                                        .size(ButtonSize::Default)
 559                                        .style(ButtonStyle::Filled)
 560                                        .layer(ElevationIndex::ModalSurface)
 561                                        .icon_position(IconPosition::Start)
 562                                        .on_click(cx.listener(move |this, _, cx| {
 563                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
 564                                        })),
 565                                ),
 566                            ),
 567                    ),
 568            )
 569    }
 570
 571    fn render_ssh_project(
 572        &self,
 573        server_ix: usize,
 574        server: &SshConnection,
 575        ix: usize,
 576        project: &SshProject,
 577        cx: &ViewContext<Self>,
 578    ) -> impl IntoElement {
 579        let project = project.clone();
 580        let server = server.clone();
 581        ListItem::new(("remote-project", ix))
 582            .spacing(ui::ListItemSpacing::Sparse)
 583            .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
 584            .child(Label::new(project.paths.join(", ")))
 585            .on_click(cx.listener(move |this, _, cx| {
 586                let Some(app_state) = this
 587                    .workspace
 588                    .update(cx, |workspace, _| workspace.app_state().clone())
 589                    .log_err()
 590                else {
 591                    return;
 592                };
 593                let project = project.clone();
 594                let server = server.clone();
 595                cx.spawn(|_, mut cx| async move {
 596                    let result = open_ssh_project(
 597                        server.into(),
 598                        project.paths.into_iter().map(PathBuf::from).collect(),
 599                        app_state,
 600                        OpenOptions::default(),
 601                        &mut cx,
 602                    )
 603                    .await;
 604                    if let Err(e) = result {
 605                        log::error!("Failed to connect: {:?}", e);
 606                        cx.prompt(
 607                            gpui::PromptLevel::Critical,
 608                            "Failed to connect",
 609                            Some(&e.to_string()),
 610                            &["Ok"],
 611                        )
 612                        .await
 613                        .ok();
 614                    }
 615                })
 616                .detach();
 617            }))
 618            .end_hover_slot::<AnyElement>(Some(
 619                IconButton::new("remove-remote-project", IconName::TrashAlt)
 620                    .on_click(
 621                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
 622                    )
 623                    .tooltip(|cx| Tooltip::text("Delete remote project", cx))
 624                    .into_any_element(),
 625            ))
 626    }
 627
 628    fn update_settings_file(
 629        &mut self,
 630        cx: &mut ViewContext<Self>,
 631        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 632    ) {
 633        let Some(fs) = self
 634            .workspace
 635            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 636            .log_err()
 637        else {
 638            return;
 639        };
 640        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 641    }
 642
 643    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 644        self.update_settings_file(cx, move |setting, _| {
 645            if let Some(connections) = setting.ssh_connections.as_mut() {
 646                connections.remove(server);
 647            }
 648        });
 649    }
 650
 651    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 652        self.update_settings_file(cx, move |setting, _| {
 653            if let Some(server) = setting
 654                .ssh_connections
 655                .as_mut()
 656                .and_then(|connections| connections.get_mut(server))
 657            {
 658                server.projects.remove(project);
 659            }
 660        });
 661    }
 662
 663    fn add_ssh_server(
 664        &mut self,
 665        connection_options: remote::SshConnectionOptions,
 666        cx: &mut ViewContext<Self>,
 667    ) {
 668        self.update_settings_file(cx, move |setting, _| {
 669            setting
 670                .ssh_connections
 671                .get_or_insert(Default::default())
 672                .push(SshConnection {
 673                    host: connection_options.host,
 674                    username: connection_options.username,
 675                    port: connection_options.port,
 676                    projects: vec![],
 677                })
 678        });
 679    }
 680
 681    fn render_create_dev_server(
 682        &self,
 683        state: &CreateDevServer,
 684        cx: &mut ViewContext<Self>,
 685    ) -> impl IntoElement {
 686        let creating = state.creating.is_some();
 687        let ssh_prompt = state.ssh_prompt.clone();
 688
 689        self.dev_server_name_input.update(cx, |input, cx| {
 690            input.editor().update(cx, |editor, cx| {
 691                if editor.text(cx).is_empty() {
 692                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
 693                }
 694            })
 695        });
 696        let theme = cx.theme();
 697        v_flex()
 698            .id("create-dev-server")
 699            .overflow_hidden()
 700            .size_full()
 701            .flex_1()
 702            .child(
 703                h_flex()
 704                    .p_2()
 705                    .gap_2()
 706                    .items_center()
 707                    .border_b_1()
 708                    .border_color(theme.colors().border_variant)
 709                    .child(
 710                        IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
 711                            .shape(IconButtonShape::Square)
 712                            .on_click(|_, cx| {
 713                                cx.dispatch_action(menu::Cancel.boxed_clone());
 714                            }),
 715                    )
 716                    .child(Label::new("Connect New Dev Server")),
 717            )
 718            .child(
 719                v_flex()
 720                    .p_3()
 721                    .border_b_1()
 722                    .border_color(theme.colors().border_variant)
 723                    .child(Label::new("SSH Arguments"))
 724                    .child(
 725                        Label::new("Enter the command you use to SSH into this server.")
 726                            .size(LabelSize::Small)
 727                            .color(Color::Muted),
 728                    )
 729                    .child(
 730                        h_flex()
 731                            .mt_2()
 732                            .w_full()
 733                            .gap_2()
 734                            .child(self.dev_server_name_input.clone())
 735                            .child(
 736                                Button::new("create-dev-server", "Connect Server")
 737                                    .style(ButtonStyle::Filled)
 738                                    .layer(ElevationIndex::ModalSurface)
 739                                    .disabled(creating)
 740                                    .on_click(cx.listener({
 741                                        move |this, _, cx| {
 742                                            this.create_ssh_server(cx);
 743                                        }
 744                                    })),
 745                            ),
 746                    ),
 747            )
 748            .child(
 749                h_flex()
 750                    .bg(theme.colors().editor_background)
 751                    .w_full()
 752                    .map(|this| {
 753                        if let Some(ssh_prompt) = ssh_prompt {
 754                            this.child(h_flex().w_full().child(ssh_prompt))
 755                        } else {
 756                            let color = Color::Muted.color(cx);
 757                            this.child(
 758                                h_flex()
 759                                    .p_2()
 760                                    .w_full()
 761                                    .content_center()
 762                                    .gap_2()
 763                                    .child(h_flex().w_full())
 764                                    .child(
 765                                        div().p_1().rounded_lg().bg(color).with_animation(
 766                                            "pulse-ssh-waiting-for-connection",
 767                                            Animation::new(Duration::from_secs(2))
 768                                                .repeat()
 769                                                .with_easing(pulsating_between(0.2, 0.5)),
 770                                            move |this, progress| this.bg(color.opacity(progress)),
 771                                        ),
 772                                    )
 773                                    .child(
 774                                        Label::new("Waiting for connection…")
 775                                            .size(LabelSize::Small),
 776                                    )
 777                                    .child(h_flex().w_full()),
 778                            )
 779                        }
 780                    }),
 781            )
 782    }
 783
 784    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 785        let dev_servers = self.dev_server_store.read(cx).dev_servers();
 786        let ssh_connections = SshSettings::get_global(cx)
 787            .ssh_connections()
 788            .collect::<Vec<_>>();
 789
 790        let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
 791        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
 792            .header(
 793                ModalHeader::new().child(
 794                    h_flex()
 795                        .justify_between()
 796                        .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
 797                        .child(
 798                            Button::new("register-dev-server-button", "Connect New Server")
 799                                .style(ButtonStyle::Filled)
 800                                .layer(ElevationIndex::ModalSurface)
 801                                .icon(IconName::Plus)
 802                                .icon_position(IconPosition::Start)
 803                                .icon_color(Color::Muted)
 804                                .on_click(cx.listener(|this, _, cx| {
 805                                    this.mode = Mode::CreateDevServer(CreateDevServer {
 806                                        ..Default::default()
 807                                    });
 808                                    this.dev_server_name_input.update(cx, |text_field, cx| {
 809                                        text_field.editor().update(cx, |editor, cx| {
 810                                            editor.set_text("", cx);
 811                                        });
 812                                    });
 813                                    cx.notify();
 814                                })),
 815                        ),
 816                ),
 817            )
 818            .section(
 819                Section::new().padded(false).child(
 820                    div()
 821                        .border_y_1()
 822                        .border_color(cx.theme().colors().border_variant)
 823                        .w_full()
 824                        .child(
 825                            div().p_2().child(
 826                                List::new()
 827                                    .empty_message("No dev servers registered yet.")
 828                                    .children(ssh_connections.iter().cloned().enumerate().map(
 829                                        |(ix, connection)| {
 830                                            self.render_ssh_connection(ix, connection, cx)
 831                                                .into_any_element()
 832                                        },
 833                                    )),
 834                            ),
 835                        ),
 836                ),
 837            )
 838            .footer(
 839                ModalFooter::new()
 840                    .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
 841            )
 842    }
 843}
 844
 845fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
 846    element
 847        .read(cx)
 848        .editor()
 849        .read(cx)
 850        .text(cx)
 851        .trim()
 852        .to_string()
 853}
 854
 855impl ModalView for DevServerProjects {}
 856
 857impl FocusableView for DevServerProjects {
 858    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 859        self.focus_handle.clone()
 860    }
 861}
 862
 863impl EventEmitter<DismissEvent> for DevServerProjects {}
 864
 865impl Render for DevServerProjects {
 866    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 867        div()
 868            .track_focus(&self.focus_handle)
 869            .elevation_3(cx)
 870            .key_context("DevServerModal")
 871            .on_action(cx.listener(Self::cancel))
 872            .on_action(cx.listener(Self::confirm))
 873            .capture_any_mouse_down(cx.listener(|this, _, cx| {
 874                this.focus_handle(cx).focus(cx);
 875            }))
 876            .on_mouse_down_out(cx.listener(|this, _, cx| {
 877                if matches!(this.mode, Mode::Default(None)) {
 878                    cx.emit(DismissEvent)
 879                }
 880            }))
 881            .w(rems(34.))
 882            .max_h(rems(40.))
 883            .child(match &self.mode {
 884                Mode::Default(_) => self.render_default(cx).into_any_element(),
 885                Mode::CreateDevServer(state) => {
 886                    self.render_create_dev_server(state, cx).into_any_element()
 887                }
 888            })
 889    }
 890}
 891
 892pub fn reconnect_to_dev_server_project(
 893    workspace: View<Workspace>,
 894    dev_server: DevServer,
 895    dev_server_project_id: DevServerProjectId,
 896    replace_current_window: bool,
 897    cx: &mut WindowContext,
 898) -> Task<Result<()>> {
 899    let store = dev_server_projects::Store::global(cx);
 900    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
 901    cx.spawn(|mut cx| async move {
 902        reconnect.await?;
 903
 904        cx.background_executor()
 905            .timer(Duration::from_millis(1000))
 906            .await;
 907
 908        if let Some(project_id) = store.update(&mut cx, |store, _| {
 909            store
 910                .dev_server_project(dev_server_project_id)
 911                .and_then(|p| p.project_id)
 912        })? {
 913            workspace
 914                .update(&mut cx, move |_, cx| {
 915                    open_dev_server_project(
 916                        replace_current_window,
 917                        dev_server_project_id,
 918                        project_id,
 919                        cx,
 920                    )
 921                })?
 922                .await?;
 923        }
 924
 925        Ok(())
 926    })
 927}
 928
 929pub fn reconnect_to_dev_server(
 930    workspace: View<Workspace>,
 931    dev_server: DevServer,
 932    cx: &mut WindowContext,
 933) -> Task<Result<()>> {
 934    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
 935        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
 936    };
 937    let dev_server_store = dev_server_projects::Store::global(cx);
 938    let get_access_token = dev_server_store.update(cx, |store, cx| {
 939        store.regenerate_dev_server_token(dev_server.id, cx)
 940    });
 941
 942    cx.spawn(|mut cx| async move {
 943        let access_token = get_access_token.await?.access_token;
 944
 945        spawn_ssh_task(
 946            workspace,
 947            dev_server_store,
 948            dev_server.id,
 949            ssh_connection_string.to_string(),
 950            access_token,
 951            &mut cx,
 952        )
 953        .await
 954    })
 955}
 956
 957pub async fn spawn_ssh_task(
 958    workspace: View<Workspace>,
 959    dev_server_store: Model<dev_server_projects::Store>,
 960    dev_server_id: DevServerId,
 961    ssh_connection_string: String,
 962    access_token: String,
 963    cx: &mut AsyncWindowContext,
 964) -> Result<()> {
 965    let terminal_panel = workspace
 966        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
 967        .ok()
 968        .flatten()
 969        .with_context(|| anyhow!("No terminal panel"))?;
 970
 971    let command = "sh".to_string();
 972    let args = vec![
 973        "-x".to_string(),
 974        "-c".to_string(),
 975        format!(
 976            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 {}"#,
 977            access_token
 978        ),
 979    ];
 980
 981    let ssh_connection_string = ssh_connection_string.to_string();
 982    let (command, args) = wrap_for_ssh(
 983        &SshCommand::DevServer(ssh_connection_string.clone()),
 984        Some((&command, &args)),
 985        None,
 986        HashMap::default(),
 987        None,
 988    );
 989
 990    let terminal = terminal_panel
 991        .update(cx, |terminal_panel, cx| {
 992            terminal_panel.spawn_in_new_terminal(
 993                SpawnInTerminal {
 994                    id: task::TaskId("ssh-remote".into()),
 995                    full_label: "Install zed over ssh".into(),
 996                    label: "Install zed over ssh".into(),
 997                    command,
 998                    args,
 999                    command_label: ssh_connection_string.clone(),
1000                    cwd: None,
1001                    use_new_terminal: true,
1002                    allow_concurrent_runs: false,
1003                    reveal: RevealStrategy::Always,
1004                    hide: HideStrategy::Never,
1005                    env: Default::default(),
1006                    shell: Default::default(),
1007                },
1008                cx,
1009            )
1010        })?
1011        .await?;
1012
1013    terminal
1014        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1015        .await;
1016
1017    // 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.
1018    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1019        == DevServerStatus::Offline
1020    {
1021        cx.background_executor()
1022            .timer(Duration::from_millis(200))
1023            .await
1024    }
1025
1026    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1027        == DevServerStatus::Offline
1028    {
1029        return Err(anyhow!("couldn't reconnect"))?;
1030    }
1031
1032    Ok(())
1033}