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