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