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