dev_servers.rs

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