ssh_connections.rs

  1use std::{path::PathBuf, sync::Arc, time::Duration};
  2
  3use anyhow::Result;
  4use auto_update::AutoUpdater;
  5use editor::Editor;
  6use futures::channel::oneshot;
  7use gpui::{
  8    percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
  9    EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task,
 10    Transformation, View,
 11};
 12use gpui::{AppContext, Model};
 13use release_channel::{AppVersion, ReleaseChannel};
 14use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
 15use schemars::JsonSchema;
 16use serde::{Deserialize, Serialize};
 17use settings::{Settings, SettingsSources};
 18use ui::{
 19    div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton,
 20    IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip,
 21    ViewContext, VisualContext, WindowContext,
 22};
 23use workspace::{AppState, ModalView, Workspace};
 24
 25#[derive(Deserialize)]
 26pub struct SshSettings {
 27    pub ssh_connections: Option<Vec<SshConnection>>,
 28}
 29
 30impl SshSettings {
 31    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
 32        self.ssh_connections.clone().into_iter().flatten()
 33    }
 34}
 35
 36#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 37pub struct SshConnection {
 38    pub host: String,
 39    #[serde(skip_serializing_if = "Option::is_none")]
 40    pub username: Option<String>,
 41    #[serde(skip_serializing_if = "Option::is_none")]
 42    pub port: Option<u16>,
 43    pub projects: Vec<SshProject>,
 44}
 45impl From<SshConnection> for SshConnectionOptions {
 46    fn from(val: SshConnection) -> Self {
 47        SshConnectionOptions {
 48            host: val.host,
 49            username: val.username,
 50            port: val.port,
 51            password: None,
 52        }
 53    }
 54}
 55
 56#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 57pub struct SshProject {
 58    pub paths: Vec<String>,
 59}
 60
 61#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 62pub struct RemoteSettingsContent {
 63    pub ssh_connections: Option<Vec<SshConnection>>,
 64}
 65
 66impl Settings for SshSettings {
 67    const KEY: Option<&'static str> = None;
 68
 69    type FileContent = RemoteSettingsContent;
 70
 71    fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
 72        sources.json_merge()
 73    }
 74}
 75
 76pub struct SshPrompt {
 77    connection_string: SharedString,
 78    status_message: Option<SharedString>,
 79    error_message: Option<SharedString>,
 80    prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
 81    editor: View<Editor>,
 82}
 83
 84pub struct SshConnectionModal {
 85    pub(crate) prompt: View<SshPrompt>,
 86    is_separate_window: bool,
 87}
 88
 89impl SshPrompt {
 90    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
 91        let connection_string = connection_options.connection_string().into();
 92        Self {
 93            connection_string,
 94            status_message: None,
 95            error_message: None,
 96            prompt: None,
 97            editor: cx.new_view(Editor::single_line),
 98        }
 99    }
