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