dev_servers.rs

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