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