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}