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                    .my_1()
 560                    .mx_1p5()
 561                    .py_0p5()
 562                    .px_3()
 563                    .child(
 564                        List::new()
 565                            .empty_message("No projects.")
 566                            .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 567                                self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
 568                            }))
 569                            .child(
 570                                h_flex().child(
 571                                    Button::new("new-remote_project", "Open Folder…")
 572                                        .icon(IconName::Plus)
 573                                        .size(ButtonSize::Default)
 574                                        .style(ButtonStyle::Filled)
 575                                        .layer(ElevationIndex::ModalSurface)
 576                                        .icon_position(IconPosition::Start)
 577                                        .on_click(cx.listener(move |this, _, cx| {
 578                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
 579                                        })),
 580                                ),
 581                            ),
 582                    ),
 583            )
 584    }
 585
 586    fn render_ssh_project(
 587        &self,
 588        server_ix: usize,
 589        server: &SshConnection,
 590        ix: usize,
 591        project: &SshProject,
 592        cx: &ViewContext<Self>,
 593    ) -> impl IntoElement {
 594        let project = project.clone();
 595        let server = server.clone();
 596        ListItem::new(("remote-project", ix))
 597            .spacing(ui::ListItemSpacing::Sparse)
 598            .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
 599            .child(Label::new(project.paths.join(", ")))
 600            .on_click(cx.listener(move |this, _, cx| {
 601                let Some(app_state) = this
 602                    .workspace
 603                    .update(cx, |workspace, _| workspace.app_state().clone())
 604                    .log_err()
 605                else {
 606                    return;
 607                };
 608                let project = project.clone();
 609                let server = server.clone();
 610                cx.spawn(|_, mut cx| async move {
 611                    let result = open_ssh_project(
 612                        server.into(),
 613                        project.paths.into_iter().map(PathBuf::from).collect(),
 614                        app_state,
 615                        OpenOptions::default(),
 616                        &mut cx,
 617                    )
 618                    .await;
 619                    if let Err(e) = result {
 620                        log::error!("Failed to connect: {:?}", e);
 621                        cx.prompt(
 622                            gpui::PromptLevel::Critical,
 623                            "Failed to connect",
 624                            Some(&e.to_string()),
 625                            &["Ok"],
 626                        )
 627                        .await
 628                        .ok();
 629                    }
 630                })
 631                .detach();
 632            }))
 633            .end_hover_slot::<AnyElement>(Some(
 634                IconButton::new("remove-remote-project", IconName::TrashAlt)
 635                    .on_click(
 636                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
 637                    )
 638                    .tooltip(|cx| Tooltip::text("Delete remote project", cx))
 639                    .into_any_element(),
 640            ))
 641    }
 642
 643    fn update_settings_file(
 644        &mut self,
 645        cx: &mut ViewContext<Self>,
 646        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 647    ) {
 648        let Some(fs) = self
 649            .workspace
 650            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 651            .log_err()
 652        else {
 653            return;
 654        };
 655        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 656    }
 657
 658    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 659        self.update_settings_file(cx, move |setting, _| {
 660            if let Some(connections) = setting.ssh_connections.as_mut() {
 661                connections.remove(server);
 662            }
 663        });
 664    }
 665
 666    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 667        self.update_settings_file(cx, move |setting, _| {
 668            if let Some(server) = setting
 669                .ssh_connections
 670                .as_mut()
 671                .and_then(|connections| connections.get_mut(server))
 672            {
 673                server.projects.remove(project);
 674            }
 675        });
 676    }
 677
 678    fn add_ssh_server(
 679        &mut self,
 680        connection_options: remote::SshConnectionOptions,
 681        cx: &mut ViewContext<Self>,
 682    ) {
 683        self.update_settings_file(cx, move |setting, _| {
 684            setting
 685                .ssh_connections
 686                .get_or_insert(Default::default())
 687                .push(SshConnection {
 688                    host: connection_options.host,
 689                    username: connection_options.username,
 690                    port: connection_options.port,
 691                    projects: vec![],
 692                })
 693        });
 694    }
 695
 696    fn render_create_dev_server(
 697        &self,
 698        state: &CreateDevServer,
 699        cx: &mut ViewContext<Self>,
 700    ) -> impl IntoElement {
 701        let creating = state.creating.is_some();
 702        let ssh_prompt = state.ssh_prompt.clone();
 703
 704        self.dev_server_name_input.update(cx, |input, cx| {
 705            input.editor().update(cx, |editor, cx| {
 706                if editor.text(cx).is_empty() {
 707                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
 708                }
 709            })
 710        });
 711        let theme = cx.theme();
 712        v_flex()
 713            .id("create-dev-server")
 714            .overflow_hidden()
 715            .size_full()
 716            .flex_1()
 717            .child(
 718                h_flex()
 719                    .p_2()
 720                    .gap_2()
 721                    .items_center()
 722                    .border_b_1()
 723                    .border_color(theme.colors().border_variant)
 724                    .child(
 725                        IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
 726                            .shape(IconButtonShape::Square)
 727                            .on_click(|_, cx| {
 728                                cx.dispatch_action(menu::Cancel.boxed_clone());
 729                            }),
 730                    )
 731                    .child(Label::new("Connect New Dev Server")),
 732            )
 733            .child(
 734                v_flex()
 735                    .p_3()
 736                    .border_b_1()
 737                    .border_color(theme.colors().border_variant)
 738                    .child(Label::new("SSH Arguments"))
 739                    .child(
 740                        Label::new("Enter the command you use to SSH into this server.")
 741                            .size(LabelSize::Small)
 742                            .color(Color::Muted),
 743                    )
 744                    .child(
 745                        h_flex()
 746                            .mt_2()
 747                            .w_full()
 748                            .gap_2()
 749                            .child(self.dev_server_name_input.clone())
 750                            .child(
 751                                Button::new("create-dev-server", "Connect Server")
 752                                    .style(ButtonStyle::Filled)
 753                                    .layer(ElevationIndex::ModalSurface)
 754                                    .disabled(creating)
 755                                    .on_click(cx.listener({
 756                                        move |this, _, cx| {
 757                                            this.create_ssh_server(cx);
 758                                        }
 759                                    })),
 760                            ),
 761                    ),
 762            )
 763            .child(
 764                h_flex()
 765                    .bg(theme.colors().editor_background)
 766                    .w_full()
 767                    .map(|this| {
 768                        if let Some(ssh_prompt) = ssh_prompt {
 769                            this.child(h_flex().w_full().child(ssh_prompt))
 770                        } else {
 771                            let color = Color::Muted.color(cx);
 772                            this.child(
 773                                h_flex()
 774                                    .p_2()
 775                                    .w_full()
 776                                    .content_center()
 777                                    .gap_2()
 778                                    .child(h_flex().w_full())
 779                                    .child(
 780                                        div().p_1().rounded_lg().bg(color).with_animation(
 781                                            "pulse-ssh-waiting-for-connection",
 782                                            Animation::new(Duration::from_secs(2))
 783                                                .repeat()
 784                                                .with_easing(pulsating_between(0.2, 0.5)),
 785                                            move |this, progress| this.bg(color.opacity(progress)),
 786                                        ),
 787                                    )
 788                                    .child(
 789                                        Label::new("Waiting for connection…")
 790                                            .size(LabelSize::Small),
 791                                    )
 792                                    .child(h_flex().w_full()),
 793                            )
 794                        }
 795                    }),
 796            )
 797    }
 798
 799    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 800        let dev_servers = self.dev_server_store.read(cx).dev_servers();
 801        let ssh_connections = SshSettings::get_global(cx)
 802            .ssh_connections()
 803            .collect::<Vec<_>>();
 804
 805        let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
 806        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
 807            .header(
 808                ModalHeader::new().child(
 809                    h_flex()
 810                        .justify_between()
 811                        .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
 812                        .child(
 813                            Button::new("register-dev-server-button", "Connect New Server")
 814                                .style(ButtonStyle::Filled)
 815                                .layer(ElevationIndex::ModalSurface)
 816                                .icon(IconName::Plus)
 817                                .icon_position(IconPosition::Start)
 818                                .icon_color(Color::Muted)
 819                                .on_click(cx.listener(|this, _, cx| {
 820                                    this.mode = Mode::CreateDevServer(CreateDevServer {
 821                                        ..Default::default()
 822                                    });
 823                                    this.dev_server_name_input.update(cx, |text_field, cx| {
 824                                        text_field.editor().update(cx, |editor, cx| {
 825                                            editor.set_text("", cx);
 826                                        });
 827                                    });
 828                                    cx.notify();
 829                                })),
 830                        ),
 831                ),
 832            )
 833            .section(
 834                Section::new().padded(false).child(
 835                    div()
 836                        .border_y_1()
 837                        .border_color(cx.theme().colors().border_variant)
 838                        .w_full()
 839                        .child(
 840                            div().p_2().child(
 841                                List::new()
 842                                    .empty_message("No dev servers registered yet.")
 843                                    .children(ssh_connections.iter().cloned().enumerate().map(
 844                                        |(ix, connection)| {
 845                                            self.render_ssh_connection(ix, connection, cx)
 846                                                .into_any_element()
 847                                        },
 848                                    )),
 849                            ),
 850                        ),
 851                ),
 852            )
 853            .footer(
 854                ModalFooter::new()
 855                    .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
 856            )
 857    }
 858}
 859
 860fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
 861    element
 862        .read(cx)
 863        .editor()
 864        .read(cx)
 865        .text(cx)
 866        .trim()
 867        .to_string()
 868}
 869
 870impl ModalView for DevServerProjects {}
 871
 872impl FocusableView for DevServerProjects {
 873    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 874        self.focus_handle.clone()
 875    }
 876}
 877
 878impl EventEmitter<DismissEvent> for DevServerProjects {}
 879
 880impl Render for DevServerProjects {
 881    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 882        div()
 883            .track_focus(&self.focus_handle)
 884            .elevation_3(cx)
 885            .key_context("DevServerModal")
 886            .on_action(cx.listener(Self::cancel))
 887            .on_action(cx.listener(Self::confirm))
 888            .capture_any_mouse_down(cx.listener(|this, _, cx| {
 889                this.focus_handle(cx).focus(cx);
 890            }))
 891            .on_mouse_down_out(cx.listener(|this, _, cx| {
 892                if matches!(this.mode, Mode::Default(None)) {
 893                    cx.emit(DismissEvent)
 894                }
 895            }))
 896            .w(rems(34.))
 897            .max_h(rems(40.))
 898            .child(match &self.mode {
 899                Mode::Default(_) => self.render_default(cx).into_any_element(),
 900                Mode::CreateDevServer(state) => {
 901                    self.render_create_dev_server(state, cx).into_any_element()
 902                }
 903            })
 904    }
 905}
 906
 907pub fn reconnect_to_dev_server_project(
 908    workspace: View<Workspace>,
 909    dev_server: DevServer,
 910    dev_server_project_id: DevServerProjectId,
 911    replace_current_window: bool,
 912    cx: &mut WindowContext,
 913) -> Task<Result<()>> {
 914    let store = dev_server_projects::Store::global(cx);
 915    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
 916    cx.spawn(|mut cx| async move {
 917        reconnect.await?;
 918
 919        cx.background_executor()
 920            .timer(Duration::from_millis(1000))
 921            .await;
 922
 923        if let Some(project_id) = store.update(&mut cx, |store, _| {
 924            store
 925                .dev_server_project(dev_server_project_id)
 926                .and_then(|p| p.project_id)
 927        })? {
 928            workspace
 929                .update(&mut cx, move |_, cx| {
 930                    open_dev_server_project(
 931                        replace_current_window,
 932                        dev_server_project_id,
 933                        project_id,
 934                        cx,
 935                    )
 936                })?
 937                .await?;
 938        }
 939
 940        Ok(())
 941    })
 942}
 943
 944pub fn reconnect_to_dev_server(
 945    workspace: View<Workspace>,
 946    dev_server: DevServer,
 947    cx: &mut WindowContext,
 948) -> Task<Result<()>> {
 949    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
 950        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
 951    };
 952    let dev_server_store = dev_server_projects::Store::global(cx);
 953    let get_access_token = dev_server_store.update(cx, |store, cx| {
 954        store.regenerate_dev_server_token(dev_server.id, cx)
 955    });
 956
 957    cx.spawn(|mut cx| async move {
 958        let access_token = get_access_token.await?.access_token;
 959
 960        spawn_ssh_task(
 961            workspace,
 962            dev_server_store,
 963            dev_server.id,
 964            ssh_connection_string.to_string(),
 965            access_token,
 966            &mut cx,
 967        )
 968        .await
 969    })
 970}
 971
 972pub async fn spawn_ssh_task(
 973    workspace: View<Workspace>,
 974    dev_server_store: Model<dev_server_projects::Store>,
 975    dev_server_id: DevServerId,
 976    ssh_connection_string: String,
 977    access_token: String,
 978    cx: &mut AsyncWindowContext,
 979) -> Result<()> {
 980    let terminal_panel = workspace
 981        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
 982        .ok()
 983        .flatten()
 984        .with_context(|| anyhow!("No terminal panel"))?;
 985
 986    let command = "sh".to_string();
 987    let args = vec![
 988        "-x".to_string(),
 989        "-c".to_string(),
 990        format!(
 991            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 {}"#,
 992            access_token
 993        ),
 994    ];
 995
 996    let ssh_connection_string = ssh_connection_string.to_string();
 997    let (command, args) = wrap_for_ssh(
 998        &SshCommand::DevServer(ssh_connection_string.clone()),
 999        Some((&command, &args)),
1000        None,
1001        HashMap::default(),
1002        None,
1003    );
1004
1005    let terminal = terminal_panel
1006        .update(cx, |terminal_panel, cx| {
1007            terminal_panel.spawn_in_new_terminal(
1008                SpawnInTerminal {
1009                    id: task::TaskId("ssh-remote".into()),
1010                    full_label: "Install zed over ssh".into(),
1011                    label: "Install zed over ssh".into(),
1012                    command,
1013                    args,
1014                    command_label: ssh_connection_string.clone(),
1015                    cwd: None,
1016                    use_new_terminal: true,
1017                    allow_concurrent_runs: false,
1018                    reveal: RevealStrategy::Always,
1019                    hide: HideStrategy::Never,
1020                    env: Default::default(),
1021                    shell: Default::default(),
1022                },
1023                cx,
1024            )
1025        })?
1026        .await?;
1027
1028    terminal
1029        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1030        .await;
1031
1032    // 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.
1033    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1034        == DevServerStatus::Offline
1035    {
1036        cx.background_executor()
1037            .timer(Duration::from_millis(200))
1038            .await
1039    }
1040
1041    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1042        == DevServerStatus::Offline
1043    {
1044        return Err(anyhow!("couldn't reconnect"))?;
1045    }
1046
1047    Ok(())
1048}