1use std::time::Duration;
2
3use anyhow::anyhow;
4use anyhow::Context;
5use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
6use editor::Editor;
7use feature_flags::FeatureFlagAppExt;
8use feature_flags::FeatureFlagViewExt;
9use gpui::AsyncWindowContext;
10use gpui::Subscription;
11use gpui::Task;
12use gpui::WeakView;
13use gpui::{
14 percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
15 FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
16};
17use markdown::Markdown;
18use markdown::MarkdownStyle;
19use rpc::proto::RegenerateDevServerTokenResponse;
20use rpc::{
21 proto::{CreateDevServerResponse, DevServerStatus},
22 ErrorCode, ErrorExt,
23};
24use task::RevealStrategy;
25use task::SpawnInTerminal;
26use task::TerminalWorkDir;
27use terminal_view::terminal_panel::TerminalPanel;
28use ui::ElevationIndex;
29use ui::Section;
30use ui::{
31 prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
32 RadioWithLabel, Tooltip,
33};
34use ui_text_field::{FieldLabelLayout, TextField};
35use util::ResultExt;
36use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
37
38use crate::OpenRemote;
39
40pub struct DevServerProjects {
41 mode: Mode,
42 focus_handle: FocusHandle,
43 scroll_handle: ScrollHandle,
44 dev_server_store: Model<dev_server_projects::Store>,
45 workspace: WeakView<Workspace>,
46 project_path_input: View<Editor>,
47 dev_server_name_input: View<TextField>,
48 markdown: View<Markdown>,
49 _dev_server_subscription: Subscription,
50}
51
52#[derive(Default)]
53struct CreateDevServer {
54 creating: Option<Task<()>>,
55 dev_server_id: Option<DevServerId>,
56 access_token: Option<String>,
57 manual_setup: bool,
58}
59
60struct CreateDevServerProject {
61 dev_server_id: DevServerId,
62 creating: bool,
63 _opening: Option<Subscription>,
64}
65
66enum Mode {
67 Default(Option<CreateDevServerProject>),
68 CreateDevServer(CreateDevServer),
69}
70
71impl DevServerProjects {
72 pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
73 cx.observe_flag::<feature_flags::Remoting, _>(|enabled, workspace, _| {
74 if enabled {
75 Self::register_open_remote_action(workspace);
76 }
77 })
78 .detach();
79
80 if cx.has_flag::<feature_flags::Remoting>() {
81 Self::register_open_remote_action(workspace);
82 }
83 }
84
85 fn register_open_remote_action(workspace: &mut Workspace) {
86 workspace.register_action(|workspace, _: &OpenRemote, cx| {
87 let handle = cx.view().downgrade();
88 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
89 });
90 }
91
92 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
93 workspace.update(cx, |workspace, cx| {
94 let handle = cx.view().downgrade();
95 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
96 })
97 }
98
99 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
100 let project_path_input = cx.new_view(|cx| {
101 let mut editor = Editor::single_line(cx);
102 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
103 editor
104 });
105 let dev_server_name_input = cx.new_view(|cx| {
106 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
107 });
108
109 let focus_handle = cx.focus_handle();
110 let dev_server_store = dev_server_projects::Store::global(cx);
111
112 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
113 cx.notify();
114 });
115
116 let markdown_style = MarkdownStyle {
117 code_block: gpui::TextStyleRefinement {
118 font_family: Some("Zed Mono".into()),
119 color: Some(cx.theme().colors().editor_foreground),
120 background_color: Some(cx.theme().colors().editor_background),
121 ..Default::default()
122 },
123 inline_code: Default::default(),
124 block_quote: Default::default(),
125 link: gpui::TextStyleRefinement {
126 color: Some(Color::Accent.color(cx)),
127 ..Default::default()
128 },
129 rule_color: Default::default(),
130 block_quote_border_color: Default::default(),
131 syntax: cx.theme().syntax().clone(),
132 selection_background_color: cx.theme().players().local().selection,
133 };
134 let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx));
135
136 Self {
137 mode: Mode::Default(None),
138 focus_handle,
139 scroll_handle: ScrollHandle::new(),
140 dev_server_store,
141 project_path_input,
142 dev_server_name_input,
143 markdown,
144 workspace,
145 _dev_server_subscription: subscription,
146 }
147 }
148
149 pub fn create_dev_server_project(
150 &mut self,
151 dev_server_id: DevServerId,
152 cx: &mut ViewContext<Self>,
153 ) {
154 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
155
156 if path == "" {
157 return;
158 }
159
160 if !path.starts_with('/') && !path.starts_with('~') {
161 path = format!("~/{}", path);
162 }
163
164 if self
165 .dev_server_store
166 .read(cx)
167 .projects_for_server(dev_server_id)
168 .iter()
169 .any(|p| p.path == path)
170 {
171 cx.spawn(|_, mut cx| async move {
172 cx.prompt(
173 gpui::PromptLevel::Critical,
174 "Failed to create project",
175 Some(&format!(
176 "Project {} already exists for this dev server.",
177 path
178 )),
179 &["Ok"],
180 )
181 .await
182 })
183 .detach_and_log_err(cx);
184 return;
185 }
186
187 let create = {
188 let path = path.clone();
189 self.dev_server_store.update(cx, |store, cx| {
190 store.create_dev_server_project(dev_server_id, path, cx)
191 })
192 };
193
194 cx.spawn(|this, mut cx| async move {
195 let result = create.await;
196 this.update(&mut cx, |this, cx| {
197 if let Ok(result) = &result {
198 if let Some(dev_server_project_id) =
199 result.dev_server_project.as_ref().map(|p| p.id)
200 {
201 let subscription =
202 cx.observe(&this.dev_server_store, move |this, store, cx| {
203 if let Some(project_id) = store
204 .read(cx)
205 .dev_server_project(DevServerProjectId(dev_server_project_id))
206 .and_then(|p| p.project_id)
207 {
208 this.project_path_input.update(cx, |editor, cx| {
209 editor.set_text("", cx);
210 });
211 this.mode = Mode::Default(None);
212 if let Some(app_state) = AppState::global(cx).upgrade() {
213 workspace::join_dev_server_project(
214 project_id, app_state, None, cx,
215 )
216 .detach_and_prompt_err(
217 "Could not join project",
218 cx,
219 |_, _| None,
220 )
221 }
222 }
223 });
224
225 this.mode = Mode::Default(Some(CreateDevServerProject {
226 dev_server_id,
227 creating: true,
228 _opening: Some(subscription),
229 }));
230 }
231 } else {
232 this.mode = Mode::Default(Some(CreateDevServerProject {
233 dev_server_id,
234 creating: false,
235 _opening: None,
236 }));
237 }
238 })
239 .log_err();
240 result
241 })
242 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
243 match e.error_code() {
244 ErrorCode::DevServerOffline => Some(
245 "The dev server is offline. Please log in and check it is connected."
246 .to_string(),
247 ),
248 ErrorCode::DevServerProjectPathDoesNotExist => {
249 Some(format!("The path `{}` does not exist on the server.", path))
250 }
251 _ => None,
252 }
253 });
254
255 self.mode = Mode::Default(Some(CreateDevServerProject {
256 dev_server_id,
257 creating: true,
258 _opening: None,
259 }));
260 }
261
262 pub fn create_or_update_dev_server(
263 &mut self,
264 manual_setup: bool,
265 existing_id: Option<DevServerId>,
266 access_token: Option<String>,
267 cx: &mut ViewContext<Self>,
268 ) {
269 let name = get_text(&self.dev_server_name_input, cx);
270 if name.is_empty() {
271 return;
272 }
273
274 let ssh_connection_string = if manual_setup {
275 None
276 } else if name.contains(' ') {
277 Some(name.clone())
278 } else {
279 Some(format!("ssh {}", name))
280 };
281
282 let dev_server = self.dev_server_store.update(cx, {
283 let access_token = access_token.clone();
284 |store, cx| {
285 let ssh_connection_string = ssh_connection_string.clone();
286 if let Some(dev_server_id) = existing_id {
287 let rename = store.rename_dev_server(
288 dev_server_id,
289 name.clone(),
290 ssh_connection_string,
291 cx,
292 );
293 let token = if let Some(access_token) = access_token {
294 Task::ready(Ok(RegenerateDevServerTokenResponse {
295 dev_server_id: dev_server_id.0,
296 access_token,
297 }))
298 } else {
299 store.regenerate_dev_server_token(dev_server_id, cx)
300 };
301 cx.spawn(|_, _| async move {
302 rename.await?;
303 let response = token.await?;
304 Ok(CreateDevServerResponse {
305 dev_server_id: dev_server_id.0,
306 name,
307 access_token: response.access_token,
308 })
309 })
310 } else {
311 store.create_dev_server(name, ssh_connection_string.clone(), cx)
312 }
313 }
314 });
315
316 let workspace = self.workspace.clone();
317 let store = dev_server_projects::Store::global(cx);
318
319 let task = cx
320 .spawn({
321 |this, mut cx| async move {
322 let result = dev_server.await;
323
324 match result {
325 Ok(dev_server) => {
326 if let Some(ssh_connection_string) = ssh_connection_string {
327 this.update(&mut cx, |this, cx| {
328 if let Mode::CreateDevServer(CreateDevServer {
329 access_token,
330 dev_server_id,
331 ..
332 }) = &mut this.mode
333 {
334 access_token.replace(dev_server.access_token.clone());
335 dev_server_id
336 .replace(DevServerId(dev_server.dev_server_id));
337 }
338 cx.notify();
339 })?;
340
341 spawn_ssh_task(
342 workspace
343 .upgrade()
344 .ok_or_else(|| anyhow!("workspace dropped"))?,
345 store,
346 DevServerId(dev_server.dev_server_id),
347 ssh_connection_string,
348 dev_server.access_token.clone(),
349 &mut cx,
350 )
351 .await
352 .log_err();
353 }
354
355 this.update(&mut cx, |this, cx| {
356 this.focus_handle.focus(cx);
357 this.mode = Mode::CreateDevServer(CreateDevServer {
358 creating: None,
359 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
360 access_token: Some(dev_server.access_token),
361 manual_setup,
362 });
363 cx.notify();
364 })?;
365 Ok(())
366 }
367 Err(e) => {
368 this.update(&mut cx, |this, cx| {
369 this.mode = Mode::CreateDevServer(CreateDevServer {
370 creating: None,
371 dev_server_id: existing_id,
372 access_token: None,
373 manual_setup,
374 });
375 cx.notify()
376 })
377 .log_err();
378
379 return Err(e);
380 }
381 }
382 }
383 })
384 .prompt_err("Failed to create server", cx, |_, _| None);
385
386 self.mode = Mode::CreateDevServer(CreateDevServer {
387 creating: Some(task),
388 dev_server_id: existing_id,
389 access_token,
390 manual_setup,
391 });
392 cx.notify()
393 }
394
395 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
396 let store = self.dev_server_store.read(cx);
397 let prompt = if store.projects_for_server(id).is_empty()
398 && store
399 .dev_server(id)
400 .is_some_and(|server| server.status == DevServerStatus::Offline)
401 {
402 None
403 } else {
404 Some(cx.prompt(
405 gpui::PromptLevel::Warning,
406 "Are you sure?",
407 Some("This will delete the dev server and all of its remote projects."),
408 &["Delete", "Cancel"],
409 ))
410 };
411
412 cx.spawn(|this, mut cx| async move {
413 if let Some(prompt) = prompt {
414 if prompt.await? != 0 {
415 return Ok(());
416 }
417 }
418
419 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
420 this.dev_server_store.update(cx, |store, _| {
421 store
422 .projects_for_server(id)
423 .into_iter()
424 .map(|project| project.id)
425 .collect()
426 })
427 })?;
428
429 this.update(&mut cx, |this, cx| {
430 this.dev_server_store
431 .update(cx, |store, cx| store.delete_dev_server(id, cx))
432 })?
433 .await?;
434
435 for id in project_ids {
436 WORKSPACE_DB
437 .delete_workspace_by_dev_server_project_id(id)
438 .await
439 .log_err();
440 }
441 Ok(())
442 })
443 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
444 }
445
446 fn delete_dev_server_project(
447 &mut self,
448 id: DevServerProjectId,
449 path: &str,
450 cx: &mut ViewContext<Self>,
451 ) {
452 let answer = cx.prompt(
453 gpui::PromptLevel::Warning,
454 format!("Delete \"{}\"?", path).as_str(),
455 Some("This will delete the remote project. You can always re-add it later."),
456 &["Delete", "Cancel"],
457 );
458
459 cx.spawn(|this, mut cx| async move {
460 let answer = answer.await?;
461
462 if answer != 0 {
463 return Ok(());
464 }
465
466 this.update(&mut cx, |this, cx| {
467 this.dev_server_store
468 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
469 })?
470 .await?;
471
472 WORKSPACE_DB
473 .delete_workspace_by_dev_server_project_id(id)
474 .await
475 .log_err();
476
477 Ok(())
478 })
479 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
480 }
481
482 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
483 match &self.mode {
484 Mode::Default(None) => {}
485 Mode::Default(Some(create_project)) => {
486 self.create_dev_server_project(create_project.dev_server_id, cx);
487 }
488 Mode::CreateDevServer(state) => {
489 if state.creating.is_none() || state.dev_server_id.is_some() {
490 self.create_or_update_dev_server(
491 state.manual_setup,
492 state.dev_server_id,
493 state.access_token.clone(),
494 cx,
495 );
496 }
497 }
498 }
499 }
500
501 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
502 match self.mode {
503 Mode::Default(None) => cx.emit(DismissEvent),
504 _ => {
505 self.mode = Mode::Default(None);
506 self.focus_handle(cx).focus(cx);
507 cx.notify();
508 }
509 }
510 }
511
512 fn render_dev_server(
513 &mut self,
514 dev_server: &DevServer,
515 create_project: Option<bool>,
516 cx: &mut ViewContext<Self>,
517 ) -> impl IntoElement {
518 let dev_server_id = dev_server.id;
519 let status = dev_server.status;
520 let dev_server_name = dev_server.name.clone();
521 let manual_setup = dev_server.ssh_connection_string.is_none();
522
523 v_flex()
524 .w_full()
525 .child(
526 h_flex().group("dev-server").justify_between().child(
527 h_flex()
528 .gap_2()
529 .child(
530 div()
531 .id(("status", dev_server.id.0))
532 .relative()
533 .child(Icon::new(IconName::Server).size(IconSize::Small))
534 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
535 Indicator::dot().color(match status {
536 DevServerStatus::Online => Color::Created,
537 DevServerStatus::Offline => Color::Hidden,
538 }),
539 ))
540 .tooltip(move |cx| {
541 Tooltip::text(
542 match status {
543 DevServerStatus::Online => "Online",
544 DevServerStatus::Offline => "Offline",
545 },
546 cx,
547 )
548 }),
549 )
550 .child(
551 div()
552 .max_w(rems(26.))
553 .overflow_hidden()
554 .whitespace_nowrap()
555 .child(Label::new(dev_server_name.clone())),
556 )
557 .child(
558 h_flex()
559 .visible_on_hover("dev-server")
560 .gap_1()
561 .child(
562 IconButton::new("edit-dev-server", IconName::Pencil)
563 .on_click(cx.listener(move |this, _, cx| {
564 this.mode = Mode::CreateDevServer(CreateDevServer {
565 dev_server_id: Some(dev_server_id),
566 creating: None,
567 access_token: None,
568 manual_setup,
569 });
570 let dev_server_name = dev_server_name.clone();
571 this.dev_server_name_input.update(
572 cx,
573 move |input, cx| {
574 input.editor().update(cx, move |editor, cx| {
575 editor.set_text(dev_server_name, cx)
576 })
577 },
578 )
579 }))
580 .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
581 )
582 .child({
583 let dev_server_id = dev_server.id;
584 IconButton::new("remove-dev-server", IconName::Trash)
585 .on_click(cx.listener(move |this, _, cx| {
586 this.delete_dev_server(dev_server_id, cx)
587 }))
588 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
589 }),
590 ),
591 ),
592 )
593 .child(
594 v_flex()
595 .w_full()
596 .bg(cx.theme().colors().background)
597 .border_1()
598 .border_color(cx.theme().colors().border_variant)
599 .rounded_md()
600 .my_1()
601 .py_0p5()
602 .px_3()
603 .child(
604 List::new()
605 .empty_message("No projects.")
606 .children(
607 self.dev_server_store
608 .read(cx)
609 .projects_for_server(dev_server.id)
610 .iter()
611 .map(|p| self.render_dev_server_project(p, cx)),
612 )
613 .when(
614 create_project.is_none()
615 && dev_server.status == DevServerStatus::Online,
616 |el| {
617 el.child(
618 ListItem::new("new-remote_project")
619 .start_slot(Icon::new(IconName::Plus))
620 .child(Label::new("Open folder…"))
621 .on_click(cx.listener(move |this, _, cx| {
622 this.mode =
623 Mode::Default(Some(CreateDevServerProject {
624 dev_server_id,
625 creating: false,
626 _opening: None,
627 }));
628 this.project_path_input
629 .read(cx)
630 .focus_handle(cx)
631 .focus(cx);
632 cx.notify();
633 })),
634 )
635 },
636 )
637 .when_some(create_project, |el, creating| {
638 el.child(self.render_create_new_project(creating, cx))
639 }),
640 ),
641 )
642 }
643
644 fn render_create_new_project(
645 &mut self,
646 creating: bool,
647 _: &mut ViewContext<Self>,
648 ) -> impl IntoElement {
649 ListItem::new("create-remote-project")
650 .disabled(true)
651 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
652 .child(self.project_path_input.clone())
653 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
654 el.child(
655 Icon::new(IconName::ArrowCircle)
656 .size(IconSize::Medium)
657 .with_animation(
658 "arrow-circle",
659 Animation::new(Duration::from_secs(2)).repeat(),
660 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
661 ),
662 )
663 }))
664 }
665
666 fn render_dev_server_project(
667 &mut self,
668 project: &DevServerProject,
669 cx: &mut ViewContext<Self>,
670 ) -> impl IntoElement {
671 let dev_server_project_id = project.id;
672 let project_id = project.project_id;
673 let is_online = project_id.is_some();
674 let project_path = project.path.clone();
675
676 ListItem::new(("remote-project", dev_server_project_id.0))
677 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
678 .child(
679 Label::new(project.path.clone())
680 )
681 .on_click(cx.listener(move |_, _, cx| {
682 if let Some(project_id) = project_id {
683 if let Some(app_state) = AppState::global(cx).upgrade() {
684 workspace::join_dev_server_project(project_id, app_state, None, cx)
685 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
686 }
687 } else {
688 cx.spawn(|_, mut cx| async move {
689 cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err();
690 }).detach();
691 }
692 }))
693 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
694 .on_click(cx.listener(move |this, _, cx| {
695 this.delete_dev_server_project(dev_server_project_id, &project_path, cx)
696 }))
697 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
698 }
699
700 fn render_create_dev_server(
701 &self,
702 state: &CreateDevServer,
703 cx: &mut ViewContext<Self>,
704 ) -> impl IntoElement {
705 let creating = state.creating.is_some();
706 let dev_server_id = state.dev_server_id;
707 let access_token = state.access_token.clone();
708 let manual_setup = state.manual_setup;
709
710 let status = dev_server_id
711 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
712 .unwrap_or_default();
713
714 let name = self.dev_server_name_input.update(cx, |input, cx| {
715 input.editor().update(cx, |editor, cx| {
716 if editor.text(cx).is_empty() {
717 if manual_setup {
718 editor.set_placeholder_text("example-server", cx)
719 } else {
720 editor.set_placeholder_text("ssh host", cx)
721 }
722 }
723 editor.text(cx)
724 })
725 });
726
727 const MANUAL_SETUP_MESSAGE: &str = "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine.";
728 const SSH_SETUP_MESSAGE: &str = "Enter the command you use to ssh into this server.\nFor example: `ssh me@my.server` or `gh cs ssh -c example`.";
729
730 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
731 .header(
732 ModalHeader::new()
733 .headline("Create Dev Server")
734 .show_back_button(true),
735 )
736 .section(
737 Section::new()
738 .header(if manual_setup {
739 "Server Name".into()
740 } else {
741 "SSH arguments".into()
742 })
743 .child(
744 div()
745 .max_w(rems(16.))
746 .child(self.dev_server_name_input.clone()),
747 ),
748 )
749 .section(
750 Section::new_contained()
751 .header("Connection Method".into())
752 .child(
753 v_flex()
754 .w_full()
755 .gap_y(Spacing::Large.rems(cx))
756 .child(
757 v_flex()
758 .child(RadioWithLabel::new(
759 "use-server-name-in-ssh",
760 Label::new("Connect via SSH (default)"),
761 !manual_setup,
762 cx.listener({
763 move |this, _, cx| {
764 if let Mode::CreateDevServer(CreateDevServer {
765 manual_setup,
766 ..
767 }) = &mut this.mode
768 {
769 *manual_setup = false;
770 }
771 cx.notify()
772 }
773 }),
774 ))
775 .child(RadioWithLabel::new(
776 "use-server-name-in-ssh",
777 Label::new("Manual Setup"),
778 manual_setup,
779 cx.listener({
780 move |this, _, cx| {
781 if let Mode::CreateDevServer(CreateDevServer {
782 manual_setup,
783 ..
784 }) = &mut this.mode
785 {
786 *manual_setup = true;
787 }
788 cx.notify()
789 }
790 }),
791 )),
792 )
793 .when(dev_server_id.is_none(), |el| {
794 el.child(
795 if manual_setup {
796 Label::new(MANUAL_SETUP_MESSAGE)
797 } else {
798 Label::new(SSH_SETUP_MESSAGE)
799 }
800 .size(LabelSize::Small)
801 .color(Color::Muted),
802 )
803 })
804 .when(dev_server_id.is_some() && access_token.is_none(), |el| {
805 el.child(
806 if manual_setup {
807 Label::new(
808 "Note: updating the dev server generate a new token",
809 )
810 } else {
811 Label::new(
812 "Enter the command you use to ssh into this server.\n\
813 For example: `ssh me@my.server` or `gh cs ssh -c example`.",
814 )
815 }
816 .size(LabelSize::Small)
817 .color(Color::Muted),
818 )
819 })
820 .when_some(access_token.clone(), {
821 |el, access_token| {
822 el.child(self.render_dev_server_token_creating(
823 access_token,
824 name,
825 manual_setup,
826 status,
827 creating,
828 cx,
829 ))
830 }
831 }),
832 ),
833 )
834 .footer(
835 ModalFooter::new().end_slot(if status == DevServerStatus::Online {
836 Button::new("create-dev-server", "Done")
837 .style(ButtonStyle::Filled)
838 .layer(ElevationIndex::ModalSurface)
839 .on_click(cx.listener(move |this, _, cx| {
840 cx.focus(&this.focus_handle);
841 this.mode = Mode::Default(None);
842 cx.notify();
843 }))
844 } else {
845 Button::new(
846 "create-dev-server",
847 if manual_setup {
848 if dev_server_id.is_some() {
849 "Update"
850 } else {
851 "Create"
852 }
853 } else {
854 if dev_server_id.is_some() {
855 "Reconnect"
856 } else {
857 "Connect"
858 }
859 },
860 )
861 .style(ButtonStyle::Filled)
862 .layer(ElevationIndex::ModalSurface)
863 .disabled(creating && dev_server_id.is_none())
864 .on_click(cx.listener({
865 let access_token = access_token.clone();
866 move |this, _, cx| {
867 this.create_or_update_dev_server(
868 manual_setup,
869 dev_server_id,
870 access_token.clone(),
871 cx,
872 );
873 }
874 }))
875 }),
876 )
877 }
878
879 fn render_dev_server_token_creating(
880 &self,
881 access_token: String,
882 dev_server_name: String,
883 manual_setup: bool,
884 status: DevServerStatus,
885 creating: bool,
886 cx: &mut ViewContext<Self>,
887 ) -> Div {
888 self.markdown.update(cx, |markdown, cx| {
889 if manual_setup {
890 markdown.reset(format!("Please log into '{}'. If you don't yet have zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen to start zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
891 } else {
892 markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using manual setup.".to_string(), cx);
893 }
894 });
895
896 v_flex()
897 .pl_2()
898 .pt_2()
899 .gap_2()
900 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
901 .map(|el| {
902 if status == DevServerStatus::Offline && !manual_setup && !creating {
903 el.child(
904 h_flex()
905 .gap_2()
906 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
907 .child(Label::new("Not connected")),
908 )
909 } else if status == DevServerStatus::Offline {
910 el.child(Self::render_loading_spinner("Waiting for connection…"))
911 } else {
912 el.child(Label::new("🎊 Connection established!"))
913 }
914 })
915 }
916
917 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
918 h_flex()
919 .gap_2()
920 .child(
921 Icon::new(IconName::ArrowCircle)
922 .size(IconSize::Medium)
923 .with_animation(
924 "arrow-circle",
925 Animation::new(Duration::from_secs(2)).repeat(),
926 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
927 ),
928 )
929 .child(Label::new(label))
930 }
931
932 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
933 let dev_servers = self.dev_server_store.read(cx).dev_servers();
934
935 let Mode::Default(create_dev_server_project) = &self.mode else {
936 unreachable!()
937 };
938
939 let mut is_creating = None;
940 let mut creating_dev_server = None;
941 if let Some(CreateDevServerProject {
942 creating,
943 dev_server_id,
944 ..
945 }) = create_dev_server_project
946 {
947 is_creating = Some(*creating);
948 creating_dev_server = Some(*dev_server_id);
949 };
950
951 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
952 .header(
953 ModalHeader::new()
954 .show_dismiss_button(true)
955 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
956 )
957 .section(
958 Section::new().child(
959 div().mb_4().child(
960 List::new()
961 .empty_message("No dev servers registered.")
962 .header(Some(
963 ListHeader::new("Dev Servers").end_slot(
964 Button::new("register-dev-server-button", "New Server")
965 .icon(IconName::Plus)
966 .icon_position(IconPosition::Start)
967 .tooltip(|cx| {
968 Tooltip::text("Register a new dev server", cx)
969 })
970 .on_click(cx.listener(|this, _, cx| {
971 this.mode =
972 Mode::CreateDevServer(CreateDevServer::default());
973 this.dev_server_name_input.update(
974 cx,
975 |text_field, cx| {
976 text_field.editor().update(cx, |editor, cx| {
977 editor.set_text("", cx);
978 });
979 },
980 );
981 cx.notify();
982 })),
983 ),
984 ))
985 .children(dev_servers.iter().map(|dev_server| {
986 let creating = if creating_dev_server == Some(dev_server.id) {
987 is_creating
988 } else {
989 None
990 };
991 self.render_dev_server(dev_server, creating, cx)
992 .into_any_element()
993 })),
994 ),
995 ),
996 )
997 }
998}
999
1000fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1001 element
1002 .read(cx)
1003 .editor()
1004 .read(cx)
1005 .text(cx)
1006 .trim()
1007 .to_string()
1008}
1009
1010impl ModalView for DevServerProjects {}
1011
1012impl FocusableView for DevServerProjects {
1013 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1014 self.focus_handle.clone()
1015 }
1016}
1017
1018impl EventEmitter<DismissEvent> for DevServerProjects {}
1019
1020impl Render for DevServerProjects {
1021 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1022 div()
1023 .track_focus(&self.focus_handle)
1024 .elevation_3(cx)
1025 .key_context("DevServerModal")
1026 .on_action(cx.listener(Self::cancel))
1027 .on_action(cx.listener(Self::confirm))
1028 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1029 this.focus_handle(cx).focus(cx);
1030 }))
1031 .on_mouse_down_out(cx.listener(|this, _, cx| {
1032 if matches!(this.mode, Mode::Default(None)) {
1033 cx.emit(DismissEvent)
1034 }
1035 }))
1036 .w(rems(34.))
1037 .max_h(rems(40.))
1038 .child(match &self.mode {
1039 Mode::Default(_) => self.render_default(cx).into_any_element(),
1040 Mode::CreateDevServer(state) => {
1041 self.render_create_dev_server(state, cx).into_any_element()
1042 }
1043 })
1044 }
1045}
1046
1047pub fn reconnect_to_dev_server(
1048 workspace: View<Workspace>,
1049 dev_server: DevServer,
1050 cx: &mut WindowContext,
1051) -> Task<anyhow::Result<()>> {
1052 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1053 return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1054 };
1055 let dev_server_store = dev_server_projects::Store::global(cx);
1056 let get_access_token = dev_server_store.update(cx, |store, cx| {
1057 store.regenerate_dev_server_token(dev_server.id, cx)
1058 });
1059
1060 cx.spawn(|mut cx| async move {
1061 let access_token = get_access_token.await?.access_token;
1062
1063 spawn_ssh_task(
1064 workspace,
1065 dev_server_store,
1066 dev_server.id,
1067 ssh_connection_string.to_string(),
1068 access_token,
1069 &mut cx,
1070 )
1071 .await
1072 })
1073}
1074
1075pub async fn spawn_ssh_task(
1076 workspace: View<Workspace>,
1077 dev_server_store: Model<dev_server_projects::Store>,
1078 dev_server_id: DevServerId,
1079 ssh_connection_string: String,
1080 access_token: String,
1081 cx: &mut AsyncWindowContext,
1082) -> anyhow::Result<()> {
1083 let terminal_panel = workspace
1084 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1085 .ok()
1086 .flatten()
1087 .with_context(|| anyhow!("No terminal panel"))?;
1088
1089 let command = "sh".to_string();
1090 let args = vec![
1091 "-x".to_string(),
1092 "-c".to_string(),
1093 format!(
1094 r#"~/.local/bin/zed -v >/dev/stderr || (curl -sSL https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | bash && ~/.local/bin/zed --dev-server-token {}"#,
1095 access_token
1096 ),
1097 ];
1098
1099 let ssh_connection_string = ssh_connection_string.to_string();
1100
1101 let terminal = terminal_panel
1102 .update(cx, |terminal_panel, cx| {
1103 terminal_panel.spawn_in_new_terminal(
1104 SpawnInTerminal {
1105 id: task::TaskId("ssh-remote".into()),
1106 full_label: "Install zed over ssh".into(),
1107 label: "Install zed over ssh".into(),
1108 command,
1109 args,
1110 command_label: ssh_connection_string.clone(),
1111 cwd: Some(TerminalWorkDir::Ssh {
1112 ssh_command: ssh_connection_string,
1113 path: None,
1114 }),
1115 env: Default::default(),
1116 use_new_terminal: true,
1117 allow_concurrent_runs: false,
1118 reveal: RevealStrategy::Always,
1119 },
1120 cx,
1121 )
1122 })?
1123 .await?;
1124
1125 terminal
1126 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1127 .await;
1128
1129 // There's a race-condition between the task completing successfully, and the server sending us the online status. Make it less likely we'll show the error state.
1130 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1131 == DevServerStatus::Offline
1132 {
1133 cx.background_executor()
1134 .timer(Duration::from_millis(200))
1135 .await
1136 }
1137
1138 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1139 == DevServerStatus::Offline
1140 {
1141 return Err(anyhow!("couldn't reconnect"))?;
1142 }
1143
1144 Ok(())
1145}