dev_servers.rs

   1use std::path::PathBuf;
   2use std::time::Duration;
   3
   4use anyhow::anyhow;
   5use anyhow::Context;
   6use anyhow::Result;
   7use client::Client;
   8use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
   9use editor::Editor;
  10use gpui::AsyncWindowContext;
  11use gpui::PathPromptOptions;
  12use gpui::Subscription;
  13use gpui::Task;
  14use gpui::WeakView;
  15use gpui::{
  16    percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
  17    FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
  18};
  19use markdown::Markdown;
  20use markdown::MarkdownStyle;
  21use rpc::proto::RegenerateDevServerTokenResponse;
  22use rpc::{
  23    proto::{CreateDevServerResponse, DevServerStatus},
  24    ErrorCode, ErrorExt,
  25};
  26use settings::update_settings_file;
  27use settings::Settings;
  28use task::HideStrategy;
  29use task::RevealStrategy;
  30use task::SpawnInTerminal;
  31use task::TerminalWorkDir;
  32use terminal_view::terminal_panel::TerminalPanel;
  33use ui::ElevationIndex;
  34use ui::Section;
  35use ui::{
  36    prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
  37    RadioWithLabel, Tooltip,
  38};
  39use ui_input::{FieldLabelLayout, TextField};
  40use util::paths::PathLikeWithPosition;
  41use util::ResultExt;
  42use workspace::notifications::NotifyResultExt;
  43use workspace::OpenOptions;
  44use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
  45
  46use crate::open_dev_server_project;
  47use crate::ssh_connections::connect_over_ssh;
  48use crate::ssh_connections::open_ssh_project;
  49use crate::ssh_connections::RemoteSettingsContent;
  50use crate::ssh_connections::SshConnection;
  51use crate::ssh_connections::SshConnectionModal;
  52use crate::ssh_connections::SshProject;
  53use crate::ssh_connections::SshPrompt;
  54use crate::ssh_connections::SshSettings;
  55use crate::OpenRemote;
  56
  57pub struct DevServerProjects {
  58    mode: Mode,
  59    focus_handle: FocusHandle,
  60    scroll_handle: ScrollHandle,
  61    dev_server_store: Model<dev_server_projects::Store>,
  62    workspace: WeakView<Workspace>,
  63    project_path_input: View<Editor>,
  64    dev_server_name_input: View<TextField>,
  65    markdown: View<Markdown>,
  66    _dev_server_subscription: Subscription,
  67}
  68
  69#[derive(Default)]
  70struct CreateDevServer {
  71    creating: Option<Task<Option<()>>>,
  72    dev_server_id: Option<DevServerId>,
  73    access_token: Option<String>,
  74    ssh_prompt: Option<View<SshPrompt>>,
  75    kind: NewServerKind,
  76}
  77
  78struct CreateDevServerProject {
  79    dev_server_id: DevServerId,
  80    creating: bool,
  81    _opening: Option<Subscription>,
  82}
  83
  84enum Mode {
  85    Default(Option<CreateDevServerProject>),
  86    CreateDevServer(CreateDevServer),
  87}
  88
  89#[derive(Default, PartialEq, Eq, Clone, Copy)]
  90enum NewServerKind {
  91    DirectSSH,
  92    #[default]
  93    LegacySSH,
  94    Manual,
  95}
  96
  97impl DevServerProjects {
  98    pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
  99        workspace.register_action(|workspace, _: &OpenRemote, cx| {
 100            let handle = cx.view().downgrade();
 101            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 102        });
 103    }
 104
 105    pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
 106        workspace.update(cx, |workspace, cx| {
 107            let handle = cx.view().downgrade();
 108            workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
 109        })
 110    }
 111
 112    pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
 113        let project_path_input = cx.new_view(|cx| {
 114            let mut editor = Editor::single_line(cx);
 115            editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
 116            editor
 117        });
 118        let dev_server_name_input = cx.new_view(|cx| {
 119            TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
 120        });
 121
 122        let focus_handle = cx.focus_handle();
 123        let dev_server_store = dev_server_projects::Store::global(cx);
 124
 125        let subscription = cx.observe(&dev_server_store, |_, _, cx| {
 126            cx.notify();
 127        });
 128
 129        let mut base_style = cx.text_style();
 130        base_style.refine(&gpui::TextStyleRefinement {
 131            color: Some(cx.theme().colors().editor_foreground),
 132            ..Default::default()
 133        });
 134
 135        let markdown_style = MarkdownStyle {
 136            base_text_style: base_style,
 137            code_block: gpui::StyleRefinement {
 138                text: Some(gpui::TextStyleRefinement {
 139                    font_family: Some("Zed Plex Mono".into()),
 140                    ..Default::default()
 141                }),
 142                ..Default::default()
 143            },
 144            link: gpui::TextStyleRefinement {
 145                color: Some(Color::Accent.color(cx)),
 146                ..Default::default()
 147            },
 148            syntax: cx.theme().syntax().clone(),
 149            selection_background_color: cx.theme().players().local().selection,
 150            ..Default::default()
 151        };
 152        let markdown =
 153            cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
 154
 155        Self {
 156            mode: Mode::Default(None),
 157            focus_handle,
 158            scroll_handle: ScrollHandle::new(),
 159            dev_server_store,
 160            project_path_input,
 161            dev_server_name_input,
 162            markdown,
 163            workspace,
 164            _dev_server_subscription: subscription,
 165        }
 166    }
 167
 168    pub fn create_dev_server_project(
 169        &mut self,
 170        dev_server_id: DevServerId,
 171        cx: &mut ViewContext<Self>,
 172    ) {
 173        let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
 174
 175        if path == "" {
 176            return;
 177        }
 178
 179        if !path.starts_with('/') && !path.starts_with('~') {
 180            path = format!("~/{}", path);
 181        }
 182
 183        if self
 184            .dev_server_store
 185            .read(cx)
 186            .projects_for_server(dev_server_id)
 187            .iter()
 188            .any(|p| p.paths.iter().any(|p| p == &path))
 189        {
 190            cx.spawn(|_, mut cx| async move {
 191                cx.prompt(
 192                    gpui::PromptLevel::Critical,
 193                    "Failed to create project",
 194                    Some(&format!("{} is already open on this dev server.", path)),
 195                    &["Ok"],
 196                )
 197                .await
 198            })
 199            .detach_and_log_err(cx);
 200            return;
 201        }
 202
 203        let create = {
 204            let path = path.clone();
 205            self.dev_server_store.update(cx, |store, cx| {
 206                store.create_dev_server_project(dev_server_id, path, cx)
 207            })
 208        };
 209
 210        cx.spawn(|this, mut cx| async move {
 211            let result = create.await;
 212            this.update(&mut cx, |this, cx| {
 213                if let Ok(result) = &result {
 214                    if let Some(dev_server_project_id) =
 215                        result.dev_server_project.as_ref().map(|p| p.id)
 216                    {
 217                        let subscription =
 218                            cx.observe(&this.dev_server_store, move |this, store, cx| {
 219                                if let Some(project_id) = store
 220                                    .read(cx)
 221                                    .dev_server_project(DevServerProjectId(dev_server_project_id))
 222                                    .and_then(|p| p.project_id)
 223                                {
 224                                    this.project_path_input.update(cx, |editor, cx| {
 225                                        editor.set_text("", cx);
 226                                    });
 227                                    this.mode = Mode::Default(None);
 228                                    if let Some(app_state) = AppState::global(cx).upgrade() {
 229                                        workspace::join_dev_server_project(
 230                                            DevServerProjectId(dev_server_project_id),
 231                                            project_id,
 232                                            app_state,
 233                                            None,
 234                                            cx,
 235                                        )
 236                                        .detach_and_prompt_err(
 237                                            "Could not join project",
 238                                            cx,
 239                                            |_, _| None,
 240                                        )
 241                                    }
 242                                }
 243                            });
 244
 245                        this.mode = Mode::Default(Some(CreateDevServerProject {
 246                            dev_server_id,
 247                            creating: true,
 248                            _opening: Some(subscription),
 249                        }));
 250                    }
 251                } else {
 252                    this.mode = Mode::Default(Some(CreateDevServerProject {
 253                        dev_server_id,
 254                        creating: false,
 255                        _opening: None,
 256                    }));
 257                }
 258            })
 259            .log_err();
 260            result
 261        })
 262        .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
 263            match e.error_code() {
 264                ErrorCode::DevServerOffline => Some(
 265                    "The dev server is offline. Please log in and check it is connected."
 266                        .to_string(),
 267                ),
 268                ErrorCode::DevServerProjectPathDoesNotExist => {
 269                    Some(format!("The path `{}` does not exist on the server.", path))
 270                }
 271                _ => None,
 272            }
 273        });
 274
 275        self.mode = Mode::Default(Some(CreateDevServerProject {
 276            dev_server_id,
 277            creating: true,
 278            _opening: None,
 279        }));
 280    }
 281
 282    fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
 283        let host = get_text(&self.dev_server_name_input, cx);
 284        if host.is_empty() {
 285            return;
 286        }
 287
 288        let mut host = host.trim_start_matches("ssh ");
 289        let mut username: Option<String> = None;
 290        let mut port: Option<u16> = None;
 291
 292        if let Some((u, rest)) = host.split_once('@') {
 293            host = rest;
 294            username = Some(u.to_string());
 295        }
 296        if let Some((rest, p)) = host.split_once(':') {
 297            host = rest;
 298            port = p.parse().ok()
 299        }
 300
 301        if let Some((rest, p)) = host.split_once(" -p") {
 302            host = rest;
 303            port = p.trim().parse().ok()
 304        }
 305
 306        let connection_options = remote::SshConnectionOptions {
 307            host: host.to_string(),
 308            username,
 309            port,
 310            password: None,
 311        };
 312        let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
 313        let connection = connect_over_ssh(connection_options.clone(), ssh_prompt.clone(), cx)
 314            .prompt_err("Failed to connect", cx, |_, _| None);
 315
 316        let creating = cx.spawn(move |this, mut cx| async move {
 317            match connection.await {
 318                Some(_) => this
 319                    .update(&mut cx, |this, cx| {
 320                        this.add_ssh_server(connection_options, cx);
 321                        this.mode = Mode::Default(None);
 322                        cx.notify()
 323                    })
 324                    .log_err(),
 325                None => this
 326                    .update(&mut cx, |this, cx| {
 327                        this.mode = Mode::CreateDevServer(CreateDevServer {
 328                            kind: NewServerKind::DirectSSH,
 329                            ..Default::default()
 330                        });
 331                        cx.notify()
 332                    })
 333                    .log_err(),
 334            };
 335            None
 336        });
 337        self.mode = Mode::CreateDevServer(CreateDevServer {
 338            kind: NewServerKind::DirectSSH,
 339            ssh_prompt: Some(ssh_prompt.clone()),
 340            creating: Some(creating),
 341            ..Default::default()
 342        });
 343    }
 344
 345    fn create_ssh_project(
 346        &mut self,
 347        ix: usize,
 348        ssh_connection: SshConnection,
 349        cx: &mut ViewContext<Self>,
 350    ) {
 351        let Some(workspace) = self.workspace.upgrade() else {
 352            return;
 353        };
 354
 355        let connection_options = ssh_connection.into();
 356        workspace.update(cx, |_, cx| {
 357            cx.defer(move |workspace, cx| {
 358                workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
 359                let prompt = workspace
 360                    .active_modal::<SshConnectionModal>(cx)
 361                    .unwrap()
 362                    .read(cx)
 363                    .prompt
 364                    .clone();
 365
 366                let connect = connect_over_ssh(connection_options, prompt, cx).prompt_err(
 367                    "Failed to connect",
 368                    cx,
 369                    |_, _| None,
 370                );
 371                cx.spawn(|workspace, mut cx| async move {
 372                    let Some(session) = connect.await else {
 373                        workspace
 374                            .update(&mut cx, |workspace, cx| {
 375                                let weak = cx.view().downgrade();
 376                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 377                            })
 378                            .log_err();
 379                        return;
 380                    };
 381                    let Ok((app_state, project, paths)) =
 382                        workspace.update(&mut cx, |workspace, cx| {
 383                            let app_state = workspace.app_state().clone();
 384                            let project = project::Project::ssh(
 385                                session,
 386                                app_state.client.clone(),
 387                                app_state.node_runtime.clone(),
 388                                app_state.user_store.clone(),
 389                                app_state.languages.clone(),
 390                                app_state.fs.clone(),
 391                                cx,
 392                            );
 393                            let paths = workspace.prompt_for_open_path(
 394                                PathPromptOptions {
 395                                    files: true,
 396                                    directories: true,
 397                                    multiple: true,
 398                                },
 399                                project::DirectoryLister::Project(project.clone()),
 400                                cx,
 401                            );
 402                            (app_state, project, paths)
 403                        })
 404                    else {
 405                        return;
 406                    };
 407
 408                    let Ok(Some(paths)) = paths.await else {
 409                        workspace
 410                            .update(&mut cx, |workspace, cx| {
 411                                let weak = cx.view().downgrade();
 412                                workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
 413                            })
 414                            .log_err();
 415                        return;
 416                    };
 417
 418                    let Some(options) = cx
 419                        .update(|cx| (app_state.build_window_options)(None, cx))
 420                        .log_err()
 421                    else {
 422                        return;
 423                    };
 424
 425                    cx.open_window(options, |cx| {
 426                        cx.activate_window();
 427
 428                        let fs = app_state.fs.clone();
 429                        update_settings_file::<SshSettings>(fs, cx, {
 430                            let paths = paths
 431                                .iter()
 432                                .map(|path| path.to_string_lossy().to_string())
 433                                .collect();
 434                            move |setting, _| {
 435                                if let Some(server) = setting
 436                                    .ssh_connections
 437                                    .as_mut()
 438                                    .and_then(|connections| connections.get_mut(ix))
 439                                {
 440                                    server.projects.push(SshProject { paths })
 441                                }
 442                            }
 443                        });
 444
 445                        let tasks = paths
 446                            .into_iter()
 447                            .map(|path| {
 448                                project.update(cx, |project, cx| {
 449                                    project.find_or_create_worktree(&path, true, cx)
 450                                })
 451                            })
 452                            .collect::<Vec<_>>();
 453                        cx.spawn(|_| async move {
 454                            for task in tasks {
 455                                task.await?;
 456                            }
 457                            Ok(())
 458                        })
 459                        .detach_and_prompt_err(
 460                            "Failed to open path",
 461                            cx,
 462                            |_, _| None,
 463                        );
 464
 465                        cx.new_view(|cx| {
 466                            Workspace::new(None, project.clone(), app_state.clone(), cx)
 467                        })
 468                    })
 469                    .log_err();
 470                })
 471                .detach()
 472            })
 473        })
 474    }
 475
 476    fn create_or_update_dev_server(
 477        &mut self,
 478        kind: NewServerKind,
 479        existing_id: Option<DevServerId>,
 480        access_token: Option<String>,
 481        cx: &mut ViewContext<Self>,
 482    ) {
 483        let name = get_text(&self.dev_server_name_input, cx);
 484        if name.is_empty() {
 485            return;
 486        }
 487
 488        let manual_setup = match kind {
 489            NewServerKind::DirectSSH => unreachable!(),
 490            NewServerKind::LegacySSH => false,
 491            NewServerKind::Manual => true,
 492        };
 493
 494        let ssh_connection_string = if manual_setup {
 495            None
 496        } else if name.contains(' ') {
 497            Some(name.clone())
 498        } else {
 499            Some(format!("ssh {}", name))
 500        };
 501
 502        let dev_server = self.dev_server_store.update(cx, {
 503            let access_token = access_token.clone();
 504            |store, cx| {
 505                let ssh_connection_string = ssh_connection_string.clone();
 506                if let Some(dev_server_id) = existing_id {
 507                    let rename = store.rename_dev_server(
 508                        dev_server_id,
 509                        name.clone(),
 510                        ssh_connection_string,
 511                        cx,
 512                    );
 513                    let token = if let Some(access_token) = access_token {
 514                        Task::ready(Ok(RegenerateDevServerTokenResponse {
 515                            dev_server_id: dev_server_id.0,
 516                            access_token,
 517                        }))
 518                    } else {
 519                        store.regenerate_dev_server_token(dev_server_id, cx)
 520                    };
 521                    cx.spawn(|_, _| async move {
 522                        rename.await?;
 523                        let response = token.await?;
 524                        Ok(CreateDevServerResponse {
 525                            dev_server_id: dev_server_id.0,
 526                            name,
 527                            access_token: response.access_token,
 528                        })
 529                    })
 530                } else {
 531                    store.create_dev_server(name, ssh_connection_string.clone(), cx)
 532                }
 533            }
 534        });
 535
 536        let workspace = self.workspace.clone();
 537        let store = dev_server_projects::Store::global(cx);
 538
 539        let task = cx
 540            .spawn({
 541                |this, mut cx| async move {
 542                    let result = dev_server.await;
 543
 544                    match result {
 545                        Ok(dev_server) => {
 546                            if let Some(ssh_connection_string) = ssh_connection_string {
 547                                this.update(&mut cx, |this, cx| {
 548                                    if let Mode::CreateDevServer(CreateDevServer {
 549                                        access_token,
 550                                        dev_server_id,
 551                                        ..
 552                                    }) = &mut this.mode
 553                                    {
 554                                        access_token.replace(dev_server.access_token.clone());
 555                                        dev_server_id
 556                                            .replace(DevServerId(dev_server.dev_server_id));
 557                                    }
 558                                    cx.notify();
 559                                })?;
 560
 561                                spawn_ssh_task(
 562                                    workspace
 563                                        .upgrade()
 564                                        .ok_or_else(|| anyhow!("workspace dropped"))?,
 565                                    store,
 566                                    DevServerId(dev_server.dev_server_id),
 567                                    ssh_connection_string,
 568                                    dev_server.access_token.clone(),
 569                                    &mut cx,
 570                                )
 571                                .await
 572                                .log_err();
 573                            }
 574
 575                            this.update(&mut cx, |this, cx| {
 576                                this.focus_handle.focus(cx);
 577                                this.mode = Mode::CreateDevServer(CreateDevServer {
 578                                    dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
 579                                    access_token: Some(dev_server.access_token),
 580                                    kind,
 581                                    ..Default::default()
 582                                });
 583                                cx.notify();
 584                            })?;
 585                            Ok(())
 586                        }
 587                        Err(e) => {
 588                            this.update(&mut cx, |this, cx| {
 589                                this.mode = Mode::CreateDevServer(CreateDevServer {
 590                                    dev_server_id: existing_id,
 591                                    access_token: None,
 592                                    kind,
 593                                    ..Default::default()
 594                                });
 595                                cx.notify()
 596                            })
 597                            .log_err();
 598
 599                            return Err(e);
 600                        }
 601                    }
 602                }
 603            })
 604            .prompt_err("Failed to create server", cx, |_, _| None);
 605
 606        self.mode = Mode::CreateDevServer(CreateDevServer {
 607            creating: Some(task),
 608            dev_server_id: existing_id,
 609            access_token,
 610            kind,
 611            ..Default::default()
 612        });
 613        cx.notify()
 614    }
 615
 616    fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
 617        let store = self.dev_server_store.read(cx);
 618        let prompt = if store.projects_for_server(id).is_empty()
 619            && store
 620                .dev_server(id)
 621                .is_some_and(|server| server.status == DevServerStatus::Offline)
 622        {
 623            None
 624        } else {
 625            Some(cx.prompt(
 626                gpui::PromptLevel::Warning,
 627                "Are you sure?",
 628                Some("This will delete the dev server and all of its remote projects."),
 629                &["Delete", "Cancel"],
 630            ))
 631        };
 632
 633        cx.spawn(|this, mut cx| async move {
 634            if let Some(prompt) = prompt {
 635                if prompt.await? != 0 {
 636                    return Ok(());
 637                }
 638            }
 639
 640            let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
 641                this.dev_server_store.update(cx, |store, _| {
 642                    store
 643                        .projects_for_server(id)
 644                        .into_iter()
 645                        .map(|project| project.id)
 646                        .collect()
 647                })
 648            })?;
 649
 650            this.update(&mut cx, |this, cx| {
 651                this.dev_server_store
 652                    .update(cx, |store, cx| store.delete_dev_server(id, cx))
 653            })?
 654            .await?;
 655
 656            for id in project_ids {
 657                WORKSPACE_DB
 658                    .delete_workspace_by_dev_server_project_id(id)
 659                    .await
 660                    .log_err();
 661            }
 662            Ok(())
 663        })
 664        .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
 665    }
 666
 667    fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
 668        let answer = cx.prompt(
 669            gpui::PromptLevel::Warning,
 670            "Delete this project?",
 671            Some("This will delete the remote project. You can always re-add it later."),
 672            &["Delete", "Cancel"],
 673        );
 674
 675        cx.spawn(|this, mut cx| async move {
 676            let answer = answer.await?;
 677
 678            if answer != 0 {
 679                return Ok(());
 680            }
 681
 682            this.update(&mut cx, |this, cx| {
 683                this.dev_server_store
 684                    .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
 685            })?
 686            .await?;
 687
 688            WORKSPACE_DB
 689                .delete_workspace_by_dev_server_project_id(id)
 690                .await
 691                .log_err();
 692
 693            Ok(())
 694        })
 695        .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
 696    }
 697
 698    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
 699        match &self.mode {
 700            Mode::Default(None) => {}
 701            Mode::Default(Some(create_project)) => {
 702                self.create_dev_server_project(create_project.dev_server_id, cx);
 703            }
 704            Mode::CreateDevServer(state) => {
 705                if let Some(prompt) = state.ssh_prompt.as_ref() {
 706                    prompt.update(cx, |prompt, cx| {
 707                        prompt.confirm(cx);
 708                    });
 709                    return;
 710                }
 711                if state.kind == NewServerKind::DirectSSH {
 712                    self.create_ssh_server(cx);
 713                    return;
 714                }
 715                if state.creating.is_none() || state.dev_server_id.is_some() {
 716                    self.create_or_update_dev_server(
 717                        state.kind,
 718                        state.dev_server_id,
 719                        state.access_token.clone(),
 720                        cx,
 721                    );
 722                }
 723            }
 724        }
 725    }
 726
 727    fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
 728        match &self.mode {
 729            Mode::Default(None) => cx.emit(DismissEvent),
 730            Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
 731                self.mode = Mode::CreateDevServer(CreateDevServer {
 732                    kind: NewServerKind::DirectSSH,
 733                    ..Default::default()
 734                });
 735                cx.notify();
 736                return;
 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| PathLikeWithPosition::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 RemoteSettingsContent) + 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 {
1310                            if dev_server_id.is_some() {
1311                                "Reconnect"
1312                            } else {
1313                                "Connect"
1314                            }
1315                        },
1316                    )
1317                    .style(ButtonStyle::Filled)
1318                    .layer(ElevationIndex::ModalSurface)
1319                    .disabled(creating && dev_server_id.is_none())
1320                    .on_click(cx.listener({
1321                        let access_token = access_token.clone();
1322                        move |this, _, cx| {
1323                            if kind == NewServerKind::DirectSSH {
1324                                this.create_ssh_server(cx);
1325                                return;
1326                            }
1327                            this.create_or_update_dev_server(
1328                                kind,
1329                                dev_server_id,
1330                                access_token.clone(),
1331                                cx,
1332                            );
1333                        }
1334                    }))
1335                }),
1336            )
1337    }
1338
1339    fn render_dev_server_token_creating(
1340        &self,
1341        access_token: String,
1342        dev_server_name: String,
1343        kind: NewServerKind,
1344        status: DevServerStatus,
1345        creating: bool,
1346        cx: &mut ViewContext<Self>,
1347    ) -> Div {
1348        self.markdown.update(cx, |markdown, cx| {
1349            if kind == NewServerKind::Manual {
1350                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);
1351            } else {
1352                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);
1353            }
1354        });
1355
1356        v_flex()
1357            .pl_2()
1358            .pt_2()
1359            .gap_2()
1360            .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1361            .map(|el| {
1362                if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1363                {
1364                    el.child(
1365                        h_flex()
1366                            .gap_2()
1367                            .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1368                            .child(Label::new("Not connected")),
1369                    )
1370                } else if status == DevServerStatus::Offline {
1371                    el.child(Self::render_loading_spinner("Waiting for connection…"))
1372                } else {
1373                    el.child(Label::new("🎊 Connection established!"))
1374                }
1375            })
1376    }
1377
1378    fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1379        h_flex()
1380            .gap_2()
1381            .child(
1382                Icon::new(IconName::ArrowCircle)
1383                    .size(IconSize::Medium)
1384                    .with_animation(
1385                        "arrow-circle",
1386                        Animation::new(Duration::from_secs(2)).repeat(),
1387                        |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1388                    ),
1389            )
1390            .child(Label::new(label))
1391    }
1392
1393    fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1394        let dev_servers = self.dev_server_store.read(cx).dev_servers();
1395        let ssh_connections = SshSettings::get_global(cx)
1396            .ssh_connections()
1397            .collect::<Vec<_>>();
1398
1399        let Mode::Default(create_dev_server_project) = &self.mode else {
1400            unreachable!()
1401        };
1402
1403        let mut is_creating = None;
1404        let mut creating_dev_server = None;
1405        if let Some(CreateDevServerProject {
1406            creating,
1407            dev_server_id,
1408            ..
1409        }) = create_dev_server_project
1410        {
1411            is_creating = Some(*creating);
1412            creating_dev_server = Some(*dev_server_id);
1413        };
1414        let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
1415
1416        Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1417            .header(
1418                ModalHeader::new()
1419                    .show_dismiss_button(true)
1420                    .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1421            )
1422            .when(is_signed_out, |modal| {
1423                modal
1424                    .section(Section::new().child(v_flex().mb_4().child(Label::new(
1425                        "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.",
1426                    ))))
1427                    .footer(
1428                        ModalFooter::new().end_slot(
1429                            Button::new("sign_in", "Sign in")
1430                                .icon(IconName::Github)
1431                                .icon_position(IconPosition::Start)
1432                                .style(ButtonStyle::Filled)
1433                                .full_width()
1434                                .on_click(cx.listener(|_, _, cx| {
1435                                    let client = Client::global(cx).clone();
1436                                    cx.spawn(|_, mut cx| async move {
1437                                        client
1438                                            .authenticate_and_connect(true, &cx)
1439                                            .await
1440                                            .notify_async_err(&mut cx);
1441                                    })
1442                                    .detach();
1443                                    cx.emit(gpui::DismissEvent);
1444                                })),
1445                        ),
1446                    )
1447            })
1448            .when(!is_signed_out, |modal| {
1449                modal.section(
1450                    Section::new().child(
1451                        div().mb_4().child(
1452                            List::new()
1453                                .empty_message("No dev servers registered.")
1454                                .header(Some(
1455                                    ListHeader::new("Connections").end_slot(
1456                                        Button::new("register-dev-server-button", "Connect")
1457                                            .icon(IconName::Plus)
1458                                            .icon_position(IconPosition::Start)
1459                                            .tooltip(|cx| {
1460                                                Tooltip::text("Connect to a new server", cx)
1461                                            })
1462                                            .on_click(cx.listener(|this, _, cx| {
1463                                                this.mode = Mode::CreateDevServer(
1464                                                    CreateDevServer {
1465                                                        kind: if SshSettings::get_global(cx).use_direct_ssh() { NewServerKind::DirectSSH } else { NewServerKind::LegacySSH },
1466                                                        ..Default::default()
1467                                                    }
1468                                                );
1469                                                this.dev_server_name_input.update(
1470                                                    cx,
1471                                                    |text_field, cx| {
1472                                                        text_field.editor().update(
1473                                                            cx,
1474                                                            |editor, cx| {
1475                                                                editor.set_text("", cx);
1476                                                            },
1477                                                        );
1478                                                    },
1479                                                );
1480                                                cx.notify();
1481                                            })),
1482                                    ),
1483                                ))
1484                                .children(ssh_connections.iter().cloned().enumerate().map(|(ix, connection)| {
1485                                    self.render_ssh_connection(ix, connection, cx)
1486                                        .into_any_element()
1487                                }))
1488                                .children(dev_servers.iter().map(|dev_server| {
1489                                    let creating = if creating_dev_server == Some(dev_server.id) {
1490                                        is_creating
1491                                    } else {
1492                                        None
1493                                    };
1494                                    self.render_dev_server(dev_server, creating, cx)
1495                                        .into_any_element()
1496                                })),
1497                        ),
1498                    ),
1499                )
1500            })
1501    }
1502}
1503
1504fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1505    element
1506        .read(cx)
1507        .editor()
1508        .read(cx)
1509        .text(cx)
1510        .trim()
1511        .to_string()
1512}
1513
1514impl ModalView for DevServerProjects {}
1515
1516impl FocusableView for DevServerProjects {
1517    fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1518        self.focus_handle.clone()
1519    }
1520}
1521
1522impl EventEmitter<DismissEvent> for DevServerProjects {}
1523
1524impl Render for DevServerProjects {
1525    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1526        div()
1527            .track_focus(&self.focus_handle)
1528            .elevation_3(cx)
1529            .key_context("DevServerModal")
1530            .on_action(cx.listener(Self::cancel))
1531            .on_action(cx.listener(Self::confirm))
1532            .capture_any_mouse_down(cx.listener(|this, _, cx| {
1533                this.focus_handle(cx).focus(cx);
1534            }))
1535            .on_mouse_down_out(cx.listener(|this, _, cx| {
1536                if matches!(this.mode, Mode::Default(None)) {
1537                    cx.emit(DismissEvent)
1538                }
1539            }))
1540            .w(rems(34.))
1541            .max_h(rems(40.))
1542            .child(match &self.mode {
1543                Mode::Default(_) => self.render_default(cx).into_any_element(),
1544                Mode::CreateDevServer(state) => {
1545                    self.render_create_dev_server(state, cx).into_any_element()
1546                }
1547            })
1548    }
1549}
1550
1551pub fn reconnect_to_dev_server_project(
1552    workspace: View<Workspace>,
1553    dev_server: DevServer,
1554    dev_server_project_id: DevServerProjectId,
1555    replace_current_window: bool,
1556    cx: &mut WindowContext,
1557) -> Task<Result<()>> {
1558    let store = dev_server_projects::Store::global(cx);
1559    let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1560    cx.spawn(|mut cx| async move {
1561        reconnect.await?;
1562
1563        cx.background_executor()
1564            .timer(Duration::from_millis(1000))
1565            .await;
1566
1567        if let Some(project_id) = store.update(&mut cx, |store, _| {
1568            store
1569                .dev_server_project(dev_server_project_id)
1570                .and_then(|p| p.project_id)
1571        })? {
1572            workspace
1573                .update(&mut cx, move |_, cx| {
1574                    open_dev_server_project(
1575                        replace_current_window,
1576                        dev_server_project_id,
1577                        project_id,
1578                        cx,
1579                    )
1580                })?
1581                .await?;
1582        }
1583
1584        Ok(())
1585    })
1586}
1587
1588pub fn reconnect_to_dev_server(
1589    workspace: View<Workspace>,
1590    dev_server: DevServer,
1591    cx: &mut WindowContext,
1592) -> Task<Result<()>> {
1593    let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1594        return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1595    };
1596    let dev_server_store = dev_server_projects::Store::global(cx);
1597    let get_access_token = dev_server_store.update(cx, |store, cx| {
1598        store.regenerate_dev_server_token(dev_server.id, cx)
1599    });
1600
1601    cx.spawn(|mut cx| async move {
1602        let access_token = get_access_token.await?.access_token;
1603
1604        spawn_ssh_task(
1605            workspace,
1606            dev_server_store,
1607            dev_server.id,
1608            ssh_connection_string.to_string(),
1609            access_token,
1610            &mut cx,
1611        )
1612        .await
1613    })
1614}
1615
1616pub async fn spawn_ssh_task(
1617    workspace: View<Workspace>,
1618    dev_server_store: Model<dev_server_projects::Store>,
1619    dev_server_id: DevServerId,
1620    ssh_connection_string: String,
1621    access_token: String,
1622    cx: &mut AsyncWindowContext,
1623) -> Result<()> {
1624    let terminal_panel = workspace
1625        .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1626        .ok()
1627        .flatten()
1628        .with_context(|| anyhow!("No terminal panel"))?;
1629
1630    let command = "sh".to_string();
1631    let args = vec![
1632        "-x".to_string(),
1633        "-c".to_string(),
1634        format!(
1635            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 {}"#,
1636            access_token
1637        ),
1638    ];
1639
1640    let ssh_connection_string = ssh_connection_string.to_string();
1641
1642    let terminal = terminal_panel
1643        .update(cx, |terminal_panel, cx| {
1644            terminal_panel.spawn_in_new_terminal(
1645                SpawnInTerminal {
1646                    id: task::TaskId("ssh-remote".into()),
1647                    full_label: "Install zed over ssh".into(),
1648                    label: "Install zed over ssh".into(),
1649                    command,
1650                    args,
1651                    command_label: ssh_connection_string.clone(),
1652                    cwd: Some(TerminalWorkDir::Ssh {
1653                        ssh_command: ssh_connection_string,
1654                        path: None,
1655                    }),
1656                    use_new_terminal: true,
1657                    allow_concurrent_runs: false,
1658                    reveal: RevealStrategy::Always,
1659                    hide: HideStrategy::Never,
1660                    env: Default::default(),
1661                    shell: Default::default(),
1662                },
1663                cx,
1664            )
1665        })?
1666        .await?;
1667
1668    terminal
1669        .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1670        .await;
1671
1672    // 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.
1673    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1674        == DevServerStatus::Offline
1675    {
1676        cx.background_executor()
1677            .timer(Duration::from_millis(200))
1678            .await
1679    }
1680
1681    if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1682        == DevServerStatus::Offline
1683    {
1684        return Err(anyhow!("couldn't reconnect"))?;
1685    }
1686
1687    Ok(())
1688}