dev_servers.rs

   1use std::collections::HashMap;
   2use std::path::PathBuf;
   3use std::time::Duration;
   4
   5use anyhow::anyhow;
   6use anyhow::Context;
   7use anyhow::Result;
   8use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
   9use editor::Editor;
  10use gpui::pulsating_between;
  11use gpui::AsyncWindowContext;
  12use gpui::ClipboardItem;
  13use gpui::PathPromptOptions;
  14use gpui::Subscription;
  15use gpui::Task;
  16use gpui::WeakView;
  17use gpui::{
  18    Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
  19    FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext,
  20};
  21use project::terminals::wrap_for_ssh;
  22use project::terminals::SshCommand;
  23use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
  24use settings::update_settings_file;
  25use settings::Settings;
  26use task::HideStrategy;
  27use task::RevealStrategy;
  28use task::SpawnInTerminal;
  29use terminal_view::terminal_panel::TerminalPanel;
  30use ui::ElevationIndex;
  31use ui::Section;
  32use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip};
  33use ui_input::{FieldLabelLayout, TextField};
  34use util::ResultExt;
  35use workspace::OpenOptions;
  36use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
  37
  38use crate::open_dev_server_project;
  39use crate::ssh_connections::connect_over_ssh;
  40use crate::ssh_connections::open_ssh_project;
  41use crate::ssh_connections::RemoteSettingsContent;
  42use crate::ssh_connections::SshConnection;
  43use crate::ssh_connections::SshConnectionModal;
  44use crate::ssh_connections::SshProject;
  45use crate::ssh_connections::SshPrompt;
  46use crate::ssh_connections::SshSettings;
  47use crate::OpenRemote;
  48
  49pub struct DevServerProjects {
  50    mode: Mode,
  51    focus_handle: FocusHandle,
  52    scroll_handle: ScrollHandle,
  53    dev_server_store: Model<dev_server_projects::Store>,
  54    workspace: WeakView<Workspace>,
  55    project_path_input: View<Editor>,
  56    dev_server_name_input: View<TextField>,
  57    _dev_server_subscription: Subscription,
  58}
  59
  60#[derive(Default)]
  61struct CreateDevServer {
  62    creating: Option<Task<Option<()>>>,
  63    ssh_prompt: Option<View<SshPrompt>>,
  64}
  65
  66struct CreateDevServerProject {
  67    dev_server_id: DevServerId,
  68    _opening: Option<Subscription>,
  69}
  70
  71enum Mode {
  72    Default(Option<CreateDevServerProject>),
  73    CreateDevServer(CreateDevServer),
  74}
  75
  76impl DevServerProjects {
  77    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  78        workspace.register_action(|workspace, _: &OpenRemote, cx| {
  79            let handle = cx.view().downgrade();
  80            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  81        });
  82    }
  83
  84    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
  85        workspace.update(cx, |workspace, cx| {
  86            let handle = cx.view().downgrade();
  87            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
  88        })
  89    }
  90
  91    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
  92        let project_path_input = cx.new_view(|cx| {
  93            let mut editor = Editor::single_line(cx);
  94            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
  95            editor
  96        });
  97        let dev_server_name_input = cx.new_view(|cx| {
  98            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
  99        });
 100
 101        let focus_handle = cx.focus_handle();
 102        let dev_server_store = dev_server_projects::Store::global(cx);
 103
 104        let subscription = cx.observe(&dev_server_store, |_, _, cx| {
 105            cx.notify();
 106        });
 107
 108        let mut base_style = cx.text_style();
 109        base_style.refine(&gpui::TextStyleRefinement {
 110            color: Some(cx.theme().colors().editor_foreground),
 111            ..Default::default()
 112        });
 113
 114        Self {
 115            mode: Mode::Default(None),
 116            focus_handle,
 117            scroll_handle: ScrollHandle::new(),
 118            dev_server_store,
 119            project_path_input,
 120            dev_server_name_input,
 121            workspace,
 122            _dev_server_subscription: subscription,
 123        }
 124    }
 125
 126    pub fn create_dev_server_project(
 127        &mut self,
 128        dev_server_id: DevServerId,
 129        cx: &mut ViewContext<Self>,
 130    ) {
 131        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
 132
 133        if path.is_empty() {
 134            return;
 135        }
 136
 137        if !path.starts_with('/') && !path.starts_with('~') {
 138            path = format!("~/{}", path);
 139        }
 140
 141        if self
 142            .dev_server_store
 143            .read(cx)
 144            .projects_for_server(dev_server_id)
 145            .iter()
 146            .any(|p| p.paths.iter().any(|p| p == &path))
 147        {
 148            cx.spawn(|_, mut cx| async move {
 149                cx.prompt(
 150                    gpui::PromptLevel::Critical,
 151                    "Failed to create project",
 152                    Some(&format!("{} is already open on this dev server.", path)),
 153                    &["Ok"],
 154                )
 155                .await
 156            })
 157            .detach_and_log_err(cx);
 158            return;
 159        }
 160
 161        let create = {
 162            let path = path.clone();
 163            self.dev_server_store.update(cx, |store, cx| {
 164                store.create_dev_server_project(dev_server_id, path, cx)
 165            })
 166        };
 167
 168        cx.spawn(|this, mut cx| async move {
 169            let result = create.await;
 170            this.update(&mut cx, |this, cx| {
 171                if let Ok(result) = &result {
 172                    if let Some(dev_server_project_id) =
 173                        result.dev_server_project.as_ref().map(|p| p.id)
 174                    {
 175                        let subscription =
 176                            cx.observe(&this.dev_server_store, move |this, store, cx| {
 177                                if let Some(project_id) = store
 178                                    .read(cx)
 179                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
 180                                    .and_then(|p| p.project_id)
 181                                {
 182                                    this.project_path_input.update(cx, |editor, cx| {
 183                                        editor.set_text("", cx);
 184                                    });
 185                                    this.mode = Mode::Default(None);
 186                                    if let Some(app_state) = AppState::global(cx).upgrade() {
 187                                        workspace::join_dev_server_project(
 188                                            DevServerProjectId(dev_server_project_id),
 189                                            project_id,
 190                                            app_state,
 191                                            None,
 192                                            cx,
 193                                        )
 194                                        .detach_and_prompt_err(
 195                                            "Could not join project",
 196                                            cx,
 197                                            |_, _| None,
 198                                        )
 199                                    }
 200                                }
 201                            });
 202
 203                        this.mode = Mode::Default(Some(CreateDevServerProject {
 204                            dev_server_id,
 205                            _opening: Some(subscription),
 206                        }));
 207                    }
 208                } else {
 209                    this.mode = Mode::Default(Some(CreateDevServerProject {
 210                        dev_server_id,
 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
 234            _opening: None,
 235        }));
 236    }
 237
 238    fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
 239        let host = get_text(&self.dev_server_name_input, cx);
 240        if host.is_empty() {
 241            return;
 242        }
 243
 244        let mut host = host.trim_start_matches("ssh ");
 245        let mut username: Option<String> = None;
 246        let mut port: Option<u16> = None;
 247
 248        if let Some((u, rest)) = host.split_once('@') {
 249            host = rest;
 250            username = Some(u.to_string());
 251        }
 252        if let Some((rest, p)) = host.split_once(':') {
 253            host = rest;
 254            port = p.parse().ok()
 255        }
 256
 257        if let Some((rest, p)) = host.split_once(" -p") {
 258            host = rest;
 259            port = p.trim().parse().ok()
 260        }
 261
 262        let connection_options = remote::SshConnectionOptions {
 263            host: host.to_string(),
 264            username: username.clone(),
 265            port,
 266            password: None,
 267        };
 268        let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 269
 270        let connection = connect_over_ssh(
 271            connection_options.dev_server_identifier(),
 272            connection_options.clone(),
 273            ssh_prompt.clone(),
 274            cx,
 275        )
 276        .prompt_err("Failed to connect", cx, |_, _| None);
 277
 278        let creating = cx.spawn(move |this, mut cx| async move {
 279            match connection.await {
 280                Some(_) => this
 281                    .update(&mut cx, |this, cx| {
 282                        let _ = this.workspace.update(cx, |workspace, _| {
 283                            workspace
 284                                .client()
 285                                .telemetry()
 286                                .report_app_event("create ssh server".to_string())
 287                        });
 288
 289                        this.add_ssh_server(connection_options, cx);
 290                        this.mode = Mode::Default(None);
 291                        cx.notify()
 292                    })
 293                    .log_err(),
 294                None => this
 295                    .update(&mut cx, |this, cx| {
 296                        this.mode = Mode::CreateDevServer(CreateDevServer::default());
 297                        cx.notify()
 298                    })
 299                    .log_err(),
 300            };
 301            None
 302        });
 303        self.mode = Mode::CreateDevServer(CreateDevServer {
 304            ssh_prompt: Some(ssh_prompt.clone()),
 305            creating: Some(creating),
 306        });
 307    }
 308
 309    fn create_ssh_project(
 310        &mut self,
 311        ix: usize,
 312        ssh_connection: SshConnection,
 313        cx: &mut ViewContext<Self>,
 314    ) {
 315        let Some(workspace) = self.workspace.upgrade() else {
 316            return;
 317        };
 318
 319        let connection_options = ssh_connection.into();
 320        workspace.update(cx, |_, cx| {
 321            cx.defer(move |workspace, cx| {
 322                workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
 323                let prompt = workspace
 324                    .active_modal::<SshConnectionModal>(cx)
 325                    .unwrap()
 326                    .read(cx)
 327                    .prompt
 328                    .clone();
 329
 330                let connect = connect_over_ssh(
 331                    connection_options.dev_server_identifier(),
 332                    connection_options,
 333                    prompt,
 334                    cx,
 335                )
 336                .prompt_err("Failed to connect", cx, |_, _| None);
 337                cx.spawn(|workspace, mut cx| async move {
 338                    let Some(session) = connect.await else {
 339                        workspace
 340                            .update(&mut cx, |workspace, cx| {
 341                                let weak = cx.view().downgrade();
 342                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 343                            })
 344                            .log_err();
 345                        return;
 346                    };
 347                    let Ok((app_state, project, paths)) =
 348                        workspace.update(&mut cx, |workspace, cx| {
 349                            let app_state = workspace.app_state().clone();
 350                            let project = project::Project::ssh(
 351                                session,
 352                                app_state.client.clone(),
 353                                app_state.node_runtime.clone(),
 354                                app_state.user_store.clone(),
 355                                app_state.languages.clone(),
 356                                app_state.fs.clone(),
 357                                cx,
 358                            );
 359                            let paths = workspace.prompt_for_open_path(
 360                                PathPromptOptions {
 361                                    files: true,
 362                                    directories: true,
 363                                    multiple: true,
 364                                },
 365                                project::DirectoryLister::Project(project.clone()),
 366                                cx,
 367                            );
 368                            (app_state, project, paths)
 369                        })
 370                    else {
 371                        return;
 372                    };
 373
 374                    let Ok(Some(paths)) = paths.await else {
 375                        workspace
 376                            .update(&mut cx, |workspace, cx| {
 377                                let weak = cx.view().downgrade();
 378                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 379                            })
 380                            .log_err();
 381                        return;
 382                    };
 383
 384                    let Some(options) = cx
 385                        .update(|cx| (app_state.build_window_options)(None, cx))
 386                        .log_err()
 387                    else {
 388                        return;
 389                    };
 390
 391                    cx.open_window(options, |cx| {
 392                        cx.activate_window();
 393
 394                        let fs = app_state.fs.clone();
 395                        update_settings_file::<SshSettings>(fs, cx, {
 396                            let paths = paths
 397                                .iter()
 398                                .map(|path| path.to_string_lossy().to_string())
 399                                .collect();
 400                            move |setting, _| {
 401                                if let Some(server) = setting
 402                                    .ssh_connections
 403                                    .as_mut()
 404                                    .and_then(|connections| connections.get_mut(ix))
 405                                {
 406                                    server.projects.push(SshProject { paths })
 407                                }
 408                            }
 409                        });
 410
 411                        let tasks = paths
 412                            .into_iter()
 413                            .map(|path| {
 414                                project.update(cx, |project, cx| {
 415                                    project.find_or_create_worktree(&path, true, cx)
 416                                })
 417                            })
 418                            .collect::<Vec<_>>();
 419                        cx.spawn(|_| async move {
 420                            for task in tasks {
 421                                task.await?;
 422                            }
 423                            Ok(())
 424                        })
 425                        .detach_and_prompt_err(
 426                            "Failed to open path",
 427                            cx,
 428                            |_, _| None,
 429                        );
 430
 431                        cx.new_view(|cx| {
 432                            let workspace =
 433                                Workspace::new(None, project.clone(), app_state.clone(), cx);
 434
 435                            workspace
 436                                .client()
 437                                .telemetry()
 438                                .report_app_event("create ssh project".to_string());
 439
 440                            workspace
 441                        })
 442                    })
 443                    .log_err();
 444                })
 445                .detach()
 446            })
 447        })
 448    }
 449
 450    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 451        match &self.mode {
 452            Mode::Default(None) => {}
 453            Mode::Default(Some(create_project)) => {
 454                self.create_dev_server_project(create_project.dev_server_id, cx);
 455            }
 456            Mode::CreateDevServer(state) => {
 457                if let Some(prompt) = state.ssh_prompt.as_ref() {
 458                    prompt.update(cx, |prompt, cx| {
 459                        prompt.confirm(cx);
 460                    });
 461                    return;
 462                }
 463
 464                self.create_ssh_server(cx);
 465            }
 466        }
 467    }
 468
 469    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 470        match &self.mode {
 471            Mode::Default(None) => cx.emit(DismissEvent),
 472            Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
 473                self.mode = Mode::CreateDevServer(CreateDevServer {
 474                    ..Default::default()
 475                });
 476                cx.notify();
 477            }
 478            _ => {
 479                self.mode = Mode::Default(None);
 480                self.focus_handle(cx).focus(cx);
 481                cx.notify();
 482            }
 483        }
 484    }
 485
 486    fn render_ssh_connection(
 487        &mut self,
 488        ix: usize,
 489        ssh_connection: SshConnection,
 490        cx: &mut ViewContext<Self>,
 491    ) -> impl IntoElement {
 492        v_flex()
 493            .w_full()
 494            .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
 495            .child(
 496                h_flex()
 497                    .w_full()
 498                    .group("ssh-server")
 499                    .justify_between()
 500                    .child(
 501                        h_flex()
 502                            .gap_2()
 503                            .w_full()
 504                            .child(
 505                                div()
 506                                    .id(("status", ix))
 507                                    .relative()
 508                                    .child(Icon::new(IconName::Server).size(IconSize::Small)),
 509                            )
 510                            .child(
 511                                h_flex()
 512                                    .max_w(rems(26.))
 513                                    .overflow_hidden()
 514                                    .whitespace_nowrap()
 515                                    .child(Label::new(ssh_connection.host.clone())),
 516                            ),
 517                    )
 518                    .child(
 519                        h_flex()
 520                            .visible_on_hover("ssh-server")
 521                            .gap_1()
 522                            .child({
 523                                IconButton::new("copy-dev-server-address", IconName::Copy)
 524                                    .icon_size(IconSize::Small)
 525                                    .on_click(cx.listener(move |this, _, cx| {
 526                                        this.update_settings_file(cx, move |servers, cx| {
 527                                            if let Some(content) = servers
 528                                                .ssh_connections
 529                                                .as_ref()
 530                                                .and_then(|connections| {
 531                                                    connections
 532                                                        .get(ix)
 533                                                        .map(|connection| connection.host.clone())
 534                                                })
 535                                            {
 536                                                cx.write_to_clipboard(ClipboardItem::new_string(
 537                                                    content,
 538                                                ));
 539                                            }
 540                                        });
 541                                    }))
 542                                    .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
 543                            })
 544                            .child({
 545                                IconButton::new("remove-dev-server", IconName::TrashAlt)
 546                                    .icon_size(IconSize::Small)
 547                                    .on_click(cx.listener(move |this, _, cx| {
 548                                        this.delete_ssh_server(ix, cx)
 549                                    }))
 550                                    .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
 551                            }),
 552                    ),
 553            )
 554            .child(
 555                v_flex()
 556                    .w_full()
 557                    .border_l_1()
 558                    .border_color(cx.theme().colors().border_variant)
 559                    .mb_1()
 560                    .mx_1p5()
 561                    .pl_2()
 562                    .child(
 563                        List::new()
 564                            .empty_message("No projects.")
 565                            .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 566                                v_flex().gap_0p5().child(self.render_ssh_project(
 567                                    ix,
 568                                    &ssh_connection,
 569                                    pix,
 570                                    p,
 571                                    cx,
 572                                ))
 573                            }))
 574                            .child(
 575                                h_flex().mt_1().pl_1().child(
 576                                    Button::new("new-remote_project", "Open Folder…")
 577                                        .size(ButtonSize::Default)
 578                                        .layer(ElevationIndex::ModalSurface)
 579                                        .icon(IconName::Plus)
 580                                        .icon_color(Color::Muted)
 581                                        .icon_position(IconPosition::Start)
 582                                        .on_click(cx.listener(move |this, _, cx| {
 583                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
 584                                        })),
 585                                ),
 586                            ),
 587                    ),
 588            )
 589    }
 590
 591    fn render_ssh_project(
 592        &self,
 593        server_ix: usize,
 594        server: &SshConnection,
 595        ix: usize,
 596        project: &SshProject,
 597        cx: &ViewContext<Self>,
 598    ) -> impl IntoElement {
 599        let project = project.clone();
 600        let server = server.clone();
 601
 602        ListItem::new(("remote-project", ix))
 603            .inset(true)
 604            .spacing(ui::ListItemSpacing::Sparse)
 605            .start_slot(
 606                Icon::new(IconName::Folder)
 607                    .color(Color::Muted)
 608                    .size(IconSize::Small),
 609            )
 610            .child(Label::new(project.paths.join(", ")))
 611            .on_click(cx.listener(move |this, _, cx| {
 612                let Some(app_state) = this
 613                    .workspace
 614                    .update(cx, |workspace, _| workspace.app_state().clone())
 615                    .log_err()
 616                else {
 617                    return;
 618                };
 619                let project = project.clone();
 620                let server = server.clone();
 621                cx.spawn(|_, mut cx| async move {
 622                    let result = open_ssh_project(
 623                        server.into(),
 624                        project.paths.into_iter().map(PathBuf::from).collect(),
 625                        app_state,
 626                        OpenOptions::default(),
 627                        &mut cx,
 628                    )
 629                    .await;
 630                    if let Err(e) = result {
 631                        log::error!("Failed to connect: {:?}", e);
 632                        cx.prompt(
 633                            gpui::PromptLevel::Critical,
 634                            "Failed to connect",
 635                            Some(&e.to_string()),
 636                            &["Ok"],
 637                        )
 638                        .await
 639                        .ok();
 640                    }
 641                })
 642                .detach();
 643            }))
 644            .end_hover_slot::<AnyElement>(Some(
 645                IconButton::new("remove-remote-project", IconName::TrashAlt)
 646                    .on_click(
 647                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
 648                    )
 649                    .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
 650                    .into_any_element(),
 651            ))
 652    }
 653
 654    fn update_settings_file(
 655        &mut self,
 656        cx: &mut ViewContext<Self>,
 657        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 658    ) {
 659        let Some(fs) = self
 660            .workspace
 661            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 662            .log_err()
 663        else {
 664            return;
 665        };
 666        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 667    }
 668
 669    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 670        self.update_settings_file(cx, move |setting, _| {
 671            if let Some(connections) = setting.ssh_connections.as_mut() {
 672                connections.remove(server);
 673            }
 674        });
 675    }
 676
 677    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 678        self.update_settings_file(cx, move |setting, _| {
 679            if let Some(server) = setting
 680                .ssh_connections
 681                .as_mut()
 682                .and_then(|connections| connections.get_mut(server))
 683            {
 684                server.projects.remove(project);
 685            }
 686        });
 687    }
 688
 689    fn add_ssh_server(
 690        &mut self,
 691        connection_options: remote::SshConnectionOptions,
 692        cx: &mut ViewContext<Self>,
 693    ) {
 694        self.update_settings_file(cx, move |setting, _| {
 695            setting
 696                .ssh_connections
 697                .get_or_insert(Default::default())
 698                .push(SshConnection {
 699                    host: connection_options.host,
 700                    username: connection_options.username,
 701                    port: connection_options.port,
 702                    projects: vec![],
 703                })
 704        });
 705    }
 706
 707    fn render_create_dev_server(
 708        &self,
 709        state: &CreateDevServer,
 710        cx: &mut ViewContext<Self>,
 711    ) -> impl IntoElement {
 712        let creating = state.creating.is_some();
 713        let ssh_prompt = state.ssh_prompt.clone();
 714
 715        self.dev_server_name_input.update(cx, |input, cx| {
 716            input.editor().update(cx, |editor, cx| {
 717                if editor.text(cx).is_empty() {
 718                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
 719                }
 720            })
 721        });
 722        let theme = cx.theme();
 723
 724        v_flex()
 725            .id("create-dev-server")
 726            .overflow_hidden()
 727            .size_full()
 728            .flex_1()
 729            .child(
 730                h_flex()
 731                    .p_2()
 732                    .gap_2()
 733                    .items_center()
 734                    .border_b_1()
 735                    .border_color(theme.colors().border_variant)
 736                    .child(
 737                        IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
 738                            .shape(IconButtonShape::Square)
 739                            .on_click(|_, cx| {
 740                                cx.dispatch_action(menu::Cancel.boxed_clone());
 741                            }),
 742                    )
 743                    .child(Label::new("Connect New Dev Server")),
 744            )
 745            .child(
 746                v_flex()
 747                    .p_3()
 748                    .border_b_1()
 749                    .border_color(theme.colors().border_variant)
 750                    .child(Label::new("SSH Arguments"))
 751                    .child(
 752                        Label::new("Enter the command you use to SSH into this server.")
 753                            .size(LabelSize::Small)
 754                            .color(Color::Muted),
 755                    )
 756                    .child(
 757                        h_flex()
 758                            .mt_2()
 759                            .w_full()
 760                            .gap_2()
 761                            .child(self.dev_server_name_input.clone())
 762                            .child(
 763                                Button::new("create-dev-server", "Connect Server")
 764                                    .style(ButtonStyle::Filled)
 765                                    .layer(ElevationIndex::ModalSurface)
 766                                    .disabled(creating)
 767                                    .on_click(cx.listener({
 768                                        move |this, _, cx| {
 769                                            this.create_ssh_server(cx);
 770                                        }
 771                                    })),
 772                            ),
 773                    ),
 774            )
 775            .child(
 776                h_flex()
 777                    .bg(theme.colors().editor_background)
 778                    .rounded_b_md()
 779                    .w_full()
 780                    .map(|this| {
 781                        if let Some(ssh_prompt) = ssh_prompt {
 782                            this.child(h_flex().w_full().child(ssh_prompt))
 783                        } else {
 784                            let color = Color::Muted.color(cx);
 785                            this.child(
 786                                h_flex()
 787                                    .p_2()
 788                                    .w_full()
 789                                    .justify_center()
 790                                    .gap_1p5()
 791                                    .child(
 792                                        div().p_1().rounded_lg().bg(color).with_animation(
 793                                            "pulse-ssh-waiting-for-connection",
 794                                            Animation::new(Duration::from_secs(2))
 795                                                .repeat()
 796                                                .with_easing(pulsating_between(0.2, 0.5)),
 797                                            move |this, progress| this.bg(color.opacity(progress)),
 798                                        ),
 799                                    )
 800                                    .child(
 801                                        Label::new("Waiting for connection…")
 802                                            .size(LabelSize::Small),
 803                                    ),
 804                            )
 805                        }
 806                    }),
 807            )
 808    }
 809
 810    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 811        let dev_servers = self.dev_server_store.read(cx).dev_servers();
 812        let ssh_connections = SshSettings::get_global(cx)
 813            .ssh_connections()
 814            .collect::<Vec<_>>();
 815
 816        let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
 817        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
 818            .header(
 819                ModalHeader::new().child(
 820                    h_flex()
 821                        .justify_between()
 822                        .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
 823                        .child(
 824                            Button::new("register-dev-server-button", "Connect New Server")
 825                                .style(ButtonStyle::Filled)
 826                                .layer(ElevationIndex::ModalSurface)
 827                                .icon(IconName::Plus)
 828                                .icon_position(IconPosition::Start)
 829                                .icon_color(Color::Muted)
 830                                .on_click(cx.listener(|this, _, cx| {
 831                                    this.mode = Mode::CreateDevServer(CreateDevServer {
 832                                        ..Default::default()
 833                                    });
 834                                    this.dev_server_name_input.update(cx, |text_field, cx| {
 835                                        text_field.editor().update(cx, |editor, cx| {
 836                                            editor.set_text("", cx);
 837                                        });
 838                                    });
 839                                    cx.notify();
 840                                })),
 841                        ),
 842                ),
 843            )
 844            .section(
 845                Section::new().padded(false).child(
 846                    div()
 847                        .border_y_1()
 848                        .border_color(cx.theme().colors().border_variant)
 849                        .w_full()
 850                        .child(
 851                            div().p_2().child(
 852                                List::new()
 853                                    .empty_message("No dev servers registered yet.")
 854                                    .children(ssh_connections.iter().cloned().enumerate().map(
 855                                        |(ix, connection)| {
 856                                            self.render_ssh_connection(ix, connection, cx)
 857                                                .into_any_element()
 858                                        },
 859                                    )),
 860                            ),
 861                        ),
 862                ),
 863            )
 864            .footer(
 865                ModalFooter::new()
 866                    .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
 867            )
 868    }
 869}
 870
 871fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
 872    element
 873        .read(cx)
 874        .editor()
 875        .read(cx)
 876        .text(cx)
 877        .trim()
 878        .to_string()
 879}
 880
 881impl ModalView for DevServerProjects {}
 882
 883impl FocusableView for DevServerProjects {
 884    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 885        self.focus_handle.clone()
 886    }
 887}
 888
 889impl EventEmitter<DismissEvent> for DevServerProjects {}
 890
 891impl Render for DevServerProjects {
 892    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 893        div()
 894            .track_focus(&self.focus_handle)
 895            .elevation_3(cx)
 896            .key_context("DevServerModal")
 897            .on_action(cx.listener(Self::cancel))
 898            .on_action(cx.listener(Self::confirm))
 899            .capture_any_mouse_down(cx.listener(|this, _, cx| {
 900                this.focus_handle(cx).focus(cx);
 901            }))
 902            .on_mouse_down_out(cx.listener(|this, _, cx| {
 903                if matches!(this.mode, Mode::Default(None)) {
 904                    cx.emit(DismissEvent)
 905                }
 906            }))
 907            .w(rems(34.))
 908            .max_h(rems(40.))
 909            .child(match &self.mode {
 910                Mode::Default(_) => self.render_default(cx).into_any_element(),
 911                Mode::CreateDevServer(state) => {
 912                    self.render_create_dev_server(state, cx).into_any_element()
 913                }
 914            })
 915    }
 916}
 917
 918pub fn reconnect_to_dev_server_project(
 919    workspace: View<Workspace>,
 920    dev_server: DevServer,
 921    dev_server_project_id: DevServerProjectId,
 922    replace_current_window: bool,
 923    cx: &mut WindowContext,
 924) -> Task<Result<()>> {
 925    let store = dev_server_projects::Store::global(cx);
 926    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
 927    cx.spawn(|mut cx| async move {
 928        reconnect.await?;
 929
 930        cx.background_executor()
 931            .timer(Duration::from_millis(1000))
 932            .await;
 933
 934        if let Some(project_id) = store.update(&mut cx, |store, _| {
 935            store
 936                .dev_server_project(dev_server_project_id)
 937                .and_then(|p| p.project_id)
 938        })? {
 939            workspace
 940                .update(&mut cx, move |_, cx| {
 941                    open_dev_server_project(
 942                        replace_current_window,
 943                        dev_server_project_id,
 944                        project_id,
 945                        cx,
 946                    )
 947                })?
 948                .await?;
 949        }
 950
 951        Ok(())
 952    })
 953}
 954
 955pub fn reconnect_to_dev_server(
 956    workspace: View<Workspace>,
 957    dev_server: DevServer,
 958    cx: &mut WindowContext,
 959) -> Task<Result<()>> {
 960    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
 961        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
 962    };
 963    let dev_server_store = dev_server_projects::Store::global(cx);
 964    let get_access_token = dev_server_store.update(cx, |store, cx| {
 965        store.regenerate_dev_server_token(dev_server.id, cx)
 966    });
 967
 968    cx.spawn(|mut cx| async move {
 969        let access_token = get_access_token.await?.access_token;
 970
 971        spawn_ssh_task(
 972            workspace,
 973            dev_server_store,
 974            dev_server.id,
 975            ssh_connection_string.to_string(),
 976            access_token,
 977            &mut cx,
 978        )
 979        .await
 980    })
 981}
 982
 983pub async fn spawn_ssh_task(
 984    workspace: View<Workspace>,
 985    dev_server_store: Model<dev_server_projects::Store>,
 986    dev_server_id: DevServerId,
 987    ssh_connection_string: String,
 988    access_token: String,
 989    cx: &mut AsyncWindowContext,
 990) -> Result<()> {
 991    let terminal_panel = workspace
 992        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
 993        .ok()
 994        .flatten()
 995        .with_context(|| anyhow!("No terminal panel"))?;
 996
 997    let command = "sh".to_string();
 998    let args = vec![
 999        "-x".to_string(),
1000        "-c".to_string(),
1001        format!(
1002            r#"~/.local/bin/zed -v >/dev/stderr || (curl -f https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | sh && ZED_HEADLESS=1 ~/.local/bin/zed --dev-server-token {}"#,
1003            access_token
1004        ),
1005    ];
1006
1007    let ssh_connection_string = ssh_connection_string.to_string();
1008    let (command, args) = wrap_for_ssh(
1009        &SshCommand::DevServer(ssh_connection_string.clone()),
1010        Some((&command, &args)),
1011        None,
1012        HashMap::default(),
1013        None,
1014    );
1015
1016    let terminal = terminal_panel
1017        .update(cx, |terminal_panel, cx| {
1018            terminal_panel.spawn_in_new_terminal(
1019                SpawnInTerminal {
1020                    id: task::TaskId("ssh-remote".into()),
1021                    full_label: "Install zed over ssh".into(),
1022                    label: "Install zed over ssh".into(),
1023                    command,
1024                    args,
1025                    command_label: ssh_connection_string.clone(),
1026                    cwd: None,
1027                    use_new_terminal: true,
1028                    allow_concurrent_runs: false,
1029                    reveal: RevealStrategy::Always,
1030                    hide: HideStrategy::Never,
1031                    env: Default::default(),
1032                    shell: Default::default(),
1033                },
1034                cx,
1035            )
1036        })?
1037        .await?;
1038
1039    terminal
1040        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1041        .await;
1042
1043    // 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.
1044    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1045        == DevServerStatus::Offline
1046    {
1047        cx.background_executor()
1048            .timer(Duration::from_millis(200))
1049            .await
1050    }
1051
1052    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1053        == DevServerStatus::Offline
1054    {
1055        return Err(anyhow!("couldn't reconnect"))?;
1056    }
1057
1058    Ok(())
1059}