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