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 client::Client;
   9use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
  10use editor::Editor;
  11use gpui::AsyncWindowContext;
  12use gpui::PathPromptOptions;
  13use gpui::Subscription;
  14use gpui::Task;
  15use gpui::WeakView;
  16use gpui::{
  17    percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
  18    FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
  19};
  20use markdown::Markdown;
  21use markdown::MarkdownStyle;
  22use project::terminals::wrap_for_ssh;
  23use project::terminals::SshCommand;
  24use rpc::proto::RegenerateDevServerTokenResponse;
  25use rpc::{
  26    proto::{CreateDevServerResponse, DevServerStatus},
  27    ErrorCode, ErrorExt,
  28};
  29use settings::update_settings_file;
  30use settings::Settings;
  31use task::HideStrategy;
  32use task::RevealStrategy;
  33use task::SpawnInTerminal;
  34use terminal_view::terminal_panel::TerminalPanel;
  35use ui::ElevationIndex;
  36use ui::Section;
  37use ui::{
  38    prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
  39    RadioWithLabel, Tooltip,
  40};
  41use ui_input::{FieldLabelLayout, TextField};
  42use util::ResultExt;
  43use workspace::OpenOptions;
  44use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
  45
  46use crate::open_dev_server_project;
  47use crate::ssh_connections::connect_over_ssh;
  48use crate::ssh_connections::open_ssh_project;
  49use crate::ssh_connections::RemoteSettingsContent;
  50use crate::ssh_connections::SshConnection;
  51use crate::ssh_connections::SshConnectionModal;
  52use crate::ssh_connections::SshProject;
  53use crate::ssh_connections::SshPrompt;
  54use crate::ssh_connections::SshSettings;
  55use crate::OpenRemote;
  56
  57pub struct DevServerProjects {
  58    mode: Mode,
  59    focus_handle: FocusHandle,
  60    scroll_handle: ScrollHandle,
  61    dev_server_store: Model<dev_server_projects::Store>,
  62    workspace: WeakView<Workspace>,
  63    project_path_input: View<Editor>,
  64    dev_server_name_input: View<TextField>,
  65    markdown: View<Markdown>,
  66    _dev_server_subscription: Subscription,
  67}
  68
  69#[derive(Default)]
  70struct CreateDevServer {
  71    creating: Option<Task<Option<()>>>,
  72    dev_server_id: Option<DevServerId>,
  73    access_token: Option<String>,
  74    ssh_prompt: Option<View<SshPrompt>>,
  75    kind: NewServerKind,
  76}
  77
  78struct CreateDevServerProject {
  79    dev_server_id: DevServerId,
  80    creating: bool,
  81    _opening: Option<Subscription>,
  82}
  83
  84enum Mode {
  85    Default(Option<CreateDevServerProject>),
  86    CreateDevServer(CreateDevServer),
  87}
  88
  89#[derive(Default, PartialEq, Eq, Clone, Copy)]
  90enum NewServerKind {
  91    DirectSSH,
  92    #[default]
  93    LegacySSH,
  94    Manual,
  95}
  96
  97impl DevServerProjects {
  98    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  99        workspace.register_action(|workspace, _: &OpenRemote, cx| {
 100            let handle = cx.view().downgrade();
 101            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 102        });
 103    }
 104
 105    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
 106        workspace.update(cx, |workspace, cx| {
 107            let handle = cx.view().downgrade();
 108            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 109        })
 110    }
 111
 112    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
 113        let project_path_input = cx.new_view(|cx| {
 114            let mut editor = Editor::single_line(cx);
 115            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
 116            editor
 117        });
 118        let dev_server_name_input = cx.new_view(|cx| {
 119            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
 120        });
 121
 122        let focus_handle = cx.focus_handle();
 123        let dev_server_store = dev_server_projects::Store::global(cx);
 124
 125        let subscription = cx.observe(&dev_server_store, |_, _, cx| {
 126            cx.notify();
 127        });
 128
 129        let mut base_style = cx.text_style();
 130        base_style.refine(&gpui::TextStyleRefinement {
 131            color: Some(cx.theme().colors().editor_foreground),
 132            ..Default::default()
 133        });
 134
 135        let markdown_style = MarkdownStyle {
 136            base_text_style: base_style,
 137            code_block: gpui::StyleRefinement {
 138                text: Some(gpui::TextStyleRefinement {
 139                    font_family: Some("Zed Plex Mono".into()),
 140                    ..Default::default()
 141                }),
 142                ..Default::default()
 143            },
 144            link: gpui::TextStyleRefinement {
 145                color: Some(Color::Accent.color(cx)),
 146                ..Default::default()
 147            },
 148            syntax: cx.theme().syntax().clone(),
 149            selection_background_color: cx.theme().players().local().selection,
 150            ..Default::default()
 151        };
 152        let markdown =
 153            cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
 154
 155        Self {
 156            mode: Mode::Default(None),
 157            focus_handle,
 158            scroll_handle: ScrollHandle::new(),
 159            dev_server_store,
 160            project_path_input,
 161            dev_server_name_input,
 162            markdown,
 163            workspace,
 164            _dev_server_subscription: subscription,
 165        }
 166    }
 167
 168    pub fn create_dev_server_project(
 169        &mut self,
 170        dev_server_id: DevServerId,
 171        cx: &mut ViewContext<Self>,
 172    ) {
 173        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
 174
 175        if path.is_empty() {
 176            return;
 177        }
 178
 179        if !path.starts_with('/') && !path.starts_with('~') {
 180            path = format!("~/{}", path);
 181        }
 182
 183        if self
 184            .dev_server_store
 185            .read(cx)
 186            .projects_for_server(dev_server_id)
 187            .iter()
 188            .any(|p| p.paths.iter().any(|p| p == &path))
 189        {
 190            cx.spawn(|_, mut cx| async move {
 191                cx.prompt(
 192                    gpui::PromptLevel::Critical,
 193                    "Failed to create project",
 194                    Some(&format!("{} is already open on this dev server.", path)),
 195                    &["Ok"],
 196                )
 197                .await
 198            })
 199            .detach_and_log_err(cx);
 200            return;
 201        }
 202
 203        let create = {
 204            let path = path.clone();
 205            self.dev_server_store.update(cx, |store, cx| {
 206                store.create_dev_server_project(dev_server_id, path, cx)
 207            })
 208        };
 209
 210        cx.spawn(|this, mut cx| async move {
 211            let result = create.await;
 212            this.update(&mut cx, |this, cx| {
 213                if let Ok(result) = &result {
 214                    if let Some(dev_server_project_id) =
 215                        result.dev_server_project.as_ref().map(|p| p.id)
 216                    {
 217                        let subscription =
 218                            cx.observe(&this.dev_server_store, move |this, store, cx| {
 219                                if let Some(project_id) = store
 220                                    .read(cx)
 221                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
 222                                    .and_then(|p| p.project_id)
 223                                {
 224                                    this.project_path_input.update(cx, |editor, cx| {
 225                                        editor.set_text("", cx);
 226                                    });
 227                                    this.mode = Mode::Default(None);
 228                                    if let Some(app_state) = AppState::global(cx).upgrade() {
 229                                        workspace::join_dev_server_project(
 230                                            DevServerProjectId(dev_server_project_id),
 231                                            project_id,
 232                                            app_state,
 233                                            None,
 234                                            cx,
 235                                        )
 236                                        .detach_and_prompt_err(
 237                                            "Could not join project",
 238                                            cx,
 239                                            |_, _| None,
 240                                        )
 241                                    }
 242                                }
 243                            });
 244
 245                        this.mode = Mode::Default(Some(CreateDevServerProject {
 246                            dev_server_id,
 247                            creating: true,
 248                            _opening: Some(subscription),
 249                        }));
 250                    }
 251                } else {
 252                    this.mode = Mode::Default(Some(CreateDevServerProject {
 253                        dev_server_id,
 254                        creating: false,
 255                        _opening: None,
 256                    }));
 257                }
 258            })
 259            .log_err();
 260            result
 261        })
 262        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
 263            match e.error_code() {
 264                ErrorCode::DevServerOffline => Some(
 265                    "The dev server is offline. Please log in and check it is connected."
 266                        .to_string(),
 267                ),
 268                ErrorCode::DevServerProjectPathDoesNotExist => {
 269                    Some(format!("The path `{}` does not exist on the server.", path))
 270                }
 271                _ => None,
 272            }
 273        });
 274
 275        self.mode = Mode::Default(Some(CreateDevServerProject {
 276            dev_server_id,
 277            creating: true,
 278            _opening: None,
 279        }));
 280    }
 281
 282    fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
 283        let host = get_text(&self.dev_server_name_input, cx);
 284        if host.is_empty() {
 285            return;
 286        }
 287
 288        let mut host = host.trim_start_matches("ssh ");
 289        let mut username: Option<String> = None;
 290        let mut port: Option<u16> = None;
 291
 292        if let Some((u, rest)) = host.split_once('@') {
 293            host = rest;
 294            username = Some(u.to_string());
 295        }
 296        if let Some((rest, p)) = host.split_once(':') {
 297            host = rest;
 298            port = p.parse().ok()
 299        }
 300
 301        if let Some((rest, p)) = host.split_once(" -p") {
 302            host = rest;
 303            port = p.trim().parse().ok()
 304        }
 305
 306        let connection_options = remote::SshConnectionOptions {
 307            host: host.to_string(),
 308            username,
 309            port,
 310            password: None,
 311        };
 312        let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 313        let connection = connect_over_ssh(connection_options.clone(), ssh_prompt.clone(), cx)
 314            .prompt_err("Failed to connect", cx, |_, _| None);
 315
 316        let creating = cx.spawn(move |this, mut cx| async move {
 317            match connection.await {
 318                Some(_) => this
 319                    .update(&mut cx, |this, cx| {
 320                        this.add_ssh_server(connection_options, cx);
 321                        this.mode = Mode::Default(None);
 322                        cx.notify()
 323                    })
 324                    .log_err(),
 325                None => this
 326                    .update(&mut cx, |this, cx| {
 327                        this.mode = Mode::CreateDevServer(CreateDevServer {
 328                            kind: NewServerKind::DirectSSH,
 329                            ..Default::default()
 330                        });
 331                        cx.notify()
 332                    })
 333                    .log_err(),
 334            };
 335            None
 336        });
 337        self.mode = Mode::CreateDevServer(CreateDevServer {
 338            kind: NewServerKind::DirectSSH,
 339            ssh_prompt: Some(ssh_prompt.clone()),
 340            creating: Some(creating),
 341            ..Default::default()
 342        });
 343    }
 344
 345    fn create_ssh_project(
 346        &mut self,
 347        ix: usize,
 348        ssh_connection: SshConnection,
 349        cx: &mut ViewContext<Self>,
 350    ) {
 351        let Some(workspace) = self.workspace.upgrade() else {
 352            return;
 353        };
 354
 355        let connection_options = ssh_connection.into();
 356        workspace.update(cx, |_, cx| {
 357            cx.defer(move |workspace, cx| {
 358                workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
 359                let prompt = workspace
 360                    .active_modal::<SshConnectionModal>(cx)
 361                    .unwrap()
 362                    .read(cx)
 363                    .prompt
 364                    .clone();
 365
 366                let connect = connect_over_ssh(connection_options, prompt, cx).prompt_err(
 367                    "Failed to connect",
 368                    cx,
 369                    |_, _| None,
 370                );
 371                cx.spawn(|workspace, mut cx| async move {
 372                    let Some(session) = connect.await else {
 373                        workspace
 374                            .update(&mut cx, |workspace, cx| {
 375                                let weak = cx.view().downgrade();
 376                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 377                            })
 378                            .log_err();
 379                        return;
 380                    };
 381                    let Ok((app_state, project, paths)) =
 382                        workspace.update(&mut cx, |workspace, cx| {
 383                            let app_state = workspace.app_state().clone();
 384                            let project = project::Project::ssh(
 385                                session,
 386                                app_state.client.clone(),
 387                                app_state.node_runtime.clone(),
 388                                app_state.user_store.clone(),
 389                                app_state.languages.clone(),
 390                                app_state.fs.clone(),
 391                                cx,
 392                            );
 393                            let paths = workspace.prompt_for_open_path(
 394                                PathPromptOptions {
 395                                    files: true,
 396                                    directories: true,
 397                                    multiple: true,
 398                                },
 399                                project::DirectoryLister::Project(project.clone()),
 400                                cx,
 401                            );
 402                            (app_state, project, paths)
 403                        })
 404                    else {
 405                        return;
 406                    };
 407
 408                    let Ok(Some(paths)) = paths.await else {
 409                        workspace
 410                            .update(&mut cx, |workspace, cx| {
 411                                let weak = cx.view().downgrade();
 412                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 413                            })
 414                            .log_err();
 415                        return;
 416                    };
 417
 418                    let Some(options) = cx
 419                        .update(|cx| (app_state.build_window_options)(None, cx))
 420                        .log_err()
 421                    else {
 422                        return;
 423                    };
 424
 425                    cx.open_window(options, |cx| {
 426                        cx.activate_window();
 427
 428                        let fs = app_state.fs.clone();
 429                        update_settings_file::<SshSettings>(fs, cx, {
 430                            let paths = paths
 431                                .iter()
 432                                .map(|path| path.to_string_lossy().to_string())
 433                                .collect();
 434                            move |setting, _| {
 435                                if let Some(server) = setting
 436                                    .ssh_connections
 437                                    .as_mut()
 438                                    .and_then(|connections| connections.get_mut(ix))
 439                                {
 440                                    server.projects.push(SshProject { paths })
 441                                }
 442                            }
 443                        });
 444
 445                        let tasks = paths
 446                            .into_iter()
 447                            .map(|path| {
 448                                project.update(cx, |project, cx| {
 449                                    project.find_or_create_worktree(&path, true, cx)
 450                                })
 451                            })
 452                            .collect::<Vec<_>>();
 453                        cx.spawn(|_| async move {
 454                            for task in tasks {
 455                                task.await?;
 456                            }
 457                            Ok(())
 458                        })
 459                        .detach_and_prompt_err(
 460                            "Failed to open path",
 461                            cx,
 462                            |_, _| None,
 463                        );
 464
 465                        cx.new_view(|cx| {
 466                            Workspace::new(None, project.clone(), app_state.clone(), cx)
 467                        })
 468                    })
 469                    .log_err();
 470                })
 471                .detach()
 472            })
 473        })
 474    }
 475
 476    fn create_or_update_dev_server(
 477        &mut self,
 478        kind: NewServerKind,
 479        existing_id: Option<DevServerId>,
 480        access_token: Option<String>,
 481        cx: &mut ViewContext<Self>,
 482    ) {
 483        let name = get_text(&self.dev_server_name_input, cx);
 484        if name.is_empty() {
 485            return;
 486        }
 487
 488        let manual_setup = match kind {
 489            NewServerKind::DirectSSH => unreachable!(),
 490            NewServerKind::LegacySSH => false,
 491            NewServerKind::Manual => true,
 492        };
 493
 494        let ssh_connection_string = if manual_setup {
 495            None
 496        } else if name.contains(' ') {
 497            Some(name.clone())
 498        } else {
 499            Some(format!("ssh {}", name))
 500        };
 501
 502        let dev_server = self.dev_server_store.update(cx, {
 503            let access_token = access_token.clone();
 504            |store, cx| {
 505                let ssh_connection_string = ssh_connection_string.clone();
 506                if let Some(dev_server_id) = existing_id {
 507                    let rename = store.rename_dev_server(
 508                        dev_server_id,
 509                        name.clone(),
 510                        ssh_connection_string,
 511                        cx,
 512                    );
 513                    let token = if let Some(access_token) = access_token {
 514                        Task::ready(Ok(RegenerateDevServerTokenResponse {
 515                            dev_server_id: dev_server_id.0,
 516                            access_token,
 517                        }))
 518                    } else {
 519                        store.regenerate_dev_server_token(dev_server_id, cx)
 520                    };
 521                    cx.spawn(|_, _| async move {
 522                        rename.await?;
 523                        let response = token.await?;
 524                        Ok(CreateDevServerResponse {
 525                            dev_server_id: dev_server_id.0,
 526                            name,
 527                            access_token: response.access_token,
 528                        })
 529                    })
 530                } else {
 531                    store.create_dev_server(name, ssh_connection_string.clone(), cx)
 532                }
 533            }
 534        });
 535
 536        let workspace = self.workspace.clone();
 537        let store = dev_server_projects::Store::global(cx);
 538
 539        let task = cx
 540            .spawn({
 541                |this, mut cx| async move {
 542                    let result = dev_server.await;
 543
 544                    match result {
 545                        Ok(dev_server) => {
 546                            if let Some(ssh_connection_string) = ssh_connection_string {
 547                                this.update(&mut cx, |this, cx| {
 548                                    if let Mode::CreateDevServer(CreateDevServer {
 549                                        access_token,
 550                                        dev_server_id,
 551                                        ..
 552                                    }) = &mut this.mode
 553                                    {
 554                                        access_token.replace(dev_server.access_token.clone());
 555                                        dev_server_id
 556                                            .replace(DevServerId(dev_server.dev_server_id));
 557                                    }
 558                                    cx.notify();
 559                                })?;
 560
 561                                spawn_ssh_task(
 562                                    workspace
 563                                        .upgrade()
 564                                        .ok_or_else(|| anyhow!("workspace dropped"))?,
 565                                    store,
 566                                    DevServerId(dev_server.dev_server_id),
 567                                    ssh_connection_string,
 568                                    dev_server.access_token.clone(),
 569                                    &mut cx,
 570                                )
 571                                .await
 572                                .log_err();
 573                            }
 574
 575                            this.update(&mut cx, |this, cx| {
 576                                this.focus_handle.focus(cx);
 577                                this.mode = Mode::CreateDevServer(CreateDevServer {
 578                                    dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
 579                                    access_token: Some(dev_server.access_token),
 580                                    kind,
 581                                    ..Default::default()
 582                                });
 583                                cx.notify();
 584                            })?;
 585                            Ok(())
 586                        }
 587                        Err(e) => {
 588                            this.update(&mut cx, |this, cx| {
 589                                this.mode = Mode::CreateDevServer(CreateDevServer {
 590                                    dev_server_id: existing_id,
 591                                    access_token: None,
 592                                    kind,
 593                                    ..Default::default()
 594                                });
 595                                cx.notify()
 596                            })
 597                            .log_err();
 598
 599                            Err(e)
 600                        }
 601                    }
 602                }
 603            })
 604            .prompt_err("Failed to create server", cx, |_, _| None);
 605
 606        self.mode = Mode::CreateDevServer(CreateDevServer {
 607            creating: Some(task),
 608            dev_server_id: existing_id,
 609            access_token,
 610            kind,
 611            ..Default::default()
 612        });
 613        cx.notify()
 614    }
 615
 616    fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
 617        let store = self.dev_server_store.read(cx);
 618        let prompt = if store.projects_for_server(id).is_empty()
 619            && store
 620                .dev_server(id)
 621                .is_some_and(|server| server.status == DevServerStatus::Offline)
 622        {
 623            None
 624        } else {
 625            Some(cx.prompt(
 626                gpui::PromptLevel::Warning,
 627                "Are you sure?",
 628                Some("This will delete the dev server and all of its remote projects."),
 629                &["Delete", "Cancel"],
 630            ))
 631        };
 632
 633        cx.spawn(|this, mut cx| async move {
 634            if let Some(prompt) = prompt {
 635                if prompt.await? != 0 {
 636                    return Ok(());
 637                }
 638            }
 639
 640            let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
 641                this.dev_server_store.update(cx, |store, _| {
 642                    store
 643                        .projects_for_server(id)
 644                        .into_iter()
 645                        .map(|project| project.id)
 646                        .collect()
 647                })
 648            })?;
 649
 650            this.update(&mut cx, |this, cx| {
 651                this.dev_server_store
 652                    .update(cx, |store, cx| store.delete_dev_server(id, cx))
 653            })?
 654            .await?;
 655
 656            for id in project_ids {
 657                WORKSPACE_DB
 658                    .delete_workspace_by_dev_server_project_id(id)
 659                    .await
 660                    .log_err();
 661            }
 662            Ok(())
 663        })
 664        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
 665    }
 666
 667    fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
 668        let answer = cx.prompt(
 669            gpui::PromptLevel::Warning,
 670            "Delete this project?",
 671            Some("This will delete the remote project. You can always re-add it later."),
 672            &["Delete", "Cancel"],
 673        );
 674
 675        cx.spawn(|this, mut cx| async move {
 676            let answer = answer.await?;
 677
 678            if answer != 0 {
 679                return Ok(());
 680            }
 681
 682            this.update(&mut cx, |this, cx| {
 683                this.dev_server_store
 684                    .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
 685            })?
 686            .await?;
 687
 688            WORKSPACE_DB
 689                .delete_workspace_by_dev_server_project_id(id)
 690                .await
 691                .log_err();
 692
 693            Ok(())
 694        })
 695        .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
 696    }
 697
 698    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 699        match &self.mode {
 700            Mode::Default(None) => {}
 701            Mode::Default(Some(create_project)) => {
 702                self.create_dev_server_project(create_project.dev_server_id, cx);
 703            }
 704            Mode::CreateDevServer(state) => {
 705                if let Some(prompt) = state.ssh_prompt.as_ref() {
 706                    prompt.update(cx, |prompt, cx| {
 707                        prompt.confirm(cx);
 708                    });
 709                    return;
 710                }
 711                if state.kind == NewServerKind::DirectSSH {
 712                    self.create_ssh_server(cx);
 713                    return;
 714                }
 715                if state.creating.is_none() || state.dev_server_id.is_some() {
 716                    self.create_or_update_dev_server(
 717                        state.kind,
 718                        state.dev_server_id,
 719                        state.access_token.clone(),
 720                        cx,
 721                    );
 722                }
 723            }
 724        }
 725    }
 726
 727    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 728        match &self.mode {
 729            Mode::Default(None) => cx.emit(DismissEvent),
 730            Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
 731                self.mode = Mode::CreateDevServer(CreateDevServer {
 732                    kind: NewServerKind::DirectSSH,
 733                    ..Default::default()
 734                });
 735                cx.notify();
 736            }
 737            _ => {
 738                self.mode = Mode::Default(None);
 739                self.focus_handle(cx).focus(cx);
 740                cx.notify();
 741            }
 742        }
 743    }
 744
 745    fn render_dev_server(
 746        &mut self,
 747        dev_server: &DevServer,
 748        create_project: Option<bool>,
 749        cx: &mut ViewContext<Self>,
 750    ) -> impl IntoElement {
 751        let dev_server_id = dev_server.id;
 752        let status = dev_server.status;
 753        let dev_server_name = dev_server.name.clone();
 754        let kind = if dev_server.ssh_connection_string.is_some() {
 755            NewServerKind::LegacySSH
 756        } else {
 757            NewServerKind::Manual
 758        };
 759
 760        v_flex()
 761            .w_full()
 762            .child(
 763                h_flex().group("dev-server").justify_between().child(
 764                    h_flex()
 765                        .gap_2()
 766                        .child(
 767                            div()
 768                                .id(("status", dev_server.id.0))
 769                                .relative()
 770                                .child(Icon::new(IconName::Server).size(IconSize::Small))
 771                                .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
 772                                    Indicator::dot().color(match status {
 773                                        DevServerStatus::Online => Color::Created,
 774                                        DevServerStatus::Offline => Color::Hidden,
 775                                    }),
 776                                ))
 777                                .tooltip(move |cx| {
 778                                    Tooltip::text(
 779                                        match status {
 780                                            DevServerStatus::Online => "Online",
 781                                            DevServerStatus::Offline => "Offline",
 782                                        },
 783                                        cx,
 784                                    )
 785                                }),
 786                        )
 787                        .child(
 788                            div()
 789                                .max_w(rems(26.))
 790                                .overflow_hidden()
 791                                .whitespace_nowrap()
 792                                .child(Label::new(dev_server_name.clone())),
 793                        )
 794                        .child(
 795                            h_flex()
 796                                .visible_on_hover("dev-server")
 797                                .gap_1()
 798                                .child(if dev_server.ssh_connection_string.is_some() {
 799                                    let dev_server = dev_server.clone();
 800                                    IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
 801                                        .on_click(cx.listener(move |this, _, cx| {
 802                                            let Some(workspace) = this.workspace.upgrade() else {
 803                                                return;
 804                                            };
 805
 806                                            reconnect_to_dev_server(
 807                                                workspace,
 808                                                dev_server.clone(),
 809                                                cx,
 810                                            )
 811                                            .detach_and_prompt_err(
 812                                                "Failed to reconnect",
 813                                                cx,
 814                                                |_, _| None,
 815                                            );
 816                                        }))
 817                                        .tooltip(|cx| Tooltip::text("Reconnect", cx))
 818                                } else {
 819                                    IconButton::new("edit-dev-server", IconName::Pencil)
 820                                        .on_click(cx.listener(move |this, _, cx| {
 821                                            this.mode = Mode::CreateDevServer(CreateDevServer {
 822                                                dev_server_id: Some(dev_server_id),
 823                                                kind,
 824                                                ..Default::default()
 825                                            });
 826                                            let dev_server_name = dev_server_name.clone();
 827                                            this.dev_server_name_input.update(
 828                                                cx,
 829                                                move |input, cx| {
 830                                                    input.editor().update(cx, move |editor, cx| {
 831                                                        editor.set_text(dev_server_name, cx)
 832                                                    })
 833                                                },
 834                                            )
 835                                        }))
 836                                        .tooltip(|cx| Tooltip::text("Edit dev server", cx))
 837                                })
 838                                .child({
 839                                    let dev_server_id = dev_server.id;
 840                                    IconButton::new("remove-dev-server", IconName::Trash)
 841                                        .on_click(cx.listener(move |this, _, cx| {
 842                                            this.delete_dev_server(dev_server_id, cx)
 843                                        }))
 844                                        .tooltip(|cx| Tooltip::text("Remove dev server", cx))
 845                                }),
 846                        ),
 847                ),
 848            )
 849            .child(
 850                v_flex()
 851                    .w_full()
 852                    .bg(cx.theme().colors().background)
 853                    .border_1()
 854                    .border_color(cx.theme().colors().border_variant)
 855                    .rounded_md()
 856                    .my_1()
 857                    .py_0p5()
 858                    .px_3()
 859                    .child(
 860                        List::new()
 861                            .empty_message("No projects.")
 862                            .children(
 863                                self.dev_server_store
 864                                    .read(cx)
 865                                    .projects_for_server(dev_server.id)
 866                                    .iter()
 867                                    .map(|p| self.render_dev_server_project(p, cx)),
 868                            )
 869                            .when(
 870                                create_project.is_none()
 871                                    && dev_server.status == DevServerStatus::Online,
 872                                |el| {
 873                                    el.child(
 874                                        ListItem::new("new-remote_project")
 875                                            .start_slot(Icon::new(IconName::Plus))
 876                                            .child(Label::new("Open folder…"))
 877                                            .on_click(cx.listener(move |this, _, cx| {
 878                                                this.mode =
 879                                                    Mode::Default(Some(CreateDevServerProject {
 880                                                        dev_server_id,
 881                                                        creating: false,
 882                                                        _opening: None,
 883                                                    }));
 884                                                this.project_path_input
 885                                                    .read(cx)
 886                                                    .focus_handle(cx)
 887                                                    .focus(cx);
 888                                                cx.notify();
 889                                            })),
 890                                    )
 891                                },
 892                            )
 893                            .when_some(create_project, |el, creating| {
 894                                el.child(self.render_create_new_project(creating, cx))
 895                            }),
 896                    ),
 897            )
 898    }
 899
 900    fn render_ssh_connection(
 901        &mut self,
 902        ix: usize,
 903        ssh_connection: SshConnection,
 904        cx: &mut ViewContext<Self>,
 905    ) -> impl IntoElement {
 906        v_flex()
 907            .w_full()
 908            .child(
 909                h_flex().group("ssh-server").justify_between().child(
 910                    h_flex()
 911                        .gap_2()
 912                        .child(
 913                            div()
 914                                .id(("status", ix))
 915                                .relative()
 916                                .child(Icon::new(IconName::Server).size(IconSize::Small)),
 917                        )
 918                        .child(
 919                            div()
 920                                .max_w(rems(26.))
 921                                .overflow_hidden()
 922                                .whitespace_nowrap()
 923                                .child(Label::new(ssh_connection.host.clone())),
 924                        )
 925                        .child(h_flex().visible_on_hover("ssh-server").gap_1().child({
 926                            IconButton::new("remove-dev-server", IconName::Trash)
 927                                .on_click(
 928                                    cx.listener(move |this, _, cx| this.delete_ssh_server(ix, cx)),
 929                                )
 930                                .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
 931                        })),
 932                ),
 933            )
 934            .child(
 935                v_flex()
 936                    .w_full()
 937                    .bg(cx.theme().colors().background)
 938                    .border_1()
 939                    .border_color(cx.theme().colors().border_variant)
 940                    .rounded_md()
 941                    .my_1()
 942                    .py_0p5()
 943                    .px_3()
 944                    .child(
 945                        List::new()
 946                            .empty_message("No projects.")
 947                            .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
 948                                self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
 949                            }))
 950                            .child(
 951                                ListItem::new("new-remote_project")
 952                                    .start_slot(Icon::new(IconName::Plus))
 953                                    .child(Label::new("Open folder…"))
 954                                    .on_click(cx.listener(move |this, _, cx| {
 955                                        this.create_ssh_project(ix, ssh_connection.clone(), cx);
 956                                    })),
 957                            ),
 958                    ),
 959            )
 960    }
 961
 962    fn render_ssh_project(
 963        &self,
 964        server_ix: usize,
 965        server: &SshConnection,
 966        ix: usize,
 967        project: &SshProject,
 968        cx: &ViewContext<Self>,
 969    ) -> impl IntoElement {
 970        let project = project.clone();
 971        let server = server.clone();
 972        ListItem::new(("remote-project", ix))
 973            .start_slot(Icon::new(IconName::FileTree))
 974            .child(Label::new(project.paths.join(", ")))
 975            .on_click(cx.listener(move |this, _, cx| {
 976                let Some(app_state) = this
 977                    .workspace
 978                    .update(cx, |workspace, _| workspace.app_state().clone())
 979                    .log_err()
 980                else {
 981                    return;
 982                };
 983                let project = project.clone();
 984                let server = server.clone();
 985                cx.spawn(|_, mut cx| async move {
 986                    let result = open_ssh_project(
 987                        server.into(),
 988                        project.paths.into_iter().map(PathBuf::from).collect(),
 989                        app_state,
 990                        OpenOptions::default(),
 991                        &mut cx,
 992                    )
 993                    .await;
 994                    if let Err(e) = result {
 995                        log::error!("Failed to connect: {:?}", e);
 996                        cx.prompt(
 997                            gpui::PromptLevel::Critical,
 998                            "Failed to connect",
 999                            Some(&e.to_string()),
1000                            &["Ok"],
1001                        )
1002                        .await
1003                        .ok();
1004                    }
1005                })
1006                .detach();
1007            }))
1008            .end_hover_slot::<AnyElement>(Some(
1009                IconButton::new("remove-remote-project", IconName::Trash)
1010                    .on_click(
1011                        cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
1012                    )
1013                    .tooltip(|cx| Tooltip::text("Delete remote project", cx))
1014                    .into_any_element(),
1015            ))
1016    }
1017
1018    fn update_settings_file(
1019        &mut self,
1020        cx: &mut ViewContext<Self>,
1021        f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
1022    ) {
1023        let Some(fs) = self
1024            .workspace
1025            .update(cx, |workspace, _| workspace.app_state().fs.clone())
1026            .log_err()
1027        else {
1028            return;
1029        };
1030        update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
1031    }
1032
1033    fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
1034        self.update_settings_file(cx, move |setting| {
1035            if let Some(connections) = setting.ssh_connections.as_mut() {
1036                connections.remove(server);
1037            }
1038        });
1039    }
1040
1041    fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
1042        self.update_settings_file(cx, move |setting| {
1043            if let Some(server) = setting
1044                .ssh_connections
1045                .as_mut()
1046                .and_then(|connections| connections.get_mut(server))
1047            {
1048                server.projects.remove(project);
1049            }
1050        });
1051    }
1052
1053    fn add_ssh_server(
1054        &mut self,
1055        connection_options: remote::SshConnectionOptions,
1056        cx: &mut ViewContext<Self>,
1057    ) {
1058        self.update_settings_file(cx, move |setting| {
1059            setting
1060                .ssh_connections
1061                .get_or_insert(Default::default())
1062                .push(SshConnection {
1063                    host: connection_options.host,
1064                    username: connection_options.username,
1065                    port: connection_options.port,
1066                    projects: vec![],
1067                })
1068        });
1069    }
1070
1071    fn render_create_new_project(
1072        &mut self,
1073        creating: bool,
1074        _: &mut ViewContext<Self>,
1075    ) -> impl IntoElement {
1076        ListItem::new("create-remote-project")
1077            .disabled(true)
1078            .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
1079            .child(self.project_path_input.clone())
1080            .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
1081                el.child(
1082                    Icon::new(IconName::ArrowCircle)
1083                        .size(IconSize::Medium)
1084                        .with_animation(
1085                            "arrow-circle",
1086                            Animation::new(Duration::from_secs(2)).repeat(),
1087                            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1088                        ),
1089                )
1090            }))
1091    }
1092
1093    fn render_dev_server_project(
1094        &mut self,
1095        project: &DevServerProject,
1096        cx: &mut ViewContext<Self>,
1097    ) -> impl IntoElement {
1098        let dev_server_project_id = project.id;
1099        let project_id = project.project_id;
1100        let is_online = project_id.is_some();
1101
1102        ListItem::new(("remote-project", dev_server_project_id.0))
1103            .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
1104            .child(
1105                    Label::new(project.paths.join(", "))
1106            )
1107            .on_click(cx.listener(move |_, _, cx| {
1108                if let Some(project_id) = project_id {
1109                    if let Some(app_state) = AppState::global(cx).upgrade() {
1110                        workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
1111                            .detach_and_prompt_err("Could not join project", cx, |_, _| None)
1112                    }
1113                } else {
1114                    cx.spawn(|_, mut cx| async move {
1115                        cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err();
1116                    }).detach();
1117                }
1118            }))
1119            .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
1120                .on_click(cx.listener(move |this, _, cx| {
1121                    this.delete_dev_server_project(dev_server_project_id, cx)
1122                }))
1123                .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
1124    }
1125
1126    fn render_create_dev_server(
1127        &self,
1128        state: &CreateDevServer,
1129        cx: &mut ViewContext<Self>,
1130    ) -> impl IntoElement {
1131        let creating = state.creating.is_some();
1132        let dev_server_id = state.dev_server_id;
1133        let access_token = state.access_token.clone();
1134        let ssh_prompt = state.ssh_prompt.clone();
1135        let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh()
1136            || Client::global(cx).status().borrow().is_signed_out();
1137
1138        let mut kind = state.kind;
1139        if use_direct_ssh && kind == NewServerKind::LegacySSH {
1140            kind = NewServerKind::DirectSSH;
1141        }
1142
1143        let status = dev_server_id
1144            .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
1145            .unwrap_or_default();
1146
1147        let name = self.dev_server_name_input.update(cx, |input, cx| {
1148            input.editor().update(cx, |editor, cx| {
1149                if editor.text(cx).is_empty() {
1150                    match kind {
1151                        NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
1152                        NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
1153                        NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
1154                    }
1155                }
1156                editor.text(cx)
1157            })
1158        });
1159
1160        const MANUAL_SETUP_MESSAGE: &str =
1161            "Generate a token for this server and follow the steps to set Zed up on that machine.";
1162        const SSH_SETUP_MESSAGE: &str =
1163            "Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
1164
1165        Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
1166            .header(
1167                ModalHeader::new()
1168                    .headline("Create Dev Server")
1169                    .show_back_button(true),
1170            )
1171            .section(
1172                Section::new()
1173                    .header(if kind == NewServerKind::Manual {
1174                        "Server Name".into()
1175                    } else {
1176                        "SSH arguments".into()
1177                    })
1178                    .child(
1179                        div()
1180                            .max_w(rems(16.))
1181                            .child(self.dev_server_name_input.clone()),
1182                    ),
1183            )
1184            .section(
1185                Section::new_contained()
1186                    .header("Connection Method".into())
1187                    .child(
1188                        v_flex()
1189                            .w_full()
1190                            .px_2()
1191                            .gap_y(Spacing::Large.rems(cx))
1192                            .when(ssh_prompt.is_none(), |el| {
1193                                el.child(
1194                                    v_flex()
1195                                        .when(use_direct_ssh, |el| {
1196                                            el.child(RadioWithLabel::new(
1197                                                "use-server-name-in-ssh",
1198                                                Label::new("Connect via SSH (default)"),
1199                                                NewServerKind::DirectSSH == kind,
1200                                                cx.listener({
1201                                                    move |this, _, cx| {
1202                                                        if let Mode::CreateDevServer(
1203                                                            CreateDevServer { kind, .. },
1204                                                        ) = &mut this.mode
1205                                                        {
1206                                                            *kind = NewServerKind::DirectSSH;
1207                                                        }
1208                                                        cx.notify()
1209                                                    }
1210                                                }),
1211                                            ))
1212                                        })
1213                                        .when(!use_direct_ssh, |el| {
1214                                            el.child(RadioWithLabel::new(
1215                                                "use-server-name-in-ssh",
1216                                                Label::new("Configure over SSH (default)"),
1217                                                kind == NewServerKind::LegacySSH,
1218                                                cx.listener({
1219                                                    move |this, _, cx| {
1220                                                        if let Mode::CreateDevServer(
1221                                                            CreateDevServer { kind, .. },
1222                                                        ) = &mut this.mode
1223                                                        {
1224                                                            *kind = NewServerKind::LegacySSH;
1225                                                        }
1226                                                        cx.notify()
1227                                                    }
1228                                                }),
1229                                            ))
1230                                        })
1231                                        .child(RadioWithLabel::new(
1232                                            "use-server-name-in-ssh",
1233                                            Label::new("Configure manually"),
1234                                            kind == NewServerKind::Manual,
1235                                            cx.listener({
1236                                                move |this, _, cx| {
1237                                                    if let Mode::CreateDevServer(
1238                                                        CreateDevServer { kind, .. },
1239                                                    ) = &mut this.mode
1240                                                    {
1241                                                        *kind = NewServerKind::Manual;
1242                                                    }
1243                                                    cx.notify()
1244                                                }
1245                                            }),
1246                                        )),
1247                                )
1248                            })
1249                            .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
1250                                el.child(
1251                                    if kind == NewServerKind::Manual {
1252                                        Label::new(MANUAL_SETUP_MESSAGE)
1253                                    } else {
1254                                        Label::new(SSH_SETUP_MESSAGE)
1255                                    }
1256                                    .size(LabelSize::Small)
1257                                    .color(Color::Muted),
1258                                )
1259                            })
1260                            .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
1261                            .when(dev_server_id.is_some() && access_token.is_none(), |el| {
1262                                el.child(
1263                                    if kind == NewServerKind::Manual {
1264                                        Label::new(
1265                                            "Note: updating the dev server generate a new token",
1266                                        )
1267                                    } else {
1268                                        Label::new(SSH_SETUP_MESSAGE)
1269                                    }
1270                                    .size(LabelSize::Small)
1271                                    .color(Color::Muted),
1272                                )
1273                            })
1274                            .when_some(access_token.clone(), {
1275                                |el, access_token| {
1276                                    el.child(self.render_dev_server_token_creating(
1277                                        access_token,
1278                                        name,
1279                                        kind,
1280                                        status,
1281                                        creating,
1282                                        cx,
1283                                    ))
1284                                }
1285                            }),
1286                    ),
1287            )
1288            .footer(
1289                ModalFooter::new().end_slot(if status == DevServerStatus::Online {
1290                    Button::new("create-dev-server", "Done")
1291                        .style(ButtonStyle::Filled)
1292                        .layer(ElevationIndex::ModalSurface)
1293                        .on_click(cx.listener(move |this, _, cx| {
1294                            cx.focus(&this.focus_handle);
1295                            this.mode = Mode::Default(None);
1296                            cx.notify();
1297                        }))
1298                } else {
1299                    Button::new(
1300                        "create-dev-server",
1301                        if kind == NewServerKind::Manual {
1302                            if dev_server_id.is_some() {
1303                                "Update"
1304                            } else {
1305                                "Create"
1306                            }
1307                        } else if dev_server_id.is_some() {
1308                            "Reconnect"
1309                        } else {
1310                            "Connect"
1311                        },
1312                    )
1313                    .style(ButtonStyle::Filled)
1314                    .layer(ElevationIndex::ModalSurface)
1315                    .disabled(creating && dev_server_id.is_none())
1316                    .on_click(cx.listener({
1317                        let access_token = access_token.clone();
1318                        move |this, _, cx| {
1319                            if kind == NewServerKind::DirectSSH {
1320                                this.create_ssh_server(cx);
1321                                return;
1322                            }
1323                            this.create_or_update_dev_server(
1324                                kind,
1325                                dev_server_id,
1326                                access_token.clone(),
1327                                cx,
1328                            );
1329                        }
1330                    }))
1331                }),
1332            )
1333    }
1334
1335    fn render_dev_server_token_creating(
1336        &self,
1337        access_token: String,
1338        dev_server_name: String,
1339        kind: NewServerKind,
1340        status: DevServerStatus,
1341        creating: bool,
1342        cx: &mut ViewContext<Self>,
1343    ) -> Div {
1344        self.markdown.update(cx, |markdown, cx| {
1345            if kind == NewServerKind::Manual {
1346                markdown.reset(format!("Please log into '{}'. If you don't yet have Zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen, to start Zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
1347            } else {
1348                markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using the manual setup.".to_string(), cx);
1349            }
1350        });
1351
1352        v_flex()
1353            .pl_2()
1354            .pt_2()
1355            .gap_2()
1356            .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1357            .map(|el| {
1358                if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1359                {
1360                    el.child(
1361                        h_flex()
1362                            .gap_2()
1363                            .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1364                            .child(Label::new("Not connected")),
1365                    )
1366                } else if status == DevServerStatus::Offline {
1367                    el.child(Self::render_loading_spinner("Waiting for connection…"))
1368                } else {
1369                    el.child(Label::new("🎊 Connection established!"))
1370                }
1371            })
1372    }
1373
1374    fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1375        h_flex()
1376            .gap_2()
1377            .child(
1378                Icon::new(IconName::ArrowCircle)
1379                    .size(IconSize::Medium)
1380                    .with_animation(
1381                        "arrow-circle",
1382                        Animation::new(Duration::from_secs(2)).repeat(),
1383                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1384                    ),
1385            )
1386            .child(Label::new(label))
1387    }
1388
1389    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1390        let dev_servers = self.dev_server_store.read(cx).dev_servers();
1391        let ssh_connections = SshSettings::get_global(cx)
1392            .ssh_connections()
1393            .collect::<Vec<_>>();
1394
1395        let Mode::Default(create_dev_server_project) = &self.mode else {
1396            unreachable!()
1397        };
1398
1399        let mut is_creating = None;
1400        let mut creating_dev_server = None;
1401        if let Some(CreateDevServerProject {
1402            creating,
1403            dev_server_id,
1404            ..
1405        }) = create_dev_server_project
1406        {
1407            is_creating = Some(*creating);
1408            creating_dev_server = Some(*dev_server_id);
1409        };
1410
1411        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1412            .header(
1413                ModalHeader::new()
1414                    .show_dismiss_button(true)
1415                    .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1416            )
1417            .section(
1418                Section::new().child(
1419                    div().child(
1420                        List::new()
1421                            .empty_message("No dev servers registered yet.")
1422                            .header(Some(
1423                                ListHeader::new("Connections").end_slot(
1424                                    Button::new("register-dev-server-button", "Connect New Server")
1425                                        .icon(IconName::Plus)
1426                                        .icon_position(IconPosition::Start)
1427                                        .icon_color(Color::Muted)
1428                                        .on_click(cx.listener(|this, _, cx| {
1429                                            this.mode = Mode::CreateDevServer(CreateDevServer {
1430                                                kind: if SshSettings::get_global(cx)
1431                                                    .use_direct_ssh()
1432                                                {
1433                                                    NewServerKind::DirectSSH
1434                                                } else {
1435                                                    NewServerKind::LegacySSH
1436                                                },
1437                                                ..Default::default()
1438                                            });
1439                                            this.dev_server_name_input.update(
1440                                                cx,
1441                                                |text_field, cx| {
1442                                                    text_field.editor().update(cx, |editor, cx| {
1443                                                        editor.set_text("", cx);
1444                                                    });
1445                                                },
1446                                            );
1447                                            cx.notify();
1448                                        })),
1449                                ),
1450                            ))
1451                            .children(ssh_connections.iter().cloned().enumerate().map(
1452                                |(ix, connection)| {
1453                                    self.render_ssh_connection(ix, connection, cx)
1454                                        .into_any_element()
1455                                },
1456                            ))
1457                            .children(dev_servers.iter().map(|dev_server| {
1458                                let creating = if creating_dev_server == Some(dev_server.id) {
1459                                    is_creating
1460                                } else {
1461                                    None
1462                                };
1463                                self.render_dev_server(dev_server, creating, cx)
1464                                    .into_any_element()
1465                            })),
1466                    ),
1467                ),
1468            )
1469    }
1470}
1471
1472fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1473    element
1474        .read(cx)
1475        .editor()
1476        .read(cx)
1477        .text(cx)
1478        .trim()
1479        .to_string()
1480}
1481
1482impl ModalView for DevServerProjects {}
1483
1484impl FocusableView for DevServerProjects {
1485    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1486        self.focus_handle.clone()
1487    }
1488}
1489
1490impl EventEmitter<DismissEvent> for DevServerProjects {}
1491
1492impl Render for DevServerProjects {
1493    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1494        div()
1495            .track_focus(&self.focus_handle)
1496            .p_2()
1497            .elevation_3(cx)
1498            .key_context("DevServerModal")
1499            .on_action(cx.listener(Self::cancel))
1500            .on_action(cx.listener(Self::confirm))
1501            .capture_any_mouse_down(cx.listener(|this, _, cx| {
1502                this.focus_handle(cx).focus(cx);
1503            }))
1504            .on_mouse_down_out(cx.listener(|this, _, cx| {
1505                if matches!(this.mode, Mode::Default(None)) {
1506                    cx.emit(DismissEvent)
1507                }
1508            }))
1509            .w(rems(34.))
1510            .max_h(rems(40.))
1511            .child(match &self.mode {
1512                Mode::Default(_) => self.render_default(cx).into_any_element(),
1513                Mode::CreateDevServer(state) => {
1514                    self.render_create_dev_server(state, cx).into_any_element()
1515                }
1516            })
1517    }
1518}
1519
1520pub fn reconnect_to_dev_server_project(
1521    workspace: View<Workspace>,
1522    dev_server: DevServer,
1523    dev_server_project_id: DevServerProjectId,
1524    replace_current_window: bool,
1525    cx: &mut WindowContext,
1526) -> Task<Result<()>> {
1527    let store = dev_server_projects::Store::global(cx);
1528    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1529    cx.spawn(|mut cx| async move {
1530        reconnect.await?;
1531
1532        cx.background_executor()
1533            .timer(Duration::from_millis(1000))
1534            .await;
1535
1536        if let Some(project_id) = store.update(&mut cx, |store, _| {
1537            store
1538                .dev_server_project(dev_server_project_id)
1539                .and_then(|p| p.project_id)
1540        })? {
1541            workspace
1542                .update(&mut cx, move |_, cx| {
1543                    open_dev_server_project(
1544                        replace_current_window,
1545                        dev_server_project_id,
1546                        project_id,
1547                        cx,
1548                    )
1549                })?
1550                .await?;
1551        }
1552
1553        Ok(())
1554    })
1555}
1556
1557pub fn reconnect_to_dev_server(
1558    workspace: View<Workspace>,
1559    dev_server: DevServer,
1560    cx: &mut WindowContext,
1561) -> Task<Result<()>> {
1562    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1563        return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1564    };
1565    let dev_server_store = dev_server_projects::Store::global(cx);
1566    let get_access_token = dev_server_store.update(cx, |store, cx| {
1567        store.regenerate_dev_server_token(dev_server.id, cx)
1568    });
1569
1570    cx.spawn(|mut cx| async move {
1571        let access_token = get_access_token.await?.access_token;
1572
1573        spawn_ssh_task(
1574            workspace,
1575            dev_server_store,
1576            dev_server.id,
1577            ssh_connection_string.to_string(),
1578            access_token,
1579            &mut cx,
1580        )
1581        .await
1582    })
1583}
1584
1585pub async fn spawn_ssh_task(
1586    workspace: View<Workspace>,
1587    dev_server_store: Model<dev_server_projects::Store>,
1588    dev_server_id: DevServerId,
1589    ssh_connection_string: String,
1590    access_token: String,
1591    cx: &mut AsyncWindowContext,
1592) -> Result<()> {
1593    let terminal_panel = workspace
1594        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1595        .ok()
1596        .flatten()
1597        .with_context(|| anyhow!("No terminal panel"))?;
1598
1599    let command = "sh".to_string();
1600    let args = vec![
1601        "-x".to_string(),
1602        "-c".to_string(),
1603        format!(
1604            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 {}"#,
1605            access_token
1606        ),
1607    ];
1608
1609    let ssh_connection_string = ssh_connection_string.to_string();
1610    let (command, args) = wrap_for_ssh(
1611        &SshCommand::DevServer(ssh_connection_string.clone()),
1612        Some((&command, &args)),
1613        None,
1614        HashMap::default(),
1615        None,
1616    );
1617
1618    let terminal = terminal_panel
1619        .update(cx, |terminal_panel, cx| {
1620            terminal_panel.spawn_in_new_terminal(
1621                SpawnInTerminal {
1622                    id: task::TaskId("ssh-remote".into()),
1623                    full_label: "Install zed over ssh".into(),
1624                    label: "Install zed over ssh".into(),
1625                    command,
1626                    args,
1627                    command_label: ssh_connection_string.clone(),
1628                    cwd: None,
1629                    use_new_terminal: true,
1630                    allow_concurrent_runs: false,
1631                    reveal: RevealStrategy::Always,
1632                    hide: HideStrategy::Never,
1633                    env: Default::default(),
1634                    shell: Default::default(),
1635                },
1636                cx,
1637            )
1638        })?
1639        .await?;
1640
1641    terminal
1642        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1643        .await;
1644
1645    // 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.
1646    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1647        == DevServerStatus::Offline
1648    {
1649        cx.background_executor()
1650            .timer(Duration::from_millis(200))
1651            .await
1652    }
1653
1654    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1655        == DevServerStatus::Offline
1656    {
1657        return Err(anyhow!("couldn't reconnect"))?;
1658    }
1659
1660    Ok(())
1661}