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::AppContext;
  8use gpui::{
  9    percentage, px, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
 10    EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion, SharedString, Task,
 11    Transformation, View,
 12};
 13use release_channel::{AppVersion, ReleaseChannel};
 14use remote::{SshConnectionOptions, SshPlatform, SshSession};
 15use schemars::JsonSchema;
 16use serde::{Deserialize, Serialize};
 17use settings::{Settings, SettingsSources};
 18use ui::{
 19    h_flex, v_flex, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement,
 20    Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext, WindowContext,
 21};
 22use util::paths::PathWithPosition;
 23use workspace::{AppState, ModalView, Workspace};
 24
 25#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 26#[serde(default)]
 27pub struct SshSettings {
 28    /// ssh_connections is an array of ssh connections.
 29    /// By default this setting is null, which disables the direct ssh connection support.
 30    /// You can configure these from `project: Open Remote` in the command palette.
 31    /// Zed's ssh support will pull configuration from your ~/.ssh too.
 32    /// Examples:
 33    /// [
 34    ///   {
 35    ///     "host": "example-box",
 36    ///     "projects": [
 37    ///       {
 38    ///         "paths": ["/home/user/code/zed"]
 39    ///       }
 40    ///     ]
 41    ///   }
 42    /// ]
 43    pub ssh_connections: Option<Vec<SshConnection>>,
 44}
 45
 46impl SshSettings {
 47    pub fn use_direct_ssh(&self) -> bool {
 48        self.ssh_connections.is_some()
 49    }
 50
 51    pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
 52        self.ssh_connections.clone().into_iter().flatten()
 53    }
 54}
 55
 56#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 57pub struct SshConnection {
 58    pub host: String,
 59    #[serde(skip_serializing_if = "Option::is_none")]
 60    pub username: Option<String>,
 61    #[serde(skip_serializing_if = "Option::is_none")]
 62    pub port: Option<u16>,
 63    pub projects: Vec<SshProject>,
 64}
 65impl From<SshConnection> for SshConnectionOptions {
 66    fn from(val: SshConnection) -> Self {
 67        SshConnectionOptions {
 68            host: val.host,
 69            username: val.username,
 70            port: val.port,
 71            password: None,
 72        }
 73    }
 74}
 75
 76#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
 77pub struct SshProject {
 78    pub paths: Vec<String>,
 79}
 80
 81impl Settings for SshSettings {
 82    const KEY: Option<&'static str> = None;
 83
 84    type FileContent = Self;
 85
 86    fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
 87        sources.json_merge()
 88    }
 89}
 90
 91pub struct SshPrompt {
 92    connection_string: SharedString,
 93    status_message: Option<SharedString>,
 94    prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
 95    editor: View<Editor>,
 96}
 97
 98pub struct SshConnectionModal {
 99    pub(crate) prompt: View<SshPrompt>,
100}
101impl SshPrompt {
102    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
103        let connection_string = connection_options.connection_string().into();
104        Self {
105            connection_string,
106            status_message: None,
107            prompt: None,
108            editor: cx.new_view(Editor::single_line),
109        }
110    }
111
112    pub fn set_prompt(
113        &mut self,
114        prompt: String,
115        tx: oneshot::Sender<Result<String>>,
116        cx: &mut ViewContext<Self>,
117    ) {
118        self.editor.update(cx, |editor, cx| {
119            if prompt.contains("yes/no") {
120                editor.set_masked(false, cx);
121            } else {
122                editor.set_masked(true, cx);
123            }
124        });
125        self.prompt = Some((prompt.into(), tx));
126        self.status_message.take();
127        cx.focus_view(&self.editor);
128        cx.notify();
129    }
130
131    pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
132        self.status_message = status.map(|s| s.into());
133        cx.notify();
134    }
135
136    pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
137        if let Some((_, tx)) = self.prompt.take() {
138            self.editor.update(cx, |editor, cx| {
139                tx.send(Ok(editor.text(cx))).ok();
140                editor.clear(cx);
141            });
142        }
143    }
144}
145
146impl Render for SshPrompt {
147    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
148        v_flex()
149            .key_context("PasswordPrompt")
150            .p_4()
151            .size_full()
152            .child(
153                h_flex()
154                    .gap_2()
155                    .child(
156                        Icon::new(IconName::ArrowCircle)
157                            .size(IconSize::Medium)
158                            .with_animation(
159                                "arrow-circle",
160                                Animation::new(Duration::from_secs(2)).repeat(),
161                                |icon, delta| {
162                                    icon.transform(Transformation::rotate(percentage(delta)))
163                                },
164                            ),
165                    )
166                    .child(
167                        Label::new(format!("ssh {}", self.connection_string))
168                            .size(ui::LabelSize::Large),
169                    ),
170            )
171            .when_some(self.status_message.as_ref(), |el, status| {
172                el.child(Label::new(status.clone()))
173            })
174            .when_some(self.prompt.as_ref(), |el, prompt| {
175                el.child(Label::new(prompt.0.clone()))
176                    .child(self.editor.clone())
177            })
178    }
179}
180
181impl SshConnectionModal {
182    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
183        Self {
184            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
185        }
186    }
187
188    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
189        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
190    }
191
192    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
193        cx.remove_window();
194    }
195}
196
197impl Render for SshConnectionModal {
198    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
199        v_flex()
200            .elevation_3(cx)
201            .p_4()
202            .gap_2()
203            .on_action(cx.listener(Self::dismiss))
204            .on_action(cx.listener(Self::confirm))
205            .w(px(400.))
206            .child(self.prompt.clone())
207    }
208}
209
210impl FocusableView for SshConnectionModal {
211    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
212        self.prompt.read(cx).editor.focus_handle(cx)
213    }
214}
215
216impl EventEmitter<DismissEvent> for SshConnectionModal {}
217
218impl ModalView for SshConnectionModal {}
219
220#[derive(Clone)]
221pub struct SshClientDelegate {
222    window: AnyWindowHandle,
223    ui: View<SshPrompt>,
224    known_password: Option<String>,
225}
226
227impl remote::SshClientDelegate for SshClientDelegate {
228    fn ask_password(
229        &self,
230        prompt: String,
231        cx: &mut AsyncAppContext,
232    ) -> oneshot::Receiver<Result<String>> {
233        let (tx, rx) = oneshot::channel();
234        let mut known_password = self.known_password.clone();
235        if let Some(password) = known_password.take() {
236            tx.send(Ok(password)).ok();
237        } else {
238            self.window
239                .update(cx, |_, cx| {
240                    self.ui.update(cx, |modal, cx| {
241                        modal.set_prompt(prompt, tx, cx);
242                    })
243                })
244                .ok();
245        }
246        rx
247    }
248
249    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
250        self.update_status(status, cx)
251    }
252
253    fn get_server_binary(
254        &self,
255        platform: SshPlatform,
256        cx: &mut AsyncAppContext,
257    ) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
258        let (tx, rx) = oneshot::channel();
259        let this = self.clone();
260        cx.spawn(|mut cx| async move {
261            tx.send(this.get_server_binary_impl(platform, &mut cx).await)
262                .ok();
263        })
264        .detach();
265        rx
266    }
267
268    fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
269        let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
270        Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
271    }
272}
273
274impl SshClientDelegate {
275    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
276        self.window
277            .update(cx, |_, cx| {
278                self.ui.update(cx, |modal, cx| {
279                    modal.set_status(status.map(|s| s.to_string()), cx);
280                })
281            })
282            .ok();
283    }
284
285    async fn get_server_binary_impl(
286        &self,
287        platform: SshPlatform,
288        cx: &mut AsyncAppContext,
289    ) -> Result<(PathBuf, SemanticVersion)> {
290        let (version, release_channel) = cx.update(|cx| {
291            let global = AppVersion::global(cx);
292            (global, ReleaseChannel::global(cx))
293        })?;
294
295        // In dev mode, build the remote server binary from source
296        #[cfg(debug_assertions)]
297        if release_channel == ReleaseChannel::Dev
298            && platform.arch == std::env::consts::ARCH
299            && platform.os == std::env::consts::OS
300        {
301            use smol::process::{Command, Stdio};
302
303            self.update_status(Some("building remote server binary from source"), cx);
304            log::info!("building remote server binary from source");
305            run_cmd(Command::new("cargo").args([
306                "build",
307                "--package",
308                "remote_server",
309                "--target-dir",
310                "target/remote_server",
311            ]))
312            .await?;
313            // run_cmd(Command::new("strip").args(["target/remote_server/debug/remote_server"]))
314            // .await?;
315            run_cmd(Command::new("gzip").args([
316                "-9",
317                "-f",
318                "target/remote_server/debug/remote_server",
319            ]))
320            .await?;
321
322            let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
323            return Ok((path, version));
324
325            async fn run_cmd(command: &mut Command) -> Result<()> {
326                let output = command.stderr(Stdio::inherit()).output().await?;
327                if !output.status.success() {
328                    Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
329                }
330                Ok(())
331            }
332        }
333
334        self.update_status(Some("checking for latest version of remote server"), cx);
335        let binary_path = AutoUpdater::get_latest_remote_server_release(
336            platform.os,
337            platform.arch,
338            release_channel,
339            cx,
340        )
341        .await
342        .map_err(|e| anyhow::anyhow!("failed to download remote server binary: {}", e))?;
343
344        Ok((binary_path, version))
345    }
346}
347
348pub fn connect_over_ssh(
349    connection_options: SshConnectionOptions,
350    ui: View<SshPrompt>,
351    cx: &mut WindowContext,
352) -> Task<Result<Arc<SshSession>>> {
353    let window = cx.window_handle();
354    let known_password = connection_options.password.clone();
355
356    cx.spawn(|mut cx| async move {
357        remote::SshSession::client(
358            connection_options,
359            Arc::new(SshClientDelegate {
360                window,
361                ui,
362                known_password,
363            }),
364            &mut cx,
365        )
366        .await
367    })
368}
369
370pub async fn open_ssh_project(
371    connection_options: SshConnectionOptions,
372    paths: Vec<PathWithPosition>,
373    app_state: Arc<AppState>,
374    _open_options: workspace::OpenOptions,
375    cx: &mut AsyncAppContext,
376) -> Result<()> {
377    let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
378    let window = cx.open_window(options, |cx| {
379        let project = project::Project::local(
380            app_state.client.clone(),
381            app_state.node_runtime.clone(),
382            app_state.user_store.clone(),
383            app_state.languages.clone(),
384            app_state.fs.clone(),
385            None,
386            cx,
387        );
388        cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
389    })?;
390
391    let result = window
392        .update(cx, |workspace, cx| {
393            cx.activate_window();
394            workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
395            let ui = workspace
396                .active_modal::<SshConnectionModal>(cx)
397                .unwrap()
398                .read(cx)
399                .prompt
400                .clone();
401            connect_over_ssh(connection_options, ui, cx)
402        })?
403        .await;
404
405    if result.is_err() {
406        window.update(cx, |_, cx| cx.remove_window()).ok();
407    }
408
409    let session = result?;
410
411    let project = cx.update(|cx| {
412        project::Project::ssh(
413            session,
414            app_state.client.clone(),
415            app_state.node_runtime.clone(),
416            app_state.user_store.clone(),
417            app_state.languages.clone(),
418            app_state.fs.clone(),
419            cx,
420        )
421    })?;
422
423    for path in paths {
424        project
425            .update(cx, |project, cx| {
426                project.find_or_create_worktree(&path.path, true, cx)
427            })?
428            .await?;
429    }
430
431    window.update(cx, |_, cx| {
432        cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx))
433    })?;
434    window.update(cx, |_, cx| cx.activate_window())?;
435
436    Ok(())
437}