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(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    prompt: Option<(SharedString, oneshot::Sender<Result<String>>)>,
 84    editor: View<Editor>,
 85}
 86
 87pub struct SshConnectionModal {
 88    pub(crate) prompt: View<SshPrompt>,
 89}
 90impl SshPrompt {
 91    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
 92        let connection_string = connection_options.connection_string().into();
 93        Self {
 94            connection_string,
 95            status_message: None,
 96            prompt: None,
 97            editor: cx.new_view(|cx| Editor::single_line(cx)),
 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 confirm(&mut self, cx: &mut ViewContext<Self>) {
126        if let Some((_, tx)) = self.prompt.take() {
127            self.editor.update(cx, |editor, cx| {
128                tx.send(Ok(editor.text(cx))).ok();
129                editor.clear(cx);
130            });
131        }
132    }
133}
134
135impl Render for SshPrompt {
136    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
137        v_flex()
138            .key_context("PasswordPrompt")
139            .p_4()
140            .size_full()
141            .child(
142                h_flex()
143                    .gap_2()
144                    .child(
145                        Icon::new(IconName::ArrowCircle)
146                            .size(IconSize::Medium)
147                            .with_animation(
148                                "arrow-circle",
149                                Animation::new(Duration::from_secs(2)).repeat(),
150                                |icon, delta| {
151                                    icon.transform(Transformation::rotate(percentage(delta)))
152                                },
153                            ),
154                    )
155                    .child(
156                        Label::new(format!("ssh {}", self.connection_string))
157                            .size(ui::LabelSize::Large),
158                    ),
159            )
160            .when_some(self.status_message.as_ref(), |el, status| {
161                el.child(Label::new(status.clone()))
162            })
163            .when_some(self.prompt.as_ref(), |el, prompt| {
164                el.child(Label::new(prompt.0.clone()))
165                    .child(self.editor.clone())
166            })
167    }
168}
169
170impl SshConnectionModal {
171    pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
172        Self {
173            prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
174        }
175    }
176
177    fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
178        self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
179    }
180
181    fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
182        cx.remove_window();
183    }
184}
185
186impl Render for SshConnectionModal {
187    fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
188        v_flex()
189            .elevation_3(cx)
190            .p_4()
191            .gap_2()
192            .on_action(cx.listener(Self::dismiss))
193            .on_action(cx.listener(Self::confirm))
194            .w(px(400.))
195            .child(self.prompt.clone())
196    }
197}
198
199impl FocusableView for SshConnectionModal {
200    fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
201        self.prompt.read(cx).editor.focus_handle(cx)
202    }
203}
204
205impl EventEmitter<DismissEvent> for SshConnectionModal {}
206
207impl ModalView for SshConnectionModal {}
208
209#[derive(Clone)]
210pub struct SshClientDelegate {
211    window: AnyWindowHandle,
212    ui: View<SshPrompt>,
213    known_password: Option<String>,
214}
215
216impl remote::SshClientDelegate for SshClientDelegate {
217    fn ask_password(
218        &self,
219        prompt: String,
220        cx: &mut AsyncAppContext,
221    ) -> oneshot::Receiver<Result<String>> {
222        let (tx, rx) = oneshot::channel();
223        let mut known_password = self.known_password.clone();
224        if let Some(password) = known_password.take() {
225            tx.send(Ok(password)).ok();
226        } else {
227            self.window
228                .update(cx, |_, cx| {
229                    self.ui.update(cx, |modal, cx| {
230                        modal.set_prompt(prompt, tx, cx);
231                    })
232                })
233                .ok();
234        }
235        rx
236    }
237
238    fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
239        self.update_status(status, cx)
240    }
241
242    fn get_server_binary(
243        &self,
244        platform: SshPlatform,
245        cx: &mut AsyncAppContext,
246    ) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
247        let (tx, rx) = oneshot::channel();
248        let this = self.clone();
249        cx.spawn(|mut cx| async move {
250            tx.send(this.get_server_binary_impl(platform, &mut cx).await)
251                .ok();
252        })
253        .detach();
254        rx
255    }
256
257    fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
258        let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
259        Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
260    }
261}
262
263impl SshClientDelegate {
264    fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
265        self.window
266            .update(cx, |_, cx| {
267                self.ui.update(cx, |modal, cx| {
268                    modal.set_status(status.map(|s| s.to_string()), cx);
269                })
270            })
271            .ok();
272    }
273
274    async fn get_server_binary_impl(
275        &self,
276        platform: SshPlatform,
277        cx: &mut AsyncAppContext,
278    ) -> Result<(PathBuf, SemanticVersion)> {
279        let (version, release_channel) = cx.update(|cx| {
280            let global = AppVersion::global(cx);
281            (global, ReleaseChannel::global(cx))
282        })?;
283
284        // In dev mode, build the remote server binary from source
285        #[cfg(debug_assertions)]
286        if release_channel == ReleaseChannel::Dev
287            && platform.arch == std::env::consts::ARCH
288            && platform.os == std::env::consts::OS
289        {
290            use smol::process::{Command, Stdio};
291
292            self.update_status(Some("building remote server binary from source"), cx);
293            log::info!("building remote server binary from source");
294            run_cmd(Command::new("cargo").args(["build", "--package", "remote_server"])).await?;
295            run_cmd(Command::new("strip").args(["target/debug/remote_server"])).await?;
296            run_cmd(Command::new("gzip").args(["-9", "-f", "target/debug/remote_server"])).await?;
297
298            let path = std::env::current_dir()?.join("target/debug/remote_server.gz");
299            return Ok((path, version));
300
301            async fn run_cmd(command: &mut Command) -> Result<()> {
302                let output = command.stderr(Stdio::inherit()).output().await?;
303                if !output.status.success() {
304                    Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
305                }
306                Ok(())
307            }
308        }
309
310        self.update_status(Some("checking for latest version of remote server"), cx);
311        let binary_path = AutoUpdater::get_latest_remote_server_release(
312            platform.os,
313            platform.arch,
314            release_channel,
315            cx,
316        )
317        .await
318        .map_err(|e| anyhow::anyhow!("failed to download remote server binary: {}", e))?;
319
320        Ok((binary_path, version))
321    }
322}
323
324pub fn connect_over_ssh(
325    connection_options: SshConnectionOptions,
326    ui: View<SshPrompt>,
327    cx: &mut WindowContext,
328) -> Task<Result<Arc<SshSession>>> {
329    let window = cx.window_handle();
330    let known_password = connection_options.password.clone();
331
332    cx.spawn(|mut cx| async move {
333        remote::SshSession::client(
334            connection_options,
335            Arc::new(SshClientDelegate {
336                window,
337                ui,
338                known_password,
339            }),
340            &mut cx,
341        )
342        .await
343    })
344}
345
346pub async fn open_ssh_project(
347    connection_options: SshConnectionOptions,
348    paths: Vec<PathWithPosition>,
349    app_state: Arc<AppState>,
350    _open_options: workspace::OpenOptions,
351    cx: &mut AsyncAppContext,
352) -> Result<()> {
353    let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
354    let window = cx.open_window(options, |cx| {
355        let project = project::Project::local(
356            app_state.client.clone(),
357            app_state.node_runtime.clone(),
358            app_state.user_store.clone(),
359            app_state.languages.clone(),
360            app_state.fs.clone(),
361            cx,
362        );
363        cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
364    })?;
365
366    let result = window
367        .update(cx, |workspace, cx| {
368            cx.activate_window();
369            workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
370            let ui = workspace
371                .active_modal::<SshConnectionModal>(cx)
372                .unwrap()
373                .read(cx)
374                .prompt
375                .clone();
376            connect_over_ssh(connection_options, ui, cx)
377        })?
378        .await;
379
380    if result.is_err() {
381        window.update(cx, |_, cx| cx.remove_window()).ok();
382    }
383
384    let session = result?;
385
386    let project = cx.update(|cx| {
387        project::Project::ssh(
388            session,
389            app_state.client.clone(),
390            app_state.node_runtime.clone(),
391            app_state.user_store.clone(),
392            app_state.languages.clone(),
393            app_state.fs.clone(),
394            cx,
395        )
396    })?;
397
398    for path in paths {
399        project
400            .update(cx, |project, cx| {
401                project.find_or_create_worktree(&path.path, true, cx)
402            })?
403            .await?;
404    }
405
406    window.update(cx, |_, cx| {
407        cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx))
408    })?;
409    window.update(cx, |_, cx| cx.activate_window())?;
410
411    Ok(())
412}