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