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