dev_servers.rs

   1use std::time::Duration;
   2
   3use anyhow::anyhow;
   4use anyhow::Context;
   5use client::Client;
   6use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
   7use editor::Editor;
   8use gpui::AsyncWindowContext;
   9use gpui::Subscription;
  10use gpui::Task;
  11use gpui::WeakView;
  12use gpui::{
  13    percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
  14    FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
  15};
  16use markdown::Markdown;
  17use markdown::MarkdownStyle;
  18use rpc::proto::RegenerateDevServerTokenResponse;
  19use rpc::{
  20    proto::{CreateDevServerResponse, DevServerStatus},
  21    ErrorCode, ErrorExt,
  22};
  23use task::RevealStrategy;
  24use task::SpawnInTerminal;
  25use task::TerminalWorkDir;
  26use terminal_view::terminal_panel::TerminalPanel;
  27use ui::ElevationIndex;
  28use ui::Section;
  29use ui::{
  30    prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
  31    RadioWithLabel, Tooltip,
  32};
  33use ui_input::{FieldLabelLayout, TextField};
  34use util::ResultExt;
  35use workspace::notifications::NotifyResultExt;
  36use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
  37
  38use crate::open_dev_server_project;
  39use crate::OpenRemote;
  40
  41pub struct DevServerProjects {
  42    mode: Mode,
  43    focus_handle: FocusHandle,
  44    scroll_handle: ScrollHandle,
  45    dev_server_store: Model<dev_server_projects::Store>,
  46    workspace: WeakView<Workspace>,
  47    project_path_input: View<Editor>,
  48    dev_server_name_input: View<TextField>,
  49    markdown: View<Markdown>,
  50    _dev_server_subscription: Subscription,
  51}
  52
  53#[derive(Default)]
  54struct CreateDevServer {
  55    creating: Option<Task<()>>,
  56    dev_server_id: Option<DevServerId>,
  57    access_token: Option<String>,
  58    manual_setup: bool,
  59}
  60
  61struct CreateDevServerProject {
  62    dev_server_id: DevServerId,
  63    creating: bool,
  64    _opening: Option<Subscription>,
  65}
  66
  67enum Mode {
  68    Default(Option<CreateDevServerProject>),
  69    CreateDevServer(CreateDevServer),
  70}
  71
  72impl DevServerProjects {
  73    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  74        workspace.register_action(|workspace, _: &OpenRemote, cx| {
  75            let handle = cx.view().downgrade();
  76            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  77        });
  78    }
  79
  80    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
  81        workspace.update(cx, |workspace, cx| {
  82            let handle = cx.view().downgrade();
  83            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  84        })
  85    }
  86
  87    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
  88        let project_path_input = cx.new_view(|cx| {
  89            let mut editor = Editor::single_line(cx);
  90            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
  91            editor
  92        });
  93        let dev_server_name_input = cx.new_view(|cx| {
  94            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
  95        });
  96
  97        let focus_handle = cx.focus_handle();
  98        let dev_server_store = dev_server_projects::Store::global(cx);
  99
 100        let subscription = cx.observe(&dev_server_store, |_, _, cx| {
 101            cx.notify();
 102        });
 103
 104        let mut base_style = cx.text_style();
 105        base_style.refine(&gpui::TextStyleRefinement {
 106            color: Some(cx.theme().colors().editor_foreground),
 107            ..Default::default()
 108        });
 109
 110        let markdown_style = MarkdownStyle {
 111            base_text_style: base_style,
 112            code_block: gpui::StyleRefinement {
 113                text: Some(gpui::TextStyleRefinement {
 114                    font_family: Some("Zed Plex Mono".into()),
 115                    ..Default::default()
 116                }),
 117                ..Default::default()
 118            },
 119            link: gpui::TextStyleRefinement {
 120                color: Some(Color::Accent.color(cx)),
 121                ..Default::default()
 122            },
 123            syntax: cx.theme().syntax().clone(),
 124            selection_background_color: cx.theme().players().local().selection,
 125            ..Default::default()
 126        };
 127        let markdown =
 128            cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
 129
 130        Self {
 131            mode: Mode::Default(None),
 132            focus_handle,
 133            scroll_handle: ScrollHandle::new(),
 134            dev_server_store,
 135            project_path_input,
 136            dev_server_name_input,
 137            markdown,
 138            workspace,
 139            _dev_server_subscription: subscription,
 140        }
 141    }
 142
 143    pub fn create_dev_server_project(
 144        &mut self,
 145        dev_server_id: DevServerId,
 146        cx: &mut ViewContext<Self>,
 147    ) {
 148        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
 149
 150        if path == "" {
 151            return;
 152        }
 153
 154        if !path.starts_with('/') && !path.starts_with('~') {
 155            path = format!("~/{}", path);
 156        }
 157
 158        if self
 159            .dev_server_store
 160            .read(cx)
 161            .projects_for_server(dev_server_id)
 162            .iter()
 163            .any(|p| p.paths.iter().any(|p| p == &path))
 164        {
 165            cx.spawn(|_, mut cx| async move {
 166                cx.prompt(
 167                    gpui::PromptLevel::Critical,
 168                    "Failed to create project",
 169                    Some(&format!("{} is already open on this dev server.", path)),
 170                    &["Ok"],
 171                )
 172                .await
 173            })
 174            .detach_and_log_err(cx);
 175            return;
 176        }
 177
 178        let create = {
 179            let path = path.clone();
 180            self.dev_server_store.update(cx, |store, cx| {
 181                store.create_dev_server_project(dev_server_id, path, cx)
 182            })
 183        };
 184
 185        cx.spawn(|this, mut cx| async move {
 186            let result = create.await;
 187            this.update(&mut cx, |this, cx| {
 188                if let Ok(result) = &result {
 189                    if let Some(dev_server_project_id) =
 190                        result.dev_server_project.as_ref().map(|p| p.id)
 191                    {
 192                        let subscription =
 193                            cx.observe(&this.dev_server_store, move |this, store, cx| {
 194                                if let Some(project_id) = store
 195                                    .read(cx)
 196                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
 197                                    .and_then(|p| p.project_id)
 198                                {
 199                                    this.project_path_input.update(cx, |editor, cx| {
 200                                        editor.set_text("", cx);
 201                                    });
 202                                    this.mode = Mode::Default(None);
 203                                    if let Some(app_state) = AppState::global(cx).upgrade() {
 204                                        workspace::join_dev_server_project(
 205                                            DevServerProjectId(dev_server_project_id),
 206                                            project_id,
 207                                            app_state,
 208                                            None,
 209                                            cx,
 210                                        )
 211                                        .detach_and_prompt_err(
 212                                            "Could not join project",
 213                                            cx,
 214                                            |_, _| None,
 215                                        )
 216                                    }
 217                                }
 218                            });
 219
 220                        this.mode = Mode::Default(Some(CreateDevServerProject {
 221                            dev_server_id,
 222                            creating: true,
 223                            _opening: Some(subscription),
 224                        }));
 225                    }
 226                } else {
 227                    this.mode = Mode::Default(Some(CreateDevServerProject {
 228                        dev_server_id,
 229                        creating: false,
 230                        _opening: None,
 231                    }));
 232                }
 233            })
 234            .log_err();
 235            result
 236        })
 237        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
 238            match e.error_code() {
 239                ErrorCode::DevServerOffline => Some(
 240                    "The dev server is offline. Please log in and check it is connected."
 241                        .to_string(),
 242                ),
 243                ErrorCode::DevServerProjectPathDoesNotExist => {
 244                    Some(format!("The path `{}` does not exist on the server.", path))
 245                }
 246                _ => None,
 247            }
 248        });
 249
 250        self.mode = Mode::Default(Some(CreateDevServerProject {
 251            dev_server_id,
 252            creating: true,
 253            _opening: None,
 254        }));
 255    }
 256
 257    pub fn create_or_update_dev_server(
 258        &mut self,
 259        manual_setup: bool,
 260        existing_id: Option<DevServerId>,
 261        access_token: Option<String>,
 262        cx: &mut ViewContext<Self>,
 263    ) {
 264        let name = get_text(&self.dev_server_name_input, cx);
 265        if name.is_empty() {
 266            return;
 267        }
 268
 269        let ssh_connection_string = if manual_setup {
 270            None
 271        } else if name.contains(' ') {
 272            Some(name.clone())
 273        } else {
 274            Some(format!("ssh {}", name))
 275        };
 276
 277        let dev_server = self.dev_server_store.update(cx, {
 278            let access_token = access_token.clone();
 279            |store, cx| {
 280                let ssh_connection_string = ssh_connection_string.clone();
 281                if let Some(dev_server_id) = existing_id {
 282                    let rename = store.rename_dev_server(
 283                        dev_server_id,
 284                        name.clone(),
 285                        ssh_connection_string,
 286                        cx,
 287                    );
 288                    let token = if let Some(access_token) = access_token {
 289                        Task::ready(Ok(RegenerateDevServerTokenResponse {
 290                            dev_server_id: dev_server_id.0,
 291                            access_token,
 292                        }))
 293                    } else {
 294                        store.regenerate_dev_server_token(dev_server_id, cx)
 295                    };
 296                    cx.spawn(|_, _| async move {
 297                        rename.await?;
 298                        let response = token.await?;
 299                        Ok(CreateDevServerResponse {
 300                            dev_server_id: dev_server_id.0,
 301                            name,
 302                            access_token: response.access_token,
 303                        })
 304                    })
 305                } else {
 306                    store.create_dev_server(name, ssh_connection_string.clone(), cx)
 307                }
 308            }
 309        });
 310
 311        let workspace = self.workspace.clone();
 312        let store = dev_server_projects::Store::global(cx);
 313
 314        let task = cx
 315            .spawn({
 316                |this, mut cx| async move {
 317                    let result = dev_server.await;
 318
 319                    match result {
 320                        Ok(dev_server) => {
 321                            if let Some(ssh_connection_string) = ssh_connection_string {
 322                                this.update(&mut cx, |this, cx| {
 323                                    if let Mode::CreateDevServer(CreateDevServer {
 324                                        access_token,
 325                                        dev_server_id,
 326                                        ..
 327                                    }) = &mut this.mode
 328                                    {
 329                                        access_token.replace(dev_server.access_token.clone());
 330                                        dev_server_id
 331                                            .replace(DevServerId(dev_server.dev_server_id));
 332                                    }
 333                                    cx.notify();
 334                                })?;
 335
 336                                spawn_ssh_task(
 337                                    workspace
 338                                        .upgrade()
 339                                        .ok_or_else(|| anyhow!("workspace dropped"))?,
 340                                    store,
 341                                    DevServerId(dev_server.dev_server_id),
 342                                    ssh_connection_string,
 343                                    dev_server.access_token.clone(),
 344                                    &mut cx,
 345                                )
 346                                .await
 347                                .log_err();
 348                            }
 349
 350                            this.update(&mut cx, |this, cx| {
 351                                this.focus_handle.focus(cx);
 352                                this.mode = Mode::CreateDevServer(CreateDevServer {
 353                                    creating: None,
 354                                    dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
 355                                    access_token: Some(dev_server.access_token),
 356                                    manual_setup,
 357                                });
 358                                cx.notify();
 359                            })?;
 360                            Ok(())
 361                        }
 362                        Err(e) => {
 363                            this.update(&mut cx, |this, cx| {
 364                                this.mode = Mode::CreateDevServer(CreateDevServer {
 365                                    creating: None,
 366                                    dev_server_id: existing_id,
 367                                    access_token: None,
 368                                    manual_setup,
 369                                });
 370                                cx.notify()
 371                            })
 372                            .log_err();
 373
 374                            return Err(e);
 375                        }
 376                    }
 377                }
 378            })
 379            .prompt_err("Failed to create server", cx, |_, _| None);
 380
 381        self.mode = Mode::CreateDevServer(CreateDevServer {
 382            creating: Some(task),
 383            dev_server_id: existing_id,
 384            access_token,
 385            manual_setup,
 386        });
 387        cx.notify()
 388    }
 389
 390    fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
 391        let store = self.dev_server_store.read(cx);
 392        let prompt = if store.projects_for_server(id).is_empty()
 393            && store
 394                .dev_server(id)
 395                .is_some_and(|server| server.status == DevServerStatus::Offline)
 396        {
 397            None
 398        } else {
 399            Some(cx.prompt(
 400                gpui::PromptLevel::Warning,
 401                "Are you sure?",
 402                Some("This will delete the dev server and all of its remote projects."),
 403                &["Delete", "Cancel"],
 404            ))
 405        };
 406
 407        cx.spawn(|this, mut cx| async move {
 408            if let Some(prompt) = prompt {
 409                if prompt.await? != 0 {
 410                    return Ok(());
 411                }
 412            }
 413
 414            let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
 415                this.dev_server_store.update(cx, |store, _| {
 416                    store
 417                        .projects_for_server(id)
 418                        .into_iter()
 419                        .map(|project| project.id)
 420                        .collect()
 421                })
 422            })?;
 423
 424            this.update(&mut cx, |this, cx| {
 425                this.dev_server_store
 426                    .update(cx, |store, cx| store.delete_dev_server(id, cx))
 427            })?
 428            .await?;
 429
 430            for id in project_ids {
 431                WORKSPACE_DB
 432                    .delete_workspace_by_dev_server_project_id(id)
 433                    .await
 434                    .log_err();
 435            }
 436            Ok(())
 437        })
 438        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
 439    }
 440
 441    fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
 442        let answer = cx.prompt(
 443            gpui::PromptLevel::Warning,
 444            "Delete this project?",
 445            Some("This will delete the remote project. You can always re-add it later."),
 446            &["Delete", "Cancel"],
 447        );
 448
 449        cx.spawn(|this, mut cx| async move {
 450            let answer = answer.await?;
 451
 452            if answer != 0 {
 453                return Ok(());
 454            }
 455
 456            this.update(&mut cx, |this, cx| {
 457                this.dev_server_store
 458                    .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
 459            })?
 460            .await?;
 461
 462            WORKSPACE_DB
 463                .delete_workspace_by_dev_server_project_id(id)
 464                .await
 465                .log_err();
 466
 467            Ok(())
 468        })
 469        .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
 470    }
 471
 472    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 473        match &self.mode {
 474            Mode::Default(None) => {}
 475            Mode::Default(Some(create_project)) => {
 476                self.create_dev_server_project(create_project.dev_server_id, cx);
 477            }
 478            Mode::CreateDevServer(state) => {
 479                if state.creating.is_none() || state.dev_server_id.is_some() {
 480                    self.create_or_update_dev_server(
 481                        state.manual_setup,
 482                        state.dev_server_id,
 483                        state.access_token.clone(),
 484                        cx,
 485                    );
 486                }
 487            }
 488        }
 489    }
 490
 491    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 492        match self.mode {
 493            Mode::Default(None) => cx.emit(DismissEvent),
 494            _ => {
 495                self.mode = Mode::Default(None);
 496                self.focus_handle(cx).focus(cx);
 497                cx.notify();
 498            }
 499        }
 500    }
 501
 502    fn render_dev_server(
 503        &mut self,
 504        dev_server: &DevServer,
 505        create_project: Option<bool>,
 506        cx: &mut ViewContext<Self>,
 507    ) -> impl IntoElement {
 508        let dev_server_id = dev_server.id;
 509        let status = dev_server.status;
 510        let dev_server_name = dev_server.name.clone();
 511        let manual_setup = dev_server.ssh_connection_string.is_none();
 512
 513        v_flex()
 514            .w_full()
 515            .child(
 516                h_flex().group("dev-server").justify_between().child(
 517                    h_flex()
 518                        .gap_2()
 519                        .child(
 520                            div()
 521                                .id(("status", dev_server.id.0))
 522                                .relative()
 523                                .child(Icon::new(IconName::Server).size(IconSize::Small))
 524                                .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
 525                                    Indicator::dot().color(match status {
 526                                        DevServerStatus::Online => Color::Created,
 527                                        DevServerStatus::Offline => Color::Hidden,
 528                                    }),
 529                                ))
 530                                .tooltip(move |cx| {
 531                                    Tooltip::text(
 532                                        match status {
 533                                            DevServerStatus::Online => "Online",
 534                                            DevServerStatus::Offline => "Offline",
 535                                        },
 536                                        cx,
 537                                    )
 538                                }),
 539                        )
 540                        .child(
 541                            div()
 542                                .max_w(rems(26.))
 543                                .overflow_hidden()
 544                                .whitespace_nowrap()
 545                                .child(Label::new(dev_server_name.clone())),
 546                        )
 547                        .child(
 548                            h_flex()
 549                                .visible_on_hover("dev-server")
 550                                .gap_1()
 551                                .child(if dev_server.ssh_connection_string.is_some() {
 552                                    let dev_server = dev_server.clone();
 553                                    IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
 554                                        .on_click(cx.listener(move |this, _, cx| {
 555                                            let Some(workspace) = this.workspace.upgrade() else {
 556                                                return;
 557                                            };
 558
 559                                            reconnect_to_dev_server(
 560                                                workspace,
 561                                                dev_server.clone(),
 562                                                cx,
 563                                            )
 564                                            .detach_and_prompt_err(
 565                                                "Failed to reconnect",
 566                                                cx,
 567                                                |_, _| None,
 568                                            );
 569                                        }))
 570                                        .tooltip(|cx| Tooltip::text("Reconnect", cx))
 571                                } else {
 572                                    IconButton::new("edit-dev-server", IconName::Pencil)
 573                                        .on_click(cx.listener(move |this, _, cx| {
 574                                            this.mode = Mode::CreateDevServer(CreateDevServer {
 575                                                dev_server_id: Some(dev_server_id),
 576                                                creating: None,
 577                                                access_token: None,
 578                                                manual_setup,
 579                                            });
 580                                            let dev_server_name = dev_server_name.clone();
 581                                            this.dev_server_name_input.update(
 582                                                cx,
 583                                                move |input, cx| {
 584                                                    input.editor().update(cx, move |editor, cx| {
 585                                                        editor.set_text(dev_server_name, cx)
 586                                                    })
 587                                                },
 588                                            )
 589                                        }))
 590                                        .tooltip(|cx| Tooltip::text("Edit dev server", cx))
 591                                })
 592                                .child({
 593                                    let dev_server_id = dev_server.id;
 594                                    IconButton::new("remove-dev-server", IconName::Trash)
 595                                        .on_click(cx.listener(move |this, _, cx| {
 596                                            this.delete_dev_server(dev_server_id, cx)
 597                                        }))
 598                                        .tooltip(|cx| Tooltip::text("Remove dev server", cx))
 599                                }),
 600                        ),
 601                ),
 602            )
 603            .child(
 604                v_flex()
 605                    .w_full()
 606                    .bg(cx.theme().colors().background)
 607                    .border_1()
 608                    .border_color(cx.theme().colors().border_variant)
 609                    .rounded_md()
 610                    .my_1()
 611                    .py_0p5()
 612                    .px_3()
 613                    .child(
 614                        List::new()
 615                            .empty_message("No projects.")
 616                            .children(
 617                                self.dev_server_store
 618                                    .read(cx)
 619                                    .projects_for_server(dev_server.id)
 620                                    .iter()
 621                                    .map(|p| self.render_dev_server_project(p, cx)),
 622                            )
 623                            .when(
 624                                create_project.is_none()
 625                                    && dev_server.status == DevServerStatus::Online,
 626                                |el| {
 627                                    el.child(
 628                                        ListItem::new("new-remote_project")
 629                                            .start_slot(Icon::new(IconName::Plus))
 630                                            .child(Label::new("Open folder…"))
 631                                            .on_click(cx.listener(move |this, _, cx| {
 632                                                this.mode =
 633                                                    Mode::Default(Some(CreateDevServerProject {
 634                                                        dev_server_id,
 635                                                        creating: false,
 636                                                        _opening: None,
 637                                                    }));
 638                                                this.project_path_input
 639                                                    .read(cx)
 640                                                    .focus_handle(cx)
 641                                                    .focus(cx);
 642                                                cx.notify();
 643                                            })),
 644                                    )
 645                                },
 646                            )
 647                            .when_some(create_project, |el, creating| {
 648                                el.child(self.render_create_new_project(creating, cx))
 649                            }),
 650                    ),
 651            )
 652    }
 653
 654    fn render_create_new_project(
 655        &mut self,
 656        creating: bool,
 657        _: &mut ViewContext<Self>,
 658    ) -> impl IntoElement {
 659        ListItem::new("create-remote-project")
 660            .disabled(true)
 661            .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
 662            .child(self.project_path_input.clone())
 663            .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
 664                el.child(
 665                    Icon::new(IconName::ArrowCircle)
 666                        .size(IconSize::Medium)
 667                        .with_animation(
 668                            "arrow-circle",
 669                            Animation::new(Duration::from_secs(2)).repeat(),
 670                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
 671                        ),
 672                )
 673            }))
 674    }
 675
 676    fn render_dev_server_project(
 677        &mut self,
 678        project: &DevServerProject,
 679        cx: &mut ViewContext<Self>,
 680    ) -> impl IntoElement {
 681        let dev_server_project_id = project.id;
 682        let project_id = project.project_id;
 683        let is_online = project_id.is_some();
 684
 685        ListItem::new(("remote-project", dev_server_project_id.0))
 686            .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
 687            .child(
 688                    Label::new(project.paths.join(", "))
 689            )
 690            .on_click(cx.listener(move |_, _, cx| {
 691                if let Some(project_id) = project_id {
 692                    if let Some(app_state) = AppState::global(cx).upgrade() {
 693                        workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
 694                            .detach_and_prompt_err("Could not join project", cx, |_, _| None)
 695                    }
 696                } else {
 697                    cx.spawn(|_, mut cx| async move {
 698                        cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err();
 699                    }).detach();
 700                }
 701            }))
 702            .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
 703                .on_click(cx.listener(move |this, _, cx| {
 704                    this.delete_dev_server_project(dev_server_project_id, cx)
 705                }))
 706                .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
 707    }
 708
 709    fn render_create_dev_server(
 710        &self,
 711        state: &CreateDevServer,
 712        cx: &mut ViewContext<Self>,
 713    ) -> impl IntoElement {
 714        let creating = state.creating.is_some();
 715        let dev_server_id = state.dev_server_id;
 716        let access_token = state.access_token.clone();
 717        let manual_setup = state.manual_setup;
 718
 719        let status = dev_server_id
 720            .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
 721            .unwrap_or_default();
 722
 723        let name = self.dev_server_name_input.update(cx, |input, cx| {
 724            input.editor().update(cx, |editor, cx| {
 725                if editor.text(cx).is_empty() {
 726                    if manual_setup {
 727                        editor.set_placeholder_text("example-server", cx)
 728                    } else {
 729                        editor.set_placeholder_text("ssh host", cx)
 730                    }
 731                }
 732                editor.text(cx)
 733            })
 734        });
 735
 736        const MANUAL_SETUP_MESSAGE: &str = "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine.";
 737        const SSH_SETUP_MESSAGE: &str = "Enter the command you use to ssh into this server.\nFor example: `ssh me@my.server` or `gh cs ssh -c example`.";
 738
 739        Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
 740            .header(
 741                ModalHeader::new()
 742                    .headline("Create Dev Server")
 743                    .show_back_button(true),
 744            )
 745            .section(
 746                Section::new()
 747                    .header(if manual_setup {
 748                        "Server Name".into()
 749                    } else {
 750                        "SSH arguments".into()
 751                    })
 752                    .child(
 753                        div()
 754                            .max_w(rems(16.))
 755                            .child(self.dev_server_name_input.clone()),
 756                    ),
 757            )
 758            .section(
 759                Section::new_contained()
 760                    .header("Connection Method".into())
 761                    .child(
 762                        v_flex()
 763                            .w_full()
 764                            .gap_y(Spacing::Large.rems(cx))
 765                            .child(
 766                                v_flex()
 767                                    .child(RadioWithLabel::new(
 768                                        "use-server-name-in-ssh",
 769                                        Label::new("Connect via SSH (default)"),
 770                                        !manual_setup,
 771                                        cx.listener({
 772                                            move |this, _, cx| {
 773                                                if let Mode::CreateDevServer(CreateDevServer {
 774                                                    manual_setup,
 775                                                    ..
 776                                                }) = &mut this.mode
 777                                                {
 778                                                    *manual_setup = false;
 779                                                }
 780                                                cx.notify()
 781                                            }
 782                                        }),
 783                                    ))
 784                                    .child(RadioWithLabel::new(
 785                                        "use-server-name-in-ssh",
 786                                        Label::new("Manual Setup"),
 787                                        manual_setup,
 788                                        cx.listener({
 789                                            move |this, _, cx| {
 790                                                if let Mode::CreateDevServer(CreateDevServer {
 791                                                    manual_setup,
 792                                                    ..
 793                                                }) = &mut this.mode
 794                                                {
 795                                                    *manual_setup = true;
 796                                                }
 797                                                cx.notify()
 798                                            }
 799                                        }),
 800                                    )),
 801                            )
 802                            .when(dev_server_id.is_none(), |el| {
 803                                el.child(
 804                                    if manual_setup {
 805                                        Label::new(MANUAL_SETUP_MESSAGE)
 806                                    } else {
 807                                        Label::new(SSH_SETUP_MESSAGE)
 808                                    }
 809                                    .size(LabelSize::Small)
 810                                    .color(Color::Muted),
 811                                )
 812                            })
 813                            .when(dev_server_id.is_some() && access_token.is_none(), |el| {
 814                                el.child(
 815                                    if manual_setup {
 816                                        Label::new(
 817                                            "Note: updating the dev server generate a new token",
 818                                        )
 819                                    } else {
 820                                        Label::new(
 821                                            "Enter the command you use to ssh into this server.\n\
 822                                        For example: `ssh me@my.server` or `gh cs ssh -c example`.",
 823                                        )
 824                                    }
 825                                    .size(LabelSize::Small)
 826                                    .color(Color::Muted),
 827                                )
 828                            })
 829                            .when_some(access_token.clone(), {
 830                                |el, access_token| {
 831                                    el.child(self.render_dev_server_token_creating(
 832                                        access_token,
 833                                        name,
 834                                        manual_setup,
 835                                        status,
 836                                        creating,
 837                                        cx,
 838                                    ))
 839                                }
 840                            }),
 841                    ),
 842            )
 843            .footer(
 844                ModalFooter::new().end_slot(if status == DevServerStatus::Online {
 845                    Button::new("create-dev-server", "Done")
 846                        .style(ButtonStyle::Filled)
 847                        .layer(ElevationIndex::ModalSurface)
 848                        .on_click(cx.listener(move |this, _, cx| {
 849                            cx.focus(&this.focus_handle);
 850                            this.mode = Mode::Default(None);
 851                            cx.notify();
 852                        }))
 853                } else {
 854                    Button::new(
 855                        "create-dev-server",
 856                        if manual_setup {
 857                            if dev_server_id.is_some() {
 858                                "Update"
 859                            } else {
 860                                "Create"
 861                            }
 862                        } else {
 863                            if dev_server_id.is_some() {
 864                                "Reconnect"
 865                            } else {
 866                                "Connect"
 867                            }
 868                        },
 869                    )
 870                    .style(ButtonStyle::Filled)
 871                    .layer(ElevationIndex::ModalSurface)
 872                    .disabled(creating && dev_server_id.is_none())
 873                    .on_click(cx.listener({
 874                        let access_token = access_token.clone();
 875                        move |this, _, cx| {
 876                            this.create_or_update_dev_server(
 877                                manual_setup,
 878                                dev_server_id,
 879                                access_token.clone(),
 880                                cx,
 881                            );
 882                        }
 883                    }))
 884                }),
 885            )
 886    }
 887
 888    fn render_dev_server_token_creating(
 889        &self,
 890        access_token: String,
 891        dev_server_name: String,
 892        manual_setup: bool,
 893        status: DevServerStatus,
 894        creating: bool,
 895        cx: &mut ViewContext<Self>,
 896    ) -> Div {
 897        self.markdown.update(cx, |markdown, cx| {
 898            if manual_setup {
 899                markdown.reset(format!("Please log into '{}'. If you don't yet have zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen to start zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
 900            } else {
 901                markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using manual setup.".to_string(), cx);
 902            }
 903        });
 904
 905        v_flex()
 906            .pl_2()
 907            .pt_2()
 908            .gap_2()
 909            .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
 910            .map(|el| {
 911                if status == DevServerStatus::Offline && !manual_setup && !creating {
 912                    el.child(
 913                        h_flex()
 914                            .gap_2()
 915                            .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
 916                            .child(Label::new("Not connected")),
 917                    )
 918                } else if status == DevServerStatus::Offline {
 919                    el.child(Self::render_loading_spinner("Waiting for connection…"))
 920                } else {
 921                    el.child(Label::new("🎊 Connection established!"))
 922                }
 923            })
 924    }
 925
 926    fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
 927        h_flex()
 928            .gap_2()
 929            .child(
 930                Icon::new(IconName::ArrowCircle)
 931                    .size(IconSize::Medium)
 932                    .with_animation(
 933                        "arrow-circle",
 934                        Animation::new(Duration::from_secs(2)).repeat(),
 935                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
 936                    ),
 937            )
 938            .child(Label::new(label))
 939    }
 940
 941    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 942        let dev_servers = self.dev_server_store.read(cx).dev_servers();
 943
 944        let Mode::Default(create_dev_server_project) = &self.mode else {
 945            unreachable!()
 946        };
 947
 948        let mut is_creating = None;
 949        let mut creating_dev_server = None;
 950        if let Some(CreateDevServerProject {
 951            creating,
 952            dev_server_id,
 953            ..
 954        }) = create_dev_server_project
 955        {
 956            is_creating = Some(*creating);
 957            creating_dev_server = Some(*dev_server_id);
 958        };
 959        let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
 960
 961        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
 962            .header(
 963                ModalHeader::new()
 964                    .show_dismiss_button(true)
 965                    .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
 966            )
 967            .when(is_signed_out, |modal| {
 968                modal
 969                    .section(Section::new().child(v_flex().mb_4().child(Label::new(
 970                        "You are not currently signed in to Zed. Currently the remote development featuers are only available to signed in users. Please sign in to continue.",
 971                    ))))
 972                    .footer(
 973                        ModalFooter::new().end_slot(
 974                            Button::new("sign_in", "Sign in")
 975                                .icon(IconName::Github)
 976                                .icon_position(IconPosition::Start)
 977                                .style(ButtonStyle::Filled)
 978                                .full_width()
 979                                .on_click(cx.listener(|_, _, cx| {
 980                                    let client = Client::global(cx).clone();
 981                                    cx.spawn(|_, mut cx| async move {
 982                                        client
 983                                            .authenticate_and_connect(true, &cx)
 984                                            .await
 985                                            .notify_async_err(&mut cx);
 986                                    })
 987                                    .detach();
 988                                    cx.emit(gpui::DismissEvent);
 989                                })),
 990                        ),
 991                    )
 992            })
 993            .when(!is_signed_out, |modal| {
 994                modal.section(
 995                    Section::new().child(
 996                        div().mb_4().child(
 997                            List::new()
 998                                .empty_message("No dev servers registered.")
 999                                .header(Some(
1000                                    ListHeader::new("Dev Servers").end_slot(
1001                                        Button::new("register-dev-server-button", "New Server")
1002                                            .icon(IconName::Plus)
1003                                            .icon_position(IconPosition::Start)
1004                                            .tooltip(|cx| {
1005                                                Tooltip::text("Register a new dev server", cx)
1006                                            })
1007                                            .on_click(cx.listener(|this, _, cx| {
1008                                                this.mode = Mode::CreateDevServer(
1009                                                    CreateDevServer::default(),
1010                                                );
1011                                                this.dev_server_name_input.update(
1012                                                    cx,
1013                                                    |text_field, cx| {
1014                                                        text_field.editor().update(
1015                                                            cx,
1016                                                            |editor, cx| {
1017                                                                editor.set_text("", cx);
1018                                                            },
1019                                                        );
1020                                                    },
1021                                                );
1022                                                cx.notify();
1023                                            })),
1024                                    ),
1025                                ))
1026                                .children(dev_servers.iter().map(|dev_server| {
1027                                    let creating = if creating_dev_server == Some(dev_server.id) {
1028                                        is_creating
1029                                    } else {
1030                                        None
1031                                    };
1032                                    self.render_dev_server(dev_server, creating, cx)
1033                                        .into_any_element()
1034                                })),
1035                        ),
1036                    ),
1037                )
1038            })
1039    }
1040}
1041
1042fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1043    element
1044        .read(cx)
1045        .editor()
1046        .read(cx)
1047        .text(cx)
1048        .trim()
1049        .to_string()
1050}
1051
1052impl ModalView for DevServerProjects {}
1053
1054impl FocusableView for DevServerProjects {
1055    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1056        self.focus_handle.clone()
1057    }
1058}
1059
1060impl EventEmitter<DismissEvent> for DevServerProjects {}
1061
1062impl Render for DevServerProjects {
1063    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1064        div()
1065            .track_focus(&self.focus_handle)
1066            .elevation_3(cx)
1067            .key_context("DevServerModal")
1068            .on_action(cx.listener(Self::cancel))
1069            .on_action(cx.listener(Self::confirm))
1070            .capture_any_mouse_down(cx.listener(|this, _, cx| {
1071                this.focus_handle(cx).focus(cx);
1072            }))
1073            .on_mouse_down_out(cx.listener(|this, _, cx| {
1074                if matches!(this.mode, Mode::Default(None)) {
1075                    cx.emit(DismissEvent)
1076                }
1077            }))
1078            .w(rems(34.))
1079            .max_h(rems(40.))
1080            .child(match &self.mode {
1081                Mode::Default(_) => self.render_default(cx).into_any_element(),
1082                Mode::CreateDevServer(state) => {
1083                    self.render_create_dev_server(state, cx).into_any_element()
1084                }
1085            })
1086    }
1087}
1088
1089pub fn reconnect_to_dev_server_project(
1090    workspace: View<Workspace>,
1091    dev_server: DevServer,
1092    dev_server_project_id: DevServerProjectId,
1093    replace_current_window: bool,
1094    cx: &mut WindowContext,
1095) -> Task<anyhow::Result<()>> {
1096    let store = dev_server_projects::Store::global(cx);
1097    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1098    cx.spawn(|mut cx| async move {
1099        reconnect.await?;
1100
1101        cx.background_executor()
1102            .timer(Duration::from_millis(1000))
1103            .await;
1104
1105        if let Some(project_id) = store.update(&mut cx, |store, _| {
1106            store
1107                .dev_server_project(dev_server_project_id)
1108                .and_then(|p| p.project_id)
1109        })? {
1110            workspace
1111                .update(&mut cx, move |_, cx| {
1112                    open_dev_server_project(
1113                        replace_current_window,
1114                        dev_server_project_id,
1115                        project_id,
1116                        cx,
1117                    )
1118                })?
1119                .await?;
1120        }
1121
1122        Ok(())
1123    })
1124}
1125
1126pub fn reconnect_to_dev_server(
1127    workspace: View<Workspace>,
1128    dev_server: DevServer,
1129    cx: &mut WindowContext,
1130) -> Task<anyhow::Result<()>> {
1131    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1132        return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1133    };
1134    let dev_server_store = dev_server_projects::Store::global(cx);
1135    let get_access_token = dev_server_store.update(cx, |store, cx| {
1136        store.regenerate_dev_server_token(dev_server.id, cx)
1137    });
1138
1139    cx.spawn(|mut cx| async move {
1140        let access_token = get_access_token.await?.access_token;
1141
1142        spawn_ssh_task(
1143            workspace,
1144            dev_server_store,
1145            dev_server.id,
1146            ssh_connection_string.to_string(),
1147            access_token,
1148            &mut cx,
1149        )
1150        .await
1151    })
1152}
1153
1154pub async fn spawn_ssh_task(
1155    workspace: View<Workspace>,
1156    dev_server_store: Model<dev_server_projects::Store>,
1157    dev_server_id: DevServerId,
1158    ssh_connection_string: String,
1159    access_token: String,
1160    cx: &mut AsyncWindowContext,
1161) -> anyhow::Result<()> {
1162    let terminal_panel = workspace
1163        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1164        .ok()
1165        .flatten()
1166        .with_context(|| anyhow!("No terminal panel"))?;
1167
1168    let command = "sh".to_string();
1169    let args = vec![
1170        "-x".to_string(),
1171        "-c".to_string(),
1172        format!(
1173            r#"~/.local/bin/zed -v >/dev/stderr || (curl -sSL https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | bash && ~/.local/bin/zed --dev-server-token {}"#,
1174            access_token
1175        ),
1176    ];
1177
1178    let ssh_connection_string = ssh_connection_string.to_string();
1179
1180    let terminal = terminal_panel
1181        .update(cx, |terminal_panel, cx| {
1182            terminal_panel.spawn_in_new_terminal(
1183                SpawnInTerminal {
1184                    id: task::TaskId("ssh-remote".into()),
1185                    full_label: "Install zed over ssh".into(),
1186                    label: "Install zed over ssh".into(),
1187                    command,
1188                    args,
1189                    command_label: ssh_connection_string.clone(),
1190                    cwd: Some(TerminalWorkDir::Ssh {
1191                        ssh_command: ssh_connection_string,
1192                        path: None,
1193                    }),
1194                    env: Default::default(),
1195                    use_new_terminal: true,
1196                    allow_concurrent_runs: false,
1197                    reveal: RevealStrategy::Always,
1198                },
1199                cx,
1200            )
1201        })?
1202        .await?;
1203
1204    terminal
1205        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1206        .await;
1207
1208    // 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.
1209    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1210        == DevServerStatus::Offline
1211    {
1212        cx.background_executor()
1213            .timer(Duration::from_millis(200))
1214            .await
1215    }
1216
1217    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1218        == DevServerStatus::Offline
1219    {
1220        return Err(anyhow!("couldn't reconnect"))?;
1221    }
1222
1223    Ok(())
1224}