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(["build", "--package", "remote_server"])).await?;
295 run_cmd(Command::new("strip").args(["target/debug/remote_server"])).await?;
296 run_cmd(Command::new("gzip").args(["-9", "-f", "target/debug/remote_server"])).await?;
297
298 let path = std::env::current_dir()?.join("target/debug/remote_server.gz");
299 return Ok((path, version));
300
301 async fn run_cmd(command: &mut Command) -> Result<()> {
302 let output = command.stderr(Stdio::inherit()).output().await?;
303 if !output.status.success() {
304 Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
305 }
306 Ok(())
307 }
308 }
309
310 self.update_status(Some("checking for latest version of remote server"), cx);
311 let binary_path = AutoUpdater::get_latest_remote_server_release(
312 platform.os,
313 platform.arch,
314 release_channel,
315 cx,
316 )
317 .await
318 .map_err(|e| anyhow::anyhow!("failed to download remote server binary: {}", e))?;
319
320 Ok((binary_path, version))
321 }
322}
323
324pub fn connect_over_ssh(
325 connection_options: SshConnectionOptions,
326 ui: View<SshPrompt>,
327 cx: &mut WindowContext,
328) -> Task<Result<Arc<SshSession>>> {
329 let window = cx.window_handle();
330 let known_password = connection_options.password.clone();
331
332 cx.spawn(|mut cx| async move {
333 remote::SshSession::client(
334 connection_options,
335 Arc::new(SshClientDelegate {
336 window,
337 ui,
338 known_password,
339 }),
340 &mut cx,
341 )
342 .await
343 })
344}
345
346pub async fn open_ssh_project(
347 connection_options: SshConnectionOptions,
348 paths: Vec<PathWithPosition>,
349 app_state: Arc<AppState>,
350 _open_options: workspace::OpenOptions,
351 cx: &mut AsyncAppContext,
352) -> Result<()> {
353 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
354 let window = cx.open_window(options, |cx| {
355 let project = project::Project::local(
356 app_state.client.clone(),
357 app_state.node_runtime.clone(),
358 app_state.user_store.clone(),
359 app_state.languages.clone(),
360 app_state.fs.clone(),
361 None,
362 cx,
363 );
364 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
365 })?;
366
367 let result = window
368 .update(cx, |workspace, cx| {
369 cx.activate_window();
370 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
371 let ui = workspace
372 .active_modal::<SshConnectionModal>(cx)
373 .unwrap()
374 .read(cx)
375 .prompt
376 .clone();
377 connect_over_ssh(connection_options, ui, cx)
378 })?
379 .await;
380
381 if result.is_err() {
382 window.update(cx, |_, cx| cx.remove_window()).ok();
383 }
384
385 let session = result?;
386
387 let project = cx.update(|cx| {
388 project::Project::ssh(
389 session,
390 app_state.client.clone(),
391 app_state.node_runtime.clone(),
392 app_state.user_store.clone(),
393 app_state.languages.clone(),
394 app_state.fs.clone(),
395 cx,
396 )
397 })?;
398
399 for path in paths {
400 project
401 .update(cx, |project, cx| {
402 project.find_or_create_worktree(&path.path, true, cx)
403 })?
404 .await?;
405 }
406
407 window.update(cx, |_, cx| {
408 cx.replace_root_view(|cx| Workspace::new(None, project, app_state, cx))
409 })?;
410 window.update(cx, |_, cx| cx.activate_window())?;
411
412 Ok(())
413}