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