dev_servers.rs

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