100
101    pub fn set_prompt(
102        &mut self,
103        prompt: String,
104        tx: oneshot::Sender<Result<String>>,
105        cx: &mut ViewContext<Self>,
106    ) {
107        self.editor.update(cx, |editor, cx| {
108            if prompt.contains("yes/no") {
109                editor.set_masked(false, cx);
110            } else {
111                editor.set_masked(true, cx);
112            }
113        });
114        self.prompt = Some((prompt.into(), tx));
115        self.status_message.take();
116        cx.focus_view(&self.editor);
117        cx.notify();
118    }
119
120    pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
121        self.status_message = status.map(|s| s.into());
122        cx.notify();
123    }
124
125    pub fn set_error(&mut self, error_message: String, cx: &mut ViewContext<Self>) {
126        self.error_message = Some(error_message.into());
127        cx.notify();
128    }
129
130    pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
131        if let Some((_, tx)) = self.prompt.take() {
132            self.editor.update(cx, |editor, cx| {
133                tx.send(Ok(editor.text(cx))).ok();
134                editor.clear(cx);
135            });
136        }
137    }
138}
139
140impl Render for SshPrompt {
141    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
142        let cx = cx.window_context();
143        let theme = cx.theme();
144        v_flex()
145            .key_context("PasswordPrompt")
146            .size_full()
147            .justify_center()
148            .child(
149                h_flex()
150                    .p_2()
151                    .justify_center()
152                    .flex_wrap()
153                    .child(if self.error_message.is_some() {
154                        Icon::new(IconName::XCircle)
155                            .size(IconSize::Medium)
156                            .color(Color::Error)
157                            .into_any_element()
158                    } else {
159                        Icon::new(IconName::ArrowCircle)
160                            .size(IconSize::Medium)
161                            .with_animation(
162                                "arrow-circle",
163                                Animation::new(Duration::from_secs(2)).repeat(),
164                                |icon, delta| {
165                                    icon.transform(Transformation::rotate(percentage(delta)))
166                                },
167                            )
168                            .into_any_element()
169                    })
170                    .child(
171                        div()
172                            .ml_1()
173                            .child(Label::new("SSH Connection").size(LabelSize::Small)),
174                    )
175                    .child(
176                        div()
177                            .text_ellipsis()
178                            .overflow_x_hidden()
179                            .when_some(self.error_message.as_ref(), |el, error| {
180                                el.child(Label::new(format!("{}", error)).size(LabelSize::Small))
181                            })
182                            .when(
183                                self.error_message.is_none() && self.status_message.is_some(),
184                                |el| {
185                                    el.child(
186                                        Label::new(format!(
187                                            "{}",
188                                            self.status_message.clone().unwrap()
189                                        ))
190                                        .size(LabelSize::Small),
191                                    )
192                                },
193                            ),
194                    ),
195            )
196            .child(div().when_some(self.prompt.as_ref(), |el, prompt| {
197                el.child(
198                    h_flex()
199                        .p_4()
200                        .border_t_1()
201                        .border_color(theme.colors().border_variant)
202                        .font_buffer(cx)
203                        .child(Label::new(prompt.0.clone()))
204                        .child(self.editor.clone()),
205                )
206            }))
207    }
208}
209
210impl SshConnectionModal {
211    pub fn new(
212        connection_options: &SshConnectionOptions,
213        is_separate_window: bool,
214        cx: &mut ViewContext<Self>,
215    ) -> Self {
216        Self {
217            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
218            is_separate_window,
219        }
220    }
221
222    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
223        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
224    }
225
226    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
227        cx.emit(DismissEvent);
228        if self.is_separate_window {
229            cx.remove_window();
230        }
231    }
232}
233
234impl Render for SshConnectionModal {
235    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
236        let connection_string = self.prompt.read(cx).connection_string.clone();
237        let theme = cx.theme();
238        let mut header_color = cx.theme().colors().text;
239        header_color.fade_out(0.96);
240        let body_color = theme.colors().editor_background;
241
242        v_flex()
243            .elevation_3(cx)
244            .track_focus(&self.focus_handle(cx))
245            .on_action(cx.listener(Self::dismiss))
246            .on_action(cx.listener(Self::confirm))
247            .w(px(500.))
248            .border_1()
249            .border_color(theme.colors().border)
250            .child(
251                h_flex()
252                    .relative()
253                    .p_1()
254                    .rounded_t_md()
255                    .border_b_1()
256                    .border_color(theme.colors().border)
257                    .bg(header_color)
258                    .justify_between()
259                    .child(
260                        div().absolute().left_0p5().top_0p5().child(
261                            IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
262                                .icon_size(IconSize::XSmall)
263                                .on_click(cx.listener(move |this, _, cx| {
264                                    this.dismiss(&Default::default(), cx);
265                                }))
266                                .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
267                        ),
268                    )
269                    .child(
270                        h_flex()
271                            .w_full()
272                            .gap_2()
273                            .justify_center()
274                            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
275                            .child(
276                                Label::new(connection_string)
277                                    .size(ui::LabelSize::Small)
278                                    .single_line(),
279                            ),
280                    ),
281            )
282            .child(
283                h_flex()
284                    .rounded_b_md()
285                    .bg(body_color)
286                    .w_full()
287                    .child(self.prompt.clone()),
288            )
289    }
290}
291
292impl FocusableView for SshConnectionModal {
293    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
294        self.prompt.read(cx).editor.focus_handle(cx)
295    }
296}
297
298impl EventEmitter<DismissEvent> for SshConnectionModal {}
299
300impl ModalView for SshConnectionModal {}
301
302#[derive(Clone)]
303pub struct SshClientDelegate {
304    window: AnyWindowHandle,
305    ui: View<SshPrompt>,
306    known_password: Option<String>,
307}
308
309impl remote::SshClientDelegate for SshClientDelegate {
310    fn ask_password(
311        &self,
312        prompt: String,
313        cx: &mut AsyncAppContext,
314    ) -> oneshot::Receiver<Result<String>> {
315        let (tx, rx) = oneshot::channel();
316        let mut known_password = self.known_password.clone();
317        if let Some(password) = known_password.take() {
318            tx.send(Ok(password)).ok();
319        } else {
320            self.window
321                .update(cx, |_, cx| {
322                    self.ui.update(cx, |modal, cx| {
323                        modal.set_prompt(prompt, tx, cx);
324                    })
325                })
326                .ok();
327        }
328        rx
329    }
330
331    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
332        self.update_status(status, cx)
333    }
334
335    fn set_error(&self, error: String, cx: &mut AsyncAppContext) {
336        self.update_error(error, cx)
337    }
338
339    fn get_server_binary(
340        &self,
341        platform: SshPlatform,
342        cx: &mut AsyncAppContext,
343    ) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
344        let (tx, rx) = oneshot::channel();
345        let this = self.clone();
346        cx.spawn(|mut cx| async move {
347            tx.send(this.get_server_binary_impl(platform, &mut cx).await)
348                .ok();
349        })
350        .detach();
351        rx
352    }
353
354    fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
355        let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
356        Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
357    }
358}
359
360impl SshClientDelegate {
361    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
362        self.window
363            .update(cx, |_, cx| {
364                self.ui.update(cx, |modal, cx| {
365                    modal.set_status(status.map(|s| s.to_string()), cx);
366                })
367            })
368            .ok();
369    }
370
371    fn update_error(&self, error: String, cx: &mut AsyncAppContext) {
372        self.window
373            .update(cx, |_, cx| {
374                self.ui.update(cx, |modal, cx| {
375                    modal.set_error(error, cx);
376                })
377            })
378            .ok();
379    }
380
381    async fn get_server_binary_impl(
382        &self,
383        platform: SshPlatform,
384        cx: &mut AsyncAppContext,
385    ) -> Result<(PathBuf, SemanticVersion)> {
386        let (version, release_channel) = cx.update(|cx| {
387            let global = AppVersion::global(cx);
388            (global, ReleaseChannel::global(cx))
389        })?;
390
391        // In dev mode, build the remote server binary from source
392        #[cfg(debug_assertions)]
393        if release_channel == ReleaseChannel::Dev
394            && platform.arch == std::env::consts::ARCH
395            && platform.os == std::env::consts::OS
396        {
397            use smol::process::{Command, Stdio};
398
399            self.update_status(Some("building remote server binary from source"), cx);
400            log::info!("building remote server binary from source");
401            run_cmd(Command::new("cargo").args([
402                "build",
403                "--package",
404                "remote_server",
405                "--target-dir",
406                "target/remote_server",
407            ]))
408            .await?;
409            // run_cmd(Command::new("strip").args(["target/remote_server/debug/remote_server"]))
410            // .await?;
411            run_cmd(Command::new("gzip").args([
412                "-9",
413                "-f",
414                "target/remote_server/debug/remote_server",
415            ]))
416            .await?;
417
418            let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
419            return Ok((path, version));
420
421            async fn run_cmd(command: &mut Command) -> Result<()> {
422                let output = command.stderr(Stdio::inherit()).output().await?;
423                if !output.status.success() {
424                    Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
425                }
426                Ok(())
427            }
428        }
429
430        self.update_status(Some("checking for latest version of remote server"), cx);
431        let binary_path = AutoUpdater::get_latest_remote_server_release(
432            platform.os,
433            platform.arch,
434            release_channel,
435            cx,
436        )
437        .await
438        .map_err(|e| {
439            anyhow::anyhow!(
440                "failed to download remote server binary (os: {}, arch: {}): {}",
441                platform.os,
442                platform.arch,
443                e
444            )
445        })?;
446
447        Ok((binary_path, version))
448    }
449}
450
451pub fn connect_over_ssh(
452    unique_identifier: String,
453    connection_options: SshConnectionOptions,
454    ui: View<SshPrompt>,
455    cx: &mut WindowContext,
456) -> Task<Result<Model<SshRemoteClient>>> {
457    let window = cx.window_handle();
458    let known_password = connection_options.password.clone();
459
460    remote::SshRemoteClient::new(
461        unique_identifier,
462        connection_options,
463        Arc::new(SshClientDelegate {
464            window,
465            ui,
466            known_password,
467        }),
468        cx,
469    )
470}
471
472pub async fn open_ssh_project(
473    connection_options: SshConnectionOptions,
474    paths: Vec<PathBuf>,
475    app_state: Arc<AppState>,
476    open_options: workspace::OpenOptions,
477    cx: &mut AsyncAppContext,
478) -> Result<()> {
479    let window = if let Some(window) = open_options.replace_window {
480        window
481    } else {
482        let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
483        cx.open_window(options, |cx| {
484            let project = project::Project::local(
485                app_state.client.clone(),
486                app_state.node_runtime.clone(),
487                app_state.user_store.clone(),
488                app_state.languages.clone(),
489                app_state.fs.clone(),
490                None,
491                cx,
492            );
493            cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
494        })?
495    };
496
497    let delegate = window.update(cx, |workspace, cx| {
498        cx.activate_window();
499        workspace.toggle_modal(cx, |cx| {
500            SshConnectionModal::new(&connection_options, true, cx)
501        });
502        let ui = workspace
503            .active_modal::<SshConnectionModal>(cx)
504            .unwrap()
505            .read(cx)
506            .prompt
507            .clone();
508
509        Arc::new(SshClientDelegate {
510            window: cx.window_handle(),
511            ui,
512            known_password: connection_options.password.clone(),
513        })
514    })?;
515
516    let did_open_ssh_project = cx
517        .update(|cx| {
518            workspace::open_ssh_project(window, connection_options, delegate, app_state, paths, cx)
519        })?
520        .await;
521
522    did_open_ssh_project
523}