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