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, Action, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext,
  9    DismissEvent, EventEmitter, FocusableView, ParentElement as _, Render, SemanticVersion,
 10    SharedString, Task, 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, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, FluentBuilder as _, Icon,
 20    IconButton, IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled,
 21    StyledExt as _, Tooltip, 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}
 87impl SshPrompt {
 88    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
 89        let connection_string = connection_options.connection_string().into();
 90        Self {
 91            connection_string,
 92            status_message: None,
 93            error_message: None,
 94            prompt: None,
 95            editor: cx.new_view(Editor::single_line),
 96        }
 97    }
 98
 99    pub fn set_prompt(
100        &mut self,
101        prompt: String,
102        tx: oneshot::Sender<Result<String>>,
103        cx: &mut ViewContext<Self>,
104    ) {
105        self.editor.update(cx, |editor, cx| {
106            if prompt.contains("yes/no") {
107                editor.set_masked(false, cx);
108            } else {
109                editor.set_masked(true, cx);
110            }
111        });
112        self.prompt = Some((prompt.into(), tx));
113        self.status_message.take();
114        cx.focus_view(&self.editor);
115        cx.notify();
116    }
117
118    pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
119        self.status_message = status.map(|s| s.into());
120        cx.notify();
121    }
122
123    pub fn set_error(&mut self, error_message: String, cx: &mut ViewContext<Self>) {
124        self.error_message = Some(error_message.into());
125        cx.notify();
126    }
127
128    pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
129        if let Some((_, tx)) = self.prompt.take() {
130            self.editor.update(cx, |editor, cx| {
131                tx.send(Ok(editor.text(cx))).ok();
132                editor.clear(cx);
133            });
134        }
135    }
136}
137
138impl Render for SshPrompt {
139    fn render(&mut self, _: &mut ViewContext<Self>) -> impl IntoElement {
140        v_flex()
141            .w_full()
142            .key_context("PasswordPrompt")
143            .justify_start()
144            .child(
145                v_flex()
146                    .p_4()
147                    .size_full()
148                    .child(
149                        h_flex()
150                            .gap_2()
151                            .justify_between()
152                            .child(h_flex().w_full())
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(
166                                                delta,
167                                            )))
168                                        },
169                                    )
170                                    .into_any_element()
171                            })
172                            .child(Label::new(format!(
173                                "Connecting to {}",
174                                self.connection_string
175                            )))
176                            .child(h_flex().w_full()),
177                    )
178                    .when_some(self.error_message.as_ref(), |el, error| {
179                        el.child(Label::new(error.clone()))
180                    })
181                    .when(
182                        self.error_message.is_none() && self.status_message.is_some(),
183                        |el| el.child(Label::new(self.status_message.clone().unwrap())),
184                    )
185                    .when_some(self.prompt.as_ref(), |el, prompt| {
186                        el.child(Label::new(prompt.0.clone()))
187                            .child(self.editor.clone())
188                    }),
189            )
190    }
191}
192
193impl SshConnectionModal {
194    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
195        Self {
196            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
197        }
198    }
199
200    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
201        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
202    }
203
204    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
205        cx.remove_window();
206    }
207}
208
209impl Render for SshConnectionModal {
210    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
211        let connection_string = self.prompt.read(cx).connection_string.clone();
212        let theme = cx.theme();
213        let header_color = theme.colors().element_background;
214        let body_color = theme.colors().background;
215        v_flex()
216            .elevation_3(cx)
217            .on_action(cx.listener(Self::dismiss))
218            .on_action(cx.listener(Self::confirm))
219            .w(px(400.))
220            .child(
221                h_flex()
222                    .p_1()
223                    .border_b_1()
224                    .border_color(theme.colors().border)
225                    .bg(header_color)
226                    .justify_between()
227                    .child(
228                        IconButton::new("ssh-connection-cancel", IconName::ArrowLeft)
229                            .icon_size(IconSize::XSmall)
230                            .on_click(|_, cx| cx.dispatch_action(menu::Cancel.boxed_clone()))
231                            .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)),
232                    )
233                    .child(
234                        h_flex()
235                            .gap_2()
236                            .child(Icon::new(IconName::Server).size(IconSize::XSmall))
237                            .child(
238                                Label::new(connection_string)
239                                    .size(ui::LabelSize::Small)
240                                    .single_line(),
241                            ),
242                    )
243                    .child(div()),
244            )
245            .child(h_flex().bg(body_color).w_full().child(self.prompt.clone()))
246    }
247}
248
249impl FocusableView for SshConnectionModal {
250    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
251        self.prompt.read(cx).editor.focus_handle(cx)
252    }
253}
254
255impl EventEmitter<DismissEvent> for SshConnectionModal {}
256
257impl ModalView for SshConnectionModal {}
258
259#[derive(Clone)]
260pub struct SshClientDelegate {
261    window: AnyWindowHandle,
262    ui: View<SshPrompt>,
263    known_password: Option<String>,
264}
265
266impl remote::SshClientDelegate for SshClientDelegate {
267    fn ask_password(
268        &self,
269        prompt: String,
270        cx: &mut AsyncAppContext,
271    ) -> oneshot::Receiver<Result<String>> {
272        let (tx, rx) = oneshot::channel();
273        let mut known_password = self.known_password.clone();
274        if let Some(password) = known_password.take() {
275            tx.send(Ok(password)).ok();
276        } else {
277            self.window
278                .update(cx, |_, cx| {
279                    self.ui.update(cx, |modal, cx| {
280                        modal.set_prompt(prompt, tx, cx);
281                    })
282                })
283                .ok();
284        }
285        rx
286    }
287
288    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
289        self.update_status(status, cx)
290    }
291
292    fn set_error(&self, error: String, cx: &mut AsyncAppContext) {
293        self.update_error(error, cx)
294    }
295
296    fn get_server_binary(
297        &self,
298        platform: SshPlatform,
299        cx: &mut AsyncAppContext,
300    ) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
301        let (tx, rx) = oneshot::channel();
302        let this = self.clone();
303        cx.spawn(|mut cx| async move {
304            tx.send(this.get_server_binary_impl(platform, &mut cx).await)
305                .ok();
306        })
307        .detach();
308        rx
309    }
310
311    fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
312        let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
313        Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
314    }
315}
316
317impl SshClientDelegate {
318    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
319        self.window
320            .update(cx, |_, cx| {
321                self.ui.update(cx, |modal, cx| {
322                    modal.set_status(status.map(|s| s.to_string()), cx);
323                })
324            })
325            .ok();
326    }
327
328    fn update_error(&self, error: String, cx: &mut AsyncAppContext) {
329        self.window
330            .update(cx, |_, cx| {
331                self.ui.update(cx, |modal, cx| {
332                    modal.set_error(error, cx);
333                })
334            })
335            .ok();
336    }
337
338    async fn get_server_binary_impl(
339        &self,
340        platform: SshPlatform,
341        cx: &mut AsyncAppContext,
342    ) -> Result<(PathBuf, SemanticVersion)> {
343        let (version, release_channel) = cx.update(|cx| {
344            let global = AppVersion::global(cx);
345            (global, ReleaseChannel::global(cx))
346        })?;
347
348        // In dev mode, build the remote server binary from source
349        #[cfg(debug_assertions)]
350        if release_channel == ReleaseChannel::Dev
351            && platform.arch == std::env::consts::ARCH
352            && platform.os == std::env::consts::OS
353        {
354            use smol::process::{Command, Stdio};
355
356            self.update_status(Some("building remote server binary from source"), cx);
357            log::info!("building remote server binary from source");
358            run_cmd(Command::new("cargo").args([
359                "build",
360                "--package",
361                "remote_server",
362                "--target-dir",
363                "target/remote_server",
364            ]))
365            .await?;
366            // run_cmd(Command::new("strip").args(["target/remote_server/debug/remote_server"]))
367            // .await?;
368            run_cmd(Command::new("gzip").args([
369                "-9",
370                "-f",
371                "target/remote_server/debug/remote_server",
372            ]))
373            .await?;
374
375            let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
376            return Ok((path, version));
377
378            async fn run_cmd(command: &mut Command) -> Result<()> {
379                let output = command.stderr(Stdio::inherit()).output().await?;
380                if !output.status.success() {
381                    Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
382                }
383                Ok(())
384            }
385        }
386
387        self.update_status(Some("checking for latest version of remote server"), cx);
388        let binary_path = AutoUpdater::get_latest_remote_server_release(
389            platform.os,
390            platform.arch,
391            release_channel,
392            cx,
393        )
394        .await
395        .map_err(|e| {
396            anyhow::anyhow!(
397                "failed to download remote server binary (os: {}, arch: {}): {}",
398                platform.os,
399                platform.arch,
400                e
401            )
402        })?;
403
404        Ok((binary_path, version))
405    }
406}
407
408pub fn connect_over_ssh(
409    unique_identifier: String,
410    connection_options: SshConnectionOptions,
411    ui: View<SshPrompt>,
412    cx: &mut WindowContext,
413) -> Task<Result<Model<SshRemoteClient>>> {
414    let window = cx.window_handle();
415    let known_password = connection_options.password.clone();
416
417    remote::SshRemoteClient::new(
418        unique_identifier,
419        connection_options,
420        Arc::new(SshClientDelegate {
421            window,
422            ui,
423            known_password,
424        }),
425        cx,
426    )
427}
428
429pub async fn open_ssh_project(
430    connection_options: SshConnectionOptions,
431    paths: Vec<PathBuf>,
432    app_state: Arc<AppState>,
433    open_options: workspace::OpenOptions,
434    cx: &mut AsyncAppContext,
435) -> Result<()> {
436    let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
437
438    let window = if let Some(window) = open_options.replace_window {
439        window
440    } else {
441        cx.open_window(options, |cx| {
442            let project = project::Project::local(
443                app_state.client.clone(),
444                app_state.node_runtime.clone(),
445                app_state.user_store.clone(),
446                app_state.languages.clone(),
447                app_state.fs.clone(),
448                None,
449                cx,
450            );
451            cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
452        })?
453    };
454
455    let delegate = window.update(cx, |workspace, cx| {
456        cx.activate_window();
457        workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
458        let ui = workspace
459            .active_modal::<SshConnectionModal>(cx)
460            .unwrap()
461            .read(cx)
462            .prompt
463            .clone();
464
465        Arc::new(SshClientDelegate {
466            window: cx.window_handle(),
467            ui,
468            known_password: connection_options.password.clone(),
469        })
470    })?;
471
472    cx.update(|cx| {
473        workspace::open_ssh_project(window, connection_options, delegate, app_state, paths, cx)
474    })?
475    .await
476}