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, SshRemoteClient};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use settings::{Settings, SettingsSources};
18use ui::{
19 h_flex, v_flex, Color, FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement,
20 IntoElement, Label, LabelCommon, Styled, StyledExt as _, ViewContext, VisualContext,
21 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, _cx: &mut ViewContext<Self>) -> impl IntoElement {
144 v_flex()
145 .key_context("PasswordPrompt")
146 .p_4()
147 .size_full()
148 .child(
149 h_flex()
150 .gap_2()
151 .child(if self.error_message.is_some() {
152 Icon::new(IconName::XCircle)
153 .size(IconSize::Medium)
154 .color(Color::Error)
155 .into_any_element()
156 } else {
157 Icon::new(IconName::ArrowCircle)
158 .size(IconSize::Medium)
159 .with_animation(
160 "arrow-circle",
161 Animation::new(Duration::from_secs(2)).repeat(),
162 |icon, delta| {
163 icon.transform(Transformation::rotate(percentage(delta)))
164 },
165 )
166 .into_any_element()
167 })
168 .child(
169 Label::new(format!("ssh {}…", self.connection_string))
170 .size(ui::LabelSize::Large),
171 ),
172 )
173 .when_some(self.error_message.as_ref(), |el, error| {
174 el.child(Label::new(error.clone()))
175 })
176 .when(
177 self.error_message.is_none() && self.status_message.is_some(),
178 |el| el.child(Label::new(self.status_message.clone().unwrap())),
179 )
180 .when_some(self.prompt.as_ref(), |el, prompt| {
181 el.child(Label::new(prompt.0.clone()))
182 .child(self.editor.clone())
183 })
184 }
185}
186
187impl SshConnectionModal {
188 pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext<Self>) -> Self {
189 Self {
190 prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
191 }
192 }
193
194 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
195 self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
196 }
197
198 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
199 cx.remove_window();
200 }
201}
202
203impl Render for SshConnectionModal {
204 fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
205 v_flex()
206 .elevation_3(cx)
207 .p_4()
208 .gap_2()
209 .on_action(cx.listener(Self::dismiss))
210 .on_action(cx.listener(Self::confirm))
211 .w(px(400.))
212 .child(self.prompt.clone())
213 }
214}
215
216impl FocusableView for SshConnectionModal {
217 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
218 self.prompt.read(cx).editor.focus_handle(cx)
219 }
220}
221
222impl EventEmitter<DismissEvent> for SshConnectionModal {}
223
224impl ModalView for SshConnectionModal {}
225
226#[derive(Clone)]
227pub struct SshClientDelegate {
228 window: AnyWindowHandle,
229 ui: View<SshPrompt>,
230 known_password: Option<String>,
231}
232
233impl remote::SshClientDelegate for SshClientDelegate {
234 fn ask_password(
235 &self,
236 prompt: String,
237 cx: &mut AsyncAppContext,
238 ) -> oneshot::Receiver<Result<String>> {
239 let (tx, rx) = oneshot::channel();
240 let mut known_password = self.known_password.clone();
241 if let Some(password) = known_password.take() {
242 tx.send(Ok(password)).ok();
243 } else {
244 self.window
245 .update(cx, |_, cx| {
246 self.ui.update(cx, |modal, cx| {
247 modal.set_prompt(prompt, tx, cx);
248 })
249 })
250 .ok();
251 }
252 rx
253 }
254
255 fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
256 self.update_status(status, cx)
257 }
258
259 fn set_error(&self, error: String, cx: &mut AsyncAppContext) {
260 self.update_error(error, cx)
261 }
262
263 fn get_server_binary(
264 &self,
265 platform: SshPlatform,
266 cx: &mut AsyncAppContext,
267 ) -> oneshot::Receiver<Result<(PathBuf, SemanticVersion)>> {
268 let (tx, rx) = oneshot::channel();
269 let this = self.clone();
270 cx.spawn(|mut cx| async move {
271 tx.send(this.get_server_binary_impl(platform, &mut cx).await)
272 .ok();
273 })
274 .detach();
275 rx
276 }
277
278 fn remote_server_binary_path(&self, cx: &mut AsyncAppContext) -> Result<PathBuf> {
279 let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
280 Ok(format!(".local/zed-remote-server-{}", release_channel.dev_name()).into())
281 }
282}
283
284impl SshClientDelegate {
285 fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
286 self.window
287 .update(cx, |_, cx| {
288 self.ui.update(cx, |modal, cx| {
289 modal.set_status(status.map(|s| s.to_string()), cx);
290 })
291 })
292 .ok();
293 }
294
295 fn update_error(&self, error: String, cx: &mut AsyncAppContext) {
296 self.window
297 .update(cx, |_, cx| {
298 self.ui.update(cx, |modal, cx| {
299 modal.set_error(error, cx);
300 })
301 })
302 .ok();
303 }
304
305 async fn get_server_binary_impl(
306 &self,
307 platform: SshPlatform,
308 cx: &mut AsyncAppContext,
309 ) -> Result<(PathBuf, SemanticVersion)> {
310 let (version, release_channel) = cx.update(|cx| {
311 let global = AppVersion::global(cx);
312 (global, ReleaseChannel::global(cx))
313 })?;
314
315 // In dev mode, build the remote server binary from source
316 #[cfg(debug_assertions)]
317 if release_channel == ReleaseChannel::Dev
318 && platform.arch == std::env::consts::ARCH
319 && platform.os == std::env::consts::OS
320 {
321 use smol::process::{Command, Stdio};
322
323 self.update_status(Some("building remote server binary from source"), cx);
324 log::info!("building remote server binary from source");
325 run_cmd(Command::new("cargo").args([
326 "build",
327 "--package",
328 "remote_server",
329 "--target-dir",
330 "target/remote_server",
331 ]))
332 .await?;
333 // run_cmd(Command::new("strip").args(["target/remote_server/debug/remote_server"]))
334 // .await?;
335 run_cmd(Command::new("gzip").args([
336 "-9",
337 "-f",
338 "target/remote_server/debug/remote_server",
339 ]))
340 .await?;
341
342 let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
343 return Ok((path, version));
344
345 async fn run_cmd(command: &mut Command) -> Result<()> {
346 let output = command.stderr(Stdio::inherit()).output().await?;
347 if !output.status.success() {
348 Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
349 }
350 Ok(())
351 }
352 }
353
354 self.update_status(Some("checking for latest version of remote server"), cx);
355 let binary_path = AutoUpdater::get_latest_remote_server_release(
356 platform.os,
357 platform.arch,
358 release_channel,
359 cx,
360 )
361 .await
362 .map_err(|e| {
363 anyhow::anyhow!(
364 "failed to download remote server binary (os: {}, arch: {}): {}",
365 platform.os,
366 platform.arch,
367 e
368 )
369 })?;
370
371 Ok((binary_path, version))
372 }
373}
374
375pub fn connect_over_ssh(
376 connection_options: SshConnectionOptions,
377 ui: View<SshPrompt>,
378 cx: &mut WindowContext,
379) -> Task<Result<Arc<SshRemoteClient>>> {
380 let window = cx.window_handle();
381 let known_password = connection_options.password.clone();
382
383 cx.spawn(|mut cx| async move {
384 remote::SshRemoteClient::new(
385 connection_options,
386 Arc::new(SshClientDelegate {
387 window,
388 ui,
389 known_password,
390 }),
391 &mut cx,
392 )
393 .await
394 })
395}
396
397pub async fn open_ssh_project(
398 connection_options: SshConnectionOptions,
399 paths: Vec<PathBuf>,
400 app_state: Arc<AppState>,
401 open_options: workspace::OpenOptions,
402 cx: &mut AsyncAppContext,
403) -> Result<()> {
404 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
405
406 let window = if let Some(window) = open_options.replace_window {
407 window
408 } else {
409 cx.open_window(options, |cx| {
410 let project = project::Project::local(
411 app_state.client.clone(),
412 app_state.node_runtime.clone(),
413 app_state.user_store.clone(),
414 app_state.languages.clone(),
415 app_state.fs.clone(),
416 None,
417 cx,
418 );
419 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
420 })?
421 };
422
423 let session = window
424 .update(cx, |workspace, cx| {
425 cx.activate_window();
426 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
427 let ui = workspace
428 .active_modal::<SshConnectionModal>(cx)
429 .unwrap()
430 .read(cx)
431 .prompt
432 .clone();
433 connect_over_ssh(connection_options.clone(), ui, cx)
434 })?
435 .await?;
436
437 cx.update(|cx| {
438 workspace::open_ssh_project(window, connection_options, session, app_state, paths, cx)
439 })?
440 .await
441}