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