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