dev_servers.rs

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