1use std::{path::PathBuf, sync::Arc, time::Duration};
2
3use anyhow::{anyhow, Result};
4use auto_update::AutoUpdater;
5use editor::Editor;
6use futures::channel::oneshot;
7use gpui::{
8 percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
9 EventEmitter, FocusableView, ParentElement as _, PromptLevel, Render, SemanticVersion,
10 SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
11};
12use gpui::{AppContext, Model};
13
14use language::CursorShape;
15use markdown::{Markdown, MarkdownStyle};
16use release_channel::{AppVersion, ReleaseChannel};
17use remote::ssh_session::{ServerBinary, ServerVersion};
18use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21use settings::{Settings, SettingsSources};
22use theme::ThemeSettings;
23use ui::{
24 prelude::*, ActiveTheme, Color, Icon, IconName, IconSize, InteractiveElement, IntoElement,
25 Label, LabelCommon, Styled, ViewContext, VisualContext, WindowContext,
26};
27use workspace::{AppState, ModalView, Workspace};
28
29#[derive(Deserialize)]
30pub struct SshSettings {
31 pub ssh_connections: Option<Vec<SshConnection>>,
32}
33
34impl SshSettings {
35 pub fn ssh_connections(&self) -> impl Iterator<Item = SshConnection> {
36 self.ssh_connections.clone().into_iter().flatten()
37 }
38
39 pub fn connection_options_for(
40 &self,
41 host: String,
42 port: Option<u16>,
43 username: Option<String>,
44 ) -> SshConnectionOptions {
45 for conn in self.ssh_connections() {
46 if conn.host == host && conn.username == username && conn.port == port {
47 return SshConnectionOptions {
48 nickname: conn.nickname,
49 upload_binary_over_ssh: conn.upload_binary_over_ssh.unwrap_or_default(),
50 args: Some(conn.args),
51 host,
52 port,
53 username,
54 password: None,
55 };
56 }
57 }
58 SshConnectionOptions {
59 host,
60 port,
61 username,
62 ..Default::default()
63 }
64 }
65}
66
67#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
68pub struct SshConnection {
69 pub host: SharedString,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub username: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub port: Option<u16>,
74 #[serde(skip_serializing_if = "Vec::is_empty")]
75 #[serde(default)]
76 pub args: Vec<String>,
77 #[serde(default)]
78 pub projects: Vec<SshProject>,
79 /// Name to use for this server in UI.
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub nickname: Option<String>,
82 // By default Zed will download the binary to the host directly.
83 // If this is set to true, Zed will download the binary to your local machine,
84 // and then upload it over the SSH connection. Useful if your SSH server has
85 // limited outbound internet access.
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub upload_binary_over_ssh: Option<bool>,
88}
89
90impl From<SshConnection> for SshConnectionOptions {
91 fn from(val: SshConnection) -> Self {
92 SshConnectionOptions {
93 host: val.host.into(),
94 username: val.username,
95 port: val.port,
96 password: None,
97 args: Some(val.args),
98 nickname: val.nickname,
99 upload_binary_over_ssh: val.upload_binary_over_ssh.unwrap_or_default(),
100 }
101 }
102}
103
104#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
105pub struct SshProject {
106 pub paths: Vec<String>,
107}
108
109#[derive(Clone, Default, Serialize, Deserialize, JsonSchema)]
110pub struct RemoteSettingsContent {
111 pub ssh_connections: Option<Vec<SshConnection>>,
112}
113
114impl Settings for SshSettings {
115 const KEY: Option<&'static str> = None;
116
117 type FileContent = RemoteSettingsContent;
118
119 fn load(sources: SettingsSources<Self::FileContent>, _: &mut AppContext) -> Result<Self> {
120 sources.json_merge()
121 }
122}
123
124pub struct SshPrompt {
125 connection_string: SharedString,
126 nickname: Option<SharedString>,
127 status_message: Option<SharedString>,
128 prompt: Option<(View<Markdown>, oneshot::Sender<Result<String>>)>,
129 cancellation: Option<oneshot::Sender<()>>,
130 editor: View<Editor>,
131}
132
133impl Drop for SshPrompt {
134 fn drop(&mut self) {
135 if let Some(cancel) = self.cancellation.take() {
136 cancel.send(()).ok();
137 }
138 }
139}
140
141pub struct SshConnectionModal {
142 pub(crate) prompt: View<SshPrompt>,
143 paths: Vec<PathBuf>,
144 finished: bool,
145}
146
147impl SshPrompt {
148 pub(crate) fn new(
149 connection_options: &SshConnectionOptions,
150 cx: &mut ViewContext<Self>,
151 ) -> Self {
152 let connection_string = connection_options.connection_string().into();
153 let nickname = connection_options.nickname.clone().map(|s| s.into());
154
155 Self {
156 connection_string,
157 nickname,
158 editor: cx.new_view(Editor::single_line),
159 status_message: None,
160 cancellation: None,
161 prompt: None,
162 }
163 }
164
165 pub fn set_cancellation_tx(&mut self, tx: oneshot::Sender<()>) {
166 self.cancellation = Some(tx);
167 }
168
169 pub fn set_prompt(
170 &mut self,
171 prompt: String,
172 tx: oneshot::Sender<Result<String>>,
173 cx: &mut ViewContext<Self>,
174 ) {
175 let theme = ThemeSettings::get_global(cx);
176
177 let mut text_style = cx.text_style();
178 let refinement = TextStyleRefinement {
179 font_family: Some(theme.buffer_font.family.clone()),
180 font_size: Some(theme.buffer_font_size.into()),
181 color: Some(cx.theme().colors().editor_foreground),
182 background_color: Some(gpui::transparent_black()),
183 ..Default::default()
184 };
185
186 text_style.refine(&refinement);
187 self.editor.update(cx, |editor, cx| {
188 if prompt.contains("yes/no") {
189 editor.set_masked(false, cx);
190 } else {
191 editor.set_masked(true, cx);
192 }
193 editor.set_text_style_refinement(refinement);
194 editor.set_cursor_shape(CursorShape::Block, cx);
195 });
196 let markdown_style = MarkdownStyle {
197 base_text_style: text_style,
198 selection_background_color: cx.theme().players().local().selection,
199 ..Default::default()
200 };
201 let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None));
202 self.prompt = Some((markdown, tx));
203 self.status_message.take();
204 cx.focus_view(&self.editor);
205 cx.notify();
206 }
207
208 pub fn set_status(&mut self, status: Option<String>, cx: &mut ViewContext<Self>) {
209 self.status_message = status.map(|s| s.into());
210 cx.notify();
211 }
212
213 pub fn confirm(&mut self, cx: &mut ViewContext<Self>) {
214 if let Some((_, tx)) = self.prompt.take() {
215 self.status_message = Some("Connecting".into());
216 self.editor.update(cx, |editor, cx| {
217 tx.send(Ok(editor.text(cx))).ok();
218 editor.clear(cx);
219 });
220 }
221 }
222}
223
224impl Render for SshPrompt {
225 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
226 let cx = cx.window_context();
227
228 v_flex()
229 .key_context("PasswordPrompt")
230 .py_2()
231 .px_3()
232 .size_full()
233 .text_buffer(cx)
234 .when_some(self.status_message.clone(), |el, status_message| {
235 el.child(
236 h_flex()
237 .gap_1()
238 .child(
239 Icon::new(IconName::ArrowCircle)
240 .size(IconSize::Medium)
241 .with_animation(
242 "arrow-circle",
243 Animation::new(Duration::from_secs(2)).repeat(),
244 |icon, delta| {
245 icon.transform(Transformation::rotate(percentage(delta)))
246 },
247 ),
248 )
249 .child(
250 div()
251 .text_ellipsis()
252 .overflow_x_hidden()
253 .child(format!("{}…", status_message)),
254 ),
255 )
256 })
257 .when_some(self.prompt.as_ref(), |el, prompt| {
258 el.child(
259 div()
260 .size_full()
261 .overflow_hidden()
262 .child(prompt.0.clone())
263 .child(self.editor.clone()),
264 )
265 })
266 }
267}
268
269impl SshConnectionModal {
270 pub(crate) fn new(
271 connection_options: &SshConnectionOptions,
272 paths: Vec<PathBuf>,
273 cx: &mut ViewContext<Self>,
274 ) -> Self {
275 Self {
276 prompt: cx.new_view(|cx| SshPrompt::new(connection_options, cx)),
277 finished: false,
278 paths,
279 }
280 }
281
282 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
283 self.prompt.update(cx, |prompt, cx| prompt.confirm(cx))
284 }
285
286 pub fn finished(&mut self, cx: &mut ViewContext<Self>) {
287 self.finished = true;
288 cx.emit(DismissEvent);
289 }
290
291 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
292 if let Some(tx) = self
293 .prompt
294 .update(cx, |prompt, _cx| prompt.cancellation.take())
295 {
296 tx.send(()).ok();
297 }
298 self.finished(cx);
299 }
300}
301
302pub(crate) struct SshConnectionHeader {
303 pub(crate) connection_string: SharedString,
304 pub(crate) paths: Vec<PathBuf>,
305 pub(crate) nickname: Option<SharedString>,
306}
307
308impl RenderOnce for SshConnectionHeader {
309 fn render(self, cx: &mut WindowContext) -> impl IntoElement {
310 let theme = cx.theme();
311
312 let mut header_color = theme.colors().text;
313 header_color.fade_out(0.96);
314
315 let (main_label, meta_label) = if let Some(nickname) = self.nickname {
316 (nickname, Some(format!("({})", self.connection_string)))
317 } else {
318 (self.connection_string, None)
319 };
320
321 h_flex()
322 .px(Spacing::XLarge.rems(cx))
323 .pt(Spacing::Large.rems(cx))
324 .pb(Spacing::Small.rems(cx))
325 .rounded_t_md()
326 .w_full()
327 .gap_1p5()
328 .child(Icon::new(IconName::Server).size(IconSize::XSmall))
329 .child(
330 h_flex()
331 .gap_1()
332 .overflow_x_hidden()
333 .child(
334 div()
335 .max_w_96()
336 .overflow_x_hidden()
337 .text_ellipsis()
338 .child(Headline::new(main_label).size(HeadlineSize::XSmall)),
339 )
340 .children(
341 meta_label.map(|label| {
342 Label::new(label).color(Color::Muted).size(LabelSize::Small)
343 }),
344 )
345 .child(div().overflow_x_hidden().text_ellipsis().children(
346 self.paths.into_iter().map(|path| {
347 Label::new(path.to_string_lossy().to_string())
348 .size(LabelSize::Small)
349 .color(Color::Muted)
350 }),
351 )),
352 )
353 }
354}
355
356impl Render for SshConnectionModal {
357 fn render(&mut self, cx: &mut ui::ViewContext<Self>) -> impl ui::IntoElement {
358 let nickname = self.prompt.read(cx).nickname.clone();
359 let connection_string = self.prompt.read(cx).connection_string.clone();
360
361 let theme = cx.theme().clone();
362 let body_color = theme.colors().editor_background;
363
364 v_flex()
365 .elevation_3(cx)
366 .w(rems(34.))
367 .border_1()
368 .border_color(theme.colors().border)
369 .key_context("SshConnectionModal")
370 .track_focus(&self.focus_handle(cx))
371 .on_action(cx.listener(Self::dismiss))
372 .on_action(cx.listener(Self::confirm))
373 .child(
374 SshConnectionHeader {
375 paths: self.paths.clone(),
376 connection_string,
377 nickname,
378 }
379 .render(cx),
380 )
381 .child(
382 div()
383 .w_full()
384 .rounded_b_lg()
385 .bg(body_color)
386 .border_t_1()
387 .border_color(theme.colors().border_variant)
388 .child(self.prompt.clone()),
389 )
390 }
391}
392
393impl FocusableView for SshConnectionModal {
394 fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
395 self.prompt.read(cx).editor.focus_handle(cx)
396 }
397}
398
399impl EventEmitter<DismissEvent> for SshConnectionModal {}
400
401impl ModalView for SshConnectionModal {
402 fn on_before_dismiss(&mut self, _: &mut ViewContext<Self>) -> workspace::DismissDecision {
403 return workspace::DismissDecision::Dismiss(self.finished);
404 }
405
406 fn fade_out_background(&self) -> bool {
407 true
408 }
409}
410
411#[derive(Clone)]
412pub struct SshClientDelegate {
413 window: AnyWindowHandle,
414 ui: WeakView<SshPrompt>,
415 known_password: Option<String>,
416}
417
418impl remote::SshClientDelegate for SshClientDelegate {
419 fn ask_password(
420 &self,
421 prompt: String,
422 cx: &mut AsyncAppContext,
423 ) -> oneshot::Receiver<Result<String>> {
424 let (tx, rx) = oneshot::channel();
425 let mut known_password = self.known_password.clone();
426 if let Some(password) = known_password.take() {
427 tx.send(Ok(password)).ok();
428 } else {
429 self.window
430 .update(cx, |_, cx| {
431 self.ui.update(cx, |modal, cx| {
432 modal.set_prompt(prompt, tx, cx);
433 })
434 })
435 .ok();
436 }
437 rx
438 }
439
440 fn set_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
441 self.update_status(status, cx)
442 }
443
444 fn get_server_binary(
445 &self,
446 platform: SshPlatform,
447 upload_binary_over_ssh: bool,
448 cx: &mut AsyncAppContext,
449 ) -> oneshot::Receiver<Result<(ServerBinary, ServerVersion)>> {
450 let (tx, rx) = oneshot::channel();
451 let this = self.clone();
452 cx.spawn(|mut cx| async move {
453 tx.send(
454 this.get_server_binary_impl(platform, upload_binary_over_ssh, &mut cx)
455 .await,
456 )
457 .ok();
458 })
459 .detach();
460 rx
461 }
462
463 fn remote_server_binary_path(
464 &self,
465 platform: SshPlatform,
466 cx: &mut AsyncAppContext,
467 ) -> Result<PathBuf> {
468 let release_channel = cx.update(|cx| ReleaseChannel::global(cx))?;
469 Ok(paths::remote_server_dir_relative().join(format!(
470 "zed-remote-server-{}-{}-{}",
471 release_channel.dev_name(),
472 platform.os,
473 platform.arch
474 )))
475 }
476}
477
478impl SshClientDelegate {
479 fn update_status(&self, status: Option<&str>, cx: &mut AsyncAppContext) {
480 self.window
481 .update(cx, |_, cx| {
482 self.ui.update(cx, |modal, cx| {
483 modal.set_status(status.map(|s| s.to_string()), cx);
484 })
485 })
486 .ok();
487 }
488
489 async fn get_server_binary_impl(
490 &self,
491 platform: SshPlatform,
492 upload_binary_via_ssh: bool,
493 cx: &mut AsyncAppContext,
494 ) -> Result<(ServerBinary, ServerVersion)> {
495 let (version, release_channel) = cx.update(|cx| {
496 let version = AppVersion::global(cx);
497 let channel = ReleaseChannel::global(cx);
498
499 (version, channel)
500 })?;
501
502 // In dev mode, build the remote server binary from source
503 #[cfg(debug_assertions)]
504 if release_channel == ReleaseChannel::Dev {
505 let result = self.build_local(cx, platform, version).await?;
506 // Fall through to a remote binary if we're not able to compile a local binary
507 if let Some((path, version)) = result {
508 return Ok((
509 ServerBinary::LocalBinary(path),
510 ServerVersion::Semantic(version),
511 ));
512 }
513 }
514
515 // For nightly channel, always get latest
516 let current_version = if release_channel == ReleaseChannel::Nightly {
517 None
518 } else {
519 Some(version)
520 };
521
522 self.update_status(
523 Some(&format!("Checking remote server release {}", version)),
524 cx,
525 );
526
527 if upload_binary_via_ssh {
528 let binary_path = AutoUpdater::download_remote_server_release(
529 platform.os,
530 platform.arch,
531 release_channel,
532 current_version,
533 cx,
534 )
535 .await
536 .map_err(|e| {
537 anyhow!(
538 "Failed to download remote server binary (version: {}, os: {}, arch: {}): {}",
539 version,
540 platform.os,
541 platform.arch,
542 e
543 )
544 })?;
545
546 Ok((
547 ServerBinary::LocalBinary(binary_path),
548 ServerVersion::Semantic(version),
549 ))
550 } else {
551 let (release, request_body) = AutoUpdater::get_remote_server_release_url(
552 platform.os,
553 platform.arch,
554 release_channel,
555 current_version,
556 cx,
557 )
558 .await
559 .map_err(|e| {
560 anyhow!(
561 "Failed to get remote server binary download url (version: {}, os: {}, arch: {}): {}",
562 version,
563 platform.os,
564 platform.arch,
565 e
566 )
567 })?;
568
569 let version = release
570 .version
571 .parse::<SemanticVersion>()
572 .map(ServerVersion::Semantic)
573 .unwrap_or_else(|_| ServerVersion::Commit(release.version));
574 Ok((
575 ServerBinary::ReleaseUrl {
576 url: release.url,
577 body: request_body,
578 },
579 version,
580 ))
581 }
582 }
583
584 #[cfg(debug_assertions)]
585 async fn build_local(
586 &self,
587 cx: &mut AsyncAppContext,
588 platform: SshPlatform,
589 version: gpui::SemanticVersion,
590 ) -> Result<Option<(PathBuf, gpui::SemanticVersion)>> {
591 use smol::process::{Command, Stdio};
592
593 async fn run_cmd(command: &mut Command) -> Result<()> {
594 let output = command
595 .kill_on_drop(true)
596 .stderr(Stdio::inherit())
597 .output()
598 .await?;
599 if !output.status.success() {
600 Err(anyhow!("Failed to run command: {:?}", command))?;
601 }
602 Ok(())
603 }
604
605 if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
606 self.update_status(Some("Building remote server binary from source"), cx);
607 log::info!("building remote server binary from source");
608 run_cmd(Command::new("cargo").args([
609 "build",
610 "--package",
611 "remote_server",
612 "--features",
613 "debug-embed",
614 "--target-dir",
615 "target/remote_server",
616 ]))
617 .await?;
618
619 self.update_status(Some("Compressing binary"), cx);
620
621 run_cmd(Command::new("gzip").args([
622 "-9",
623 "-f",
624 "target/remote_server/debug/remote_server",
625 ]))
626 .await?;
627
628 let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
629 return Ok(Some((path, version)));
630 } else if let Some(triple) = platform.triple() {
631 smol::fs::create_dir_all("target/remote_server").await?;
632
633 self.update_status(Some("Installing cross.rs for cross-compilation"), cx);
634 log::info!("installing cross");
635 run_cmd(Command::new("cargo").args([
636 "install",
637 "cross",
638 "--git",
639 "https://github.com/cross-rs/cross",
640 ]))
641 .await?;
642
643 self.update_status(
644 Some(&format!(
645 "Building remote server binary from source for {} with Docker",
646 &triple
647 )),
648 cx,
649 );
650 log::info!("building remote server binary from source for {}", &triple);
651 run_cmd(
652 Command::new("cross")
653 .args([
654 "build",
655 "--package",
656 "remote_server",
657 "--features",
658 "debug-embed",
659 "--target-dir",
660 "target/remote_server",
661 "--target",
662 &triple,
663 ])
664 .env(
665 "CROSS_CONTAINER_OPTS",
666 "--mount type=bind,src=./target,dst=/app/target",
667 ),
668 )
669 .await?;
670
671 self.update_status(Some("Compressing binary"), cx);
672
673 run_cmd(Command::new("gzip").args([
674 "-9",
675 "-f",
676 &format!("target/remote_server/{}/debug/remote_server", triple),
677 ]))
678 .await?;
679
680 let path = std::env::current_dir()?.join(format!(
681 "target/remote_server/{}/debug/remote_server.gz",
682 triple
683 ));
684
685 return Ok(Some((path, version)));
686 } else {
687 return Ok(None);
688 }
689 }
690}
691
692pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &AppContext) -> bool {
693 workspace.active_modal::<SshConnectionModal>(cx).is_some()
694}
695
696pub fn connect_over_ssh(
697 unique_identifier: String,
698 connection_options: SshConnectionOptions,
699 ui: View<SshPrompt>,
700 cx: &mut WindowContext,
701) -> Task<Result<Option<Model<SshRemoteClient>>>> {
702 let window = cx.window_handle();
703 let known_password = connection_options.password.clone();
704 let (tx, rx) = oneshot::channel();
705 ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
706
707 remote::SshRemoteClient::new(
708 unique_identifier,
709 connection_options,
710 rx,
711 Arc::new(SshClientDelegate {
712 window,
713 ui: ui.downgrade(),
714 known_password,
715 }),
716 cx,
717 )
718}
719
720pub async fn open_ssh_project(
721 connection_options: SshConnectionOptions,
722 paths: Vec<PathBuf>,
723 app_state: Arc<AppState>,
724 open_options: workspace::OpenOptions,
725 cx: &mut AsyncAppContext,
726) -> Result<()> {
727 let window = if let Some(window) = open_options.replace_window {
728 window
729 } else {
730 let options = cx.update(|cx| (app_state.build_window_options)(None, cx))?;
731 cx.open_window(options, |cx| {
732 let project = project::Project::local(
733 app_state.client.clone(),
734 app_state.node_runtime.clone(),
735 app_state.user_store.clone(),
736 app_state.languages.clone(),
737 app_state.fs.clone(),
738 None,
739 cx,
740 );
741 cx.new_view(|cx| Workspace::new(None, project, app_state.clone(), cx))
742 })?
743 };
744
745 loop {
746 let (cancel_tx, cancel_rx) = oneshot::channel();
747 let delegate = window.update(cx, {
748 let connection_options = connection_options.clone();
749 let paths = paths.clone();
750 move |workspace, cx| {
751 cx.activate_window();
752 workspace.toggle_modal(cx, |cx| {
753 SshConnectionModal::new(&connection_options, paths, cx)
754 });
755
756 let ui = workspace
757 .active_modal::<SshConnectionModal>(cx)?
758 .read(cx)
759 .prompt
760 .clone();
761
762 ui.update(cx, |ui, _cx| {
763 ui.set_cancellation_tx(cancel_tx);
764 });
765
766 Some(Arc::new(SshClientDelegate {
767 window: cx.window_handle(),
768 ui: ui.downgrade(),
769 known_password: connection_options.password.clone(),
770 }))
771 }
772 })?;
773
774 let Some(delegate) = delegate else { break };
775
776 let did_open_ssh_project = cx
777 .update(|cx| {
778 workspace::open_ssh_project(
779 window,
780 connection_options.clone(),
781 cancel_rx,
782 delegate.clone(),
783 app_state.clone(),
784 paths.clone(),
785 cx,
786 )
787 })?
788 .await;
789
790 window
791 .update(cx, |workspace, cx| {
792 if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
793 ui.update(cx, |modal, cx| modal.finished(cx))
794 }
795 })
796 .ok();
797
798 if let Err(e) = did_open_ssh_project {
799 log::error!("Failed to open project: {:?}", e);
800 let response = window
801 .update(cx, |_, cx| {
802 cx.prompt(
803 PromptLevel::Critical,
804 "Failed to connect over SSH",
805 Some(&e.to_string()),
806 &["Retry", "Ok"],
807 )
808 })?
809 .await;
810
811 if response == Ok(0) {
812 continue;
813 }
814 }
815
816 break;
817 }
818
819 // Already showed the error to the user
820 Ok(())
821}