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