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| {
 323                    SshConnectionModal::new(&connection_options, false, cx)
 324                });
 325                let prompt = workspace
 326                    .active_modal::<SshConnectionModal>(cx)
 327                    .unwrap()
 328                    .read(cx)
 329                    .prompt
 330                    .clone();
 331
 332                let connect = connect_over_ssh(
 333                    connection_options.dev_server_identifier(),
 334                    connection_options,
 335                    prompt,
 336                    cx,
 337                )
 338                .prompt_err("Failed to connect", cx, |_, _| None);
 339                cx.spawn(|workspace, mut cx| async move {
 340                    let Some(session) = connect.await else {
 341                        workspace
 342                            .update(&mut cx, |workspace, cx| {
 343                                let weak = cx.view().downgrade();
 344                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 345                            })
 346                            .log_err();
 347                        return;
 348                    };
 349                    let Ok((app_state, project, paths)) =
 350                        workspace.update(&mut cx, |workspace, cx| {
 351                            let app_state = workspace.app_state().clone();
 352                            let project = project::Project::ssh(
 353                                session,
 354                                app_state.client.clone(),
 355                                app_state.node_runtime.clone(),
 356                                app_state.user_store.clone(),
 357                                app_state.languages.clone(),
 358                                app_state.fs.clone(),
 359                                cx,
 360                            );
 361                            let paths = workspace.prompt_for_open_path(
 362                                PathPromptOptions {
 363                                    files: true,
 364                                    directories: true,
 365                                    multiple: true,
 366                                },
 367                                project::DirectoryLister::Project(project.clone()),
 368                                cx,
 369                            );
 370                            (app_state, project, paths)
 371                        })
 372                    else {
 373                        return;
 374                    };
 375
 376                    let Ok(Some(paths)) = paths.await else {
 377                        workspace
 378                            .update(&mut cx, |workspace, cx| {
 379                                let weak = cx.view().downgrade();
 380                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 381                            })
 382                            .log_err();
 383                        return;
 384                    };
 385
 386                    let Some(options) = cx
 387                        .update(|cx| (app_state.build_window_options)(None, cx))
 388                        .log_err()
 389                    else {
 390                        return;
 391                    };
 392
 393                    cx.open_window(options, |cx| {
 394                        cx.activate_window();
 395
 396                        let fs = app_state.fs.clone();
 397                        update_settings_file::<SshSettings>(fs, cx, {
 398                            let paths = paths
 399                                .iter()
 400                                .map(|path| path.to_string_lossy().to_string())
 401                                .collect();
 402                            move |setting, _| {
 403                                if let Some(server) = setting
 404                                    .ssh_connections
 405                                    .as_mut()
 406                                    .and_then(|connections| connections.get_mut(ix))
 407                                {
 408                                    server.projects.push(SshProject { paths })
 409                                }
 410                            }
 411                        });
 412
 413                        let tasks = paths
 414                            .into_iter()
 415                            .map(|path| {
 416                                project.update(cx, |project, cx| {
 417                                    project.find_or_create_worktree(&path, true, cx)
 418                                })
 419                            })
 420                            .collect::<Vec<_>>();
 421                        cx.spawn(|_| async move {
 422                            for task in tasks {
 423                                task.await?;
 424                            }
 425                            Ok(())
 426                        })
 427                        .detach_and_prompt_err(
 428                            "Failed to open path",
 429                            cx,
 430                            |_, _| None,
 431                        );
 432
 433                        cx.new_view(|cx| {
 434                            let workspace =
 435                                Workspace::new(None, project.clone(), app_state.clone(), cx);
 436
 437                            workspace
 438                                .client()
 439                                .telemetry()
 440                                .report_app_event("create ssh project".to_string());
 441
 442                            workspace
 443                        })
 444                    })
 445                    .log_err();
 446                })
 447                .detach()
 448            })
 449        })
 450    }
 451
 452    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 453        match &self.mode {
 454            Mode::Default(None) => {}
 455            Mode::Default(Some(create_project)) => {
 456                self.create_dev_server_project(create_project.dev_server_id, cx);
 457            }
 458            Mode::CreateDevServer(state) => {
 459                if let Some(prompt) = state.ssh_prompt.as_ref() {
 460                    prompt.update(cx, |prompt, cx| {
 461                        prompt.confirm(cx);
 462                    });
 463                    return;
 464                }
 465
 466                self.create_ssh_server(cx);
 467            }
 468        }
 469    }
 470
 471    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 472        match &self.mode {
 473            Mode::Default(None) => cx.emit(DismissEvent),
 474            Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
 475                self.mode = Mode::CreateDevServer(CreateDevServer {
 476                    ..Default::default()
 477                });
 478                cx.notify();
 479            }
 480            _ => {
 481                self.mode = Mode::Default(None);
 482                self.focus_handle(cx).focus(cx);
 483                cx.notify();
 484            }
 485        }
 486    }
 487
 488    fn render_ssh_connection(
 489        &mut self,
 490        ix: usize,
 491        ssh_connection: SshConnection,
 492        cx: &mut ViewContext<Self>,
 493    ) -> impl IntoElement {
 494        v_flex()
 495            .w_full()
 496            .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
 497            .child(
 498                h_flex()
 499                    .w_full()
 500                    .group("ssh-server")
 501                    .justify_between()
 502                    .child(
 503                        h_flex()
 504                            .gap_2()
 505                            .w_full()
 506                            .child(
 507                                div()
 508                                    .id(("status", ix))
 509                                    .relative()
 510                                    .child(Icon::new(IconName::Server).size(IconSize::Small)),
 511                            )
 512                            .child(
 513                                h_flex()
 514                                    .max_w(rems(26.))
 515                                    .overflow_hidden()
 516                                    .whitespace_nowrap()
 517                                    .child(Label::new(ssh_connection.host.clone())),
 518                            ),
 519                    )
 520                    .child(
 521                        h_flex()
 522                            .visible_on_hover("ssh-server")
 523                            .gap_1()
 524                            .child({
 525                                IconButton::new("copy-dev-server-address", IconName::Copy)
 526                                    .icon_size(IconSize::Small)
 527                                    .on_click(cx.listener(move |this, _, cx| {
 528                                        this.update_settings_file(cx, move |servers, cx| {
 529                                            if let Some(content) = servers
 530                                                .ssh_connections
 531                                                .as_ref()
 532                                                .and_then(|connections| {
 533                                                    connections
 534                                                        .get(ix)
 535                                                        .map(|connection| connection.host.clone())
 536                                                })
 537                                            {
 538                                                cx.write_to_clipboard(ClipboardItem::new_string(
 539                                                    content,
 540                                                ));
 541                                            }
 542                                        });
 543                                    }))
 544                                    .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
 545                            })
 546                            .child({
 547                                IconButton::new("remove-dev-server", IconName::TrashAlt)
 548                                    .icon_size(IconSize::Small)
 549                                    .on_click(cx.listener(move |this, _, cx| {
 550                                        this.delete_ssh_server(ix, cx)
 551                                    }))
 552                                    .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
 553                            }),
 554                    ),
 555            )
 556            .child(
 557                v_flex()
 558                    .w_full()
 559                    .border_l_1()
 560                    .border_color(cx.theme().colors().border_variant)
 561                    .mb_1()
 562                    .mx_1p5()
 563                    .pl_2()
 564                    .child(
 565                        List::new()
 566                            .empty_message("No projects.")
 567                            .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 568                                v_flex().gap_0p5().child(self.render_ssh_project(
 569                                    ix,
 570                                    &ssh_connection,
 571                                    pix,
 572                                    p,
 573                                    cx,
 574                                ))
 575                            }))
 576                            .child(
 577                                h_flex().mt_1().pl_1().child(
 578                                    Button::new(("new-remote_project", ix), "Open Folder…")
 579                                        .size(ButtonSize::Default)
 580                                        .layer(ElevationIndex::ModalSurface)
 581                                        .icon(IconName::Plus)
 582                                        .icon_color(Color::Muted)
 583                                        .icon_position(IconPosition::Start)
 584                                        .on_click(cx.listener(move |this, _, cx| {
 585                                            this.create_ssh_project(ix, ssh_connection.clone(), cx);
 586                                        })),
 587                                ),
 588                            ),
 589                    ),
 590            )
 591    }
 592
 593    fn render_ssh_project(
 594        &self,
 595        server_ix: usize,
 596        server: &SshConnection,
 597        ix: usize,
 598        project: &SshProject,
 599        cx: &ViewContext<Self>,
 600    ) -> impl IntoElement {
 601        let project = project.clone();
 602        let server = server.clone();
 603
 604        ListItem::new(("remote-project", ix))
 605            .inset(true)
 606            .spacing(ui::ListItemSpacing::Sparse)
 607            .start_slot(
 608                Icon::new(IconName::Folder)
 609                    .color(Color::Muted)
 610                    .size(IconSize::Small),
 611            )
 612            .child(Label::new(project.paths.join(", ")))
 613            .on_click(cx.listener(move |this, _, cx| {
 614                let Some(app_state) = this
 615                    .workspace
 616                    .update(cx, |workspace, _| workspace.app_state().clone())
 617                    .log_err()
 618                else {
 619                    return;
 620                };
 621                let project = project.clone();
 622                let server = server.clone();
 623                cx.spawn(|_, mut cx| async move {
 624                    let result = open_ssh_project(
 625                        server.into(),
 626                        project.paths.into_iter().map(PathBuf::from).collect(),
 627                        app_state,
 628                        OpenOptions::default(),
 629                        &mut cx,
 630                    )
 631                    .await;
 632                    if let Err(e) = result {
 633                        log::error!("Failed to connect: {:?}", e);
 634                        cx.prompt(
 635                            gpui::PromptLevel::Critical,
 636                            "Failed to connect",
 637                            Some(&e.to_string()),
 638                            &["Ok"],
 639                        )
 640                        .await
 641                        .ok();
 642                    }
 643                })
 644                .detach();
 645            }))
 646            .end_hover_slot::<AnyElement>(Some(
 647                IconButton::new("remove-remote-project", IconName::TrashAlt)
 648                    .on_click(
 649                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
 650                    )
 651                    .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
 652                    .into_any_element(),
 653            ))
 654    }
 655
 656    fn update_settings_file(
 657        &mut self,
 658        cx: &mut ViewContext<Self>,
 659        f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
 660    ) {
 661        let Some(fs) = self
 662            .workspace
 663            .update(cx, |workspace, _| workspace.app_state().fs.clone())
 664            .log_err()
 665        else {
 666            return;
 667        };
 668        update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
 669    }
 670
 671    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
 672        self.update_settings_file(cx, move |setting, _| {
 673            if let Some(connections) = setting.ssh_connections.as_mut() {
 674                connections.remove(server);
 675            }
 676        });
 677    }
 678
 679    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
 680        self.update_settings_file(cx, move |setting, _| {
 681            if let Some(server) = setting
 682                .ssh_connections
 683                .as_mut()
 684                .and_then(|connections| connections.get_mut(server))
 685            {
 686                server.projects.remove(project);
 687            }
 688        });
 689    }
 690
 691    fn add_ssh_server(
 692        &mut self,
 693        connection_options: remote::SshConnectionOptions,
 694        cx: &mut ViewContext<Self>,
 695    ) {
 696        self.update_settings_file(cx, move |setting, _| {
 697            setting
 698                .ssh_connections
 699                .get_or_insert(Default::default())
 700                .push(SshConnection {
 701                    host: connection_options.host,
 702                    username: connection_options.username,
 703                    port: connection_options.port,
 704                    projects: vec![],
 705                })
 706        });
 707    }
 708
 709    fn render_create_dev_server(
 710        &self,
 711        state: &CreateDevServer,
 712        cx: &mut ViewContext<Self>,
 713    ) -> impl IntoElement {
 714        let creating = state.creating.is_some();
 715        let ssh_prompt = state.ssh_prompt.clone();
 716
 717        self.dev_server_name_input.update(cx, |input, cx| {
 718            input.editor().update(cx, |editor, cx| {
 719                if editor.text(cx).is_empty() {
 720                    editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
 721                }
 722            })
 723        });
 724        let theme = cx.theme();
 725
 726        v_flex()
 727            .id("create-dev-server")
 728            .overflow_hidden()
 729            .size_full()
 730            .flex_1()
 731            .child(
 732                h_flex()
 733                    .p_2()
 734                    .gap_2()
 735                    .items_center()
 736                    .border_b_1()
 737                    .border_color(theme.colors().border_variant)
 738                    .child(
 739                        IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
 740                            .shape(IconButtonShape::Square)
 741                            .on_click(|_, cx| {
 742                                cx.dispatch_action(menu::Cancel.boxed_clone());
 743                            }),
 744                    )
 745                    .child(Label::new("Connect New Dev Server")),
 746            )
 747            .child(
 748                v_flex()
 749                    .p_3()
 750                    .border_b_1()
 751                    .border_color(theme.colors().border_variant)
 752                    .child(Label::new("SSH Arguments"))
 753                    .child(
 754                        Label::new("Enter the command you use to SSH into this server.")
 755                            .size(LabelSize::Small)
 756                            .color(Color::Muted),
 757                    )
 758                    .child(
 759                        h_flex()
 760                            .mt_2()
 761                            .w_full()
 762                            .gap_2()
 763                            .child(self.dev_server_name_input.clone())
 764                            .child(
 765                                Button::new("create-dev-server", "Connect Server")
 766                                    .style(ButtonStyle::Filled)
 767                                    .layer(ElevationIndex::ModalSurface)
 768                                    .disabled(creating)
 769                                    .on_click(cx.listener({
 770                                        move |this, _, cx| {
 771                                            this.create_ssh_server(cx);
 772                                        }
 773                                    })),
 774                            ),
 775                    ),
 776            )
 777            .child(
 778                h_flex()
 779                    .bg(theme.colors().editor_background)
 780                    .rounded_b_md()
 781                    .w_full()
 782                    .map(|this| {
 783                        if let Some(ssh_prompt) = ssh_prompt {
 784                            this.child(h_flex().w_full().child(ssh_prompt))
 785                        } else {
 786                            let color = Color::Muted.color(cx);
 787                            this.child(
 788                                h_flex()
 789                                    .p_2()
 790                                    .w_full()
 791                                    .justify_center()
 792                                    .gap_1p5()
 793                                    .child(
 794                                        div().p_1().rounded_lg().bg(color).with_animation(
 795                                            "pulse-ssh-waiting-for-connection",
 796                                            Animation::new(Duration::from_secs(2))
 797                                                .repeat()
 798                                                .with_easing(pulsating_between(0.2, 0.5)),
 799                                            move |this, progress| this.bg(color.opacity(progress)),
 800                                        ),
 801                                    )
 802                                    .child(
 803                                        Label::new("Waiting for connection…")
 804                                            .size(LabelSize::Small),
 805                                    ),
 806                            )
 807                        }
 808                    }),
 809            )
 810    }
 811
 812    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 813        let dev_servers = self.dev_server_store.read(cx).dev_servers();
 814        let ssh_connections = SshSettings::get_global(cx)
 815            .ssh_connections()
 816            .collect::<Vec<_>>();
 817
 818        let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
 819        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
 820            .header(
 821                ModalHeader::new().child(
 822                    h_flex()
 823                        .justify_between()
 824                        .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
 825                        .child(
 826                            Button::new("register-dev-server-button", "Connect New Server")
 827                                .style(ButtonStyle::Filled)
 828                                .layer(ElevationIndex::ModalSurface)
 829                                .icon(IconName::Plus)
 830                                .icon_position(IconPosition::Start)
 831                                .icon_color(Color::Muted)
 832                                .on_click(cx.listener(|this, _, cx| {
 833                                    this.mode = Mode::CreateDevServer(CreateDevServer {
 834                                        ..Default::default()
 835                                    });
 836                                    this.dev_server_name_input.update(cx, |text_field, cx| {
 837                                        text_field.editor().update(cx, |editor, cx| {
 838                                            editor.set_text("", cx);
 839                                        });
 840                                    });
 841                                    cx.notify();
 842                                })),
 843                        ),
 844                ),
 845            )
 846            .section(
 847                Section::new().padded(false).child(
 848                    div()
 849                        .border_y_1()
 850                        .border_color(cx.theme().colors().border_variant)
 851                        .w_full()
 852                        .child(
 853                            div().p_2().child(
 854                                List::new()
 855                                    .empty_message("No dev servers registered yet.")
 856                                    .children(ssh_connections.iter().cloned().enumerate().map(
 857                                        |(ix, connection)| {
 858                                            self.render_ssh_connection(ix, connection, cx)
 859                                                .into_any_element()
 860                                        },
 861                                    )),
 862                            ),
 863                        ),
 864                ),
 865            )
 866            .footer(
 867                ModalFooter::new()
 868                    .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
 869            )
 870    }
 871}
 872
 873fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
 874    element
 875        .read(cx)
 876        .editor()
 877        .read(cx)
 878        .text(cx)
 879        .trim()
 880        .to_string()
 881}
 882
 883impl ModalView for DevServerProjects {}
 884
 885impl FocusableView for DevServerProjects {
 886    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
 887        self.focus_handle.clone()
 888    }
 889}
 890
 891impl EventEmitter<DismissEvent> for DevServerProjects {}
 892
 893impl Render for DevServerProjects {
 894    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
 895        div()
 896            .track_focus(&self.focus_handle)
 897            .elevation_3(cx)
 898            .key_context("DevServerModal")
 899            .on_action(cx.listener(Self::cancel))
 900            .on_action(cx.listener(Self::confirm))
 901            .capture_any_mouse_down(cx.listener(|this, _, cx| {
 902                this.focus_handle(cx).focus(cx);
 903            }))
 904            .on_mouse_down_out(cx.listener(|this, _, cx| {
 905                if matches!(this.mode, Mode::Default(None)) {
 906                    cx.emit(DismissEvent)
 907                }
 908            }))
 909            .w(rems(34.))
 910            .max_h(rems(40.))
 911            .child(match &self.mode {
 912                Mode::Default(_) => self.render_default(cx).into_any_element(),
 913                Mode::CreateDevServer(state) => {
 914                    self.render_create_dev_server(state, cx).into_any_element()
 915                }
 916            })
 917    }
 918}
 919
 920pub fn reconnect_to_dev_server_project(
 921    workspace: View<Workspace>,
 922    dev_server: DevServer,
 923    dev_server_project_id: DevServerProjectId,
 924    replace_current_window: bool,
 925    cx: &mut WindowContext,
 926) -> Task<Result<()>> {
 927    let store = dev_server_projects::Store::global(cx);
 928    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
 929    cx.spawn(|mut cx| async move {
 930        reconnect.await?;
 931
 932        cx.background_executor()
 933            .timer(Duration::from_millis(1000))
 934            .await;
 935
 936        if let Some(project_id) = store.update(&mut cx, |store, _| {
 937            store
 938                .dev_server_project(dev_server_project_id)
 939                .and_then(|p| p.project_id)
 940        })? {
 941            workspace
 942                .update(&mut cx, move |_, cx| {
 943                    open_dev_server_project(
 944                        replace_current_window,
 945                        dev_server_project_id,
 946                        project_id,
 947                        cx,
 948                    )
 949                })?
 950                .await?;
 951        }
 952
 953        Ok(())
 954    })
 955}
 956
 957pub fn reconnect_to_dev_server(
 958    workspace: View<Workspace>,
 959    dev_server: DevServer,
 960    cx: &mut WindowContext,
 961) -> Task<Result<()>> {
 962    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
 963        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
 964    };
 965    let dev_server_store = dev_server_projects::Store::global(cx);
 966    let get_access_token = dev_server_store.update(cx, |store, cx| {
 967        store.regenerate_dev_server_token(dev_server.id, cx)
 968    });
 969
 970    cx.spawn(|mut cx| async move {
 971        let access_token = get_access_token.await?.access_token;
 972
 973        spawn_ssh_task(
 974            workspace,
 975            dev_server_store,
 976            dev_server.id,
 977            ssh_connection_string.to_string(),
 978            access_token,
 979            &mut cx,
 980        )
 981        .await
 982    })
 983}
 984
 985pub async fn spawn_ssh_task(
 986    workspace: View<Workspace>,
 987    dev_server_store: Model<dev_server_projects::Store>,
 988    dev_server_id: DevServerId,
 989    ssh_connection_string: String,
 990    access_token: String,
 991    cx: &mut AsyncWindowContext,
 992) -> Result<()> {
 993    let terminal_panel = workspace
 994        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
 995        .ok()
 996        .flatten()
 997        .with_context(|| anyhow!("No terminal panel"))?;
 998
 999    let command = "sh".to_string();
1000    let args = vec![
1001        "-x".to_string(),
1002        "-c".to_string(),
1003        format!(
1004            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 {}"#,
1005            access_token
1006        ),
1007    ];
1008
1009    let ssh_connection_string = ssh_connection_string.to_string();
1010    let (command, args) = wrap_for_ssh(
1011        &SshCommand::DevServer(ssh_connection_string.clone()),
1012        Some((&command, &args)),
1013        None,
1014        HashMap::default(),
1015        None,
1016    );
1017
1018    let terminal = terminal_panel
1019        .update(cx, |terminal_panel, cx| {
1020            terminal_panel.spawn_in_new_terminal(
1021                SpawnInTerminal {
1022                    id: task::TaskId("ssh-remote".into()),
1023                    full_label: "Install zed over ssh".into(),
1024                    label: "Install zed over ssh".into(),
1025                    command,
1026                    args,
1027                    command_label: ssh_connection_string.clone(),
1028                    cwd: None,
1029                    use_new_terminal: true,
1030                    allow_concurrent_runs: false,
1031                    reveal: RevealStrategy::Always,
1032                    hide: HideStrategy::Never,
1033                    env: Default::default(),
1034                    shell: Default::default(),
1035                },
1036                cx,
1037            )
1038        })?
1039        .await?;
1040
1041    terminal
1042        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1043        .await;
1044
1045    // 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.
1046    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1047        == DevServerStatus::Offline
1048    {
1049        cx.background_executor()
1050            .timer(Duration::from_millis(200))
1051            .await
1052    }
1053
1054    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1055        == DevServerStatus::Offline
1056    {
1057        return Err(anyhow!("couldn't reconnect"))?;
1058    }
1059
1060    Ok(())
1061}