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