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, Clone)]
53struct CreateDevServer {
54 creating: bool,
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 cx.spawn({
320 |this, mut cx| async move {
321 let result = dev_server.await;
322
323 match result {
324 Ok(dev_server) => {
325 if let Some(ssh_connection_string) = ssh_connection_string {
326 spawn_ssh_task(
327 workspace
328 .upgrade()
329 .ok_or_else(|| anyhow!("workspace dropped"))?,
330 store,
331 DevServerId(dev_server.dev_server_id),
332 ssh_connection_string,
333 dev_server.access_token.clone(),
334 &mut cx,
335 )
336 .await
337 .log_err();
338 }
339
340 this.update(&mut cx, |this, cx| {
341 this.focus_handle.focus(cx);
342 this.mode = Mode::CreateDevServer(CreateDevServer {
343 creating: false,
344 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
345 access_token: Some(dev_server.access_token),
346 manual_setup,
347 });
348 cx.notify();
349 })?;
350 Ok(())
351 }
352 Err(e) => {
353 this.update(&mut cx, |this, cx| {
354 this.mode = Mode::CreateDevServer(CreateDevServer {
355 creating: false,
356 dev_server_id: existing_id,
357 access_token: None,
358 manual_setup,
359 });
360 cx.notify()
361 })
362 .log_err();
363
364 return Err(e);
365 }
366 }
367 }
368 })
369 .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
370
371 self.mode = Mode::CreateDevServer(CreateDevServer {
372 creating: true,
373 dev_server_id: existing_id,
374 access_token,
375 manual_setup,
376 });
377 cx.notify()
378 }
379
380 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
381 let store = self.dev_server_store.read(cx);
382 let prompt = if store.projects_for_server(id).is_empty()
383 && store
384 .dev_server(id)
385 .is_some_and(|server| server.status == DevServerStatus::Offline)
386 {
387 None
388 } else {
389 Some(cx.prompt(
390 gpui::PromptLevel::Warning,
391 "Are you sure?",
392 Some("This will delete the dev server and all of its remote projects."),
393 &["Delete", "Cancel"],
394 ))
395 };
396
397 cx.spawn(|this, mut cx| async move {
398 if let Some(prompt) = prompt {
399 if prompt.await? != 0 {
400 return Ok(());
401 }
402 }
403
404 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
405 this.dev_server_store.update(cx, |store, _| {
406 store
407 .projects_for_server(id)
408 .into_iter()
409 .map(|project| project.id)
410 .collect()
411 })
412 })?;
413
414 this.update(&mut cx, |this, cx| {
415 this.dev_server_store
416 .update(cx, |store, cx| store.delete_dev_server(id, cx))
417 })?
418 .await?;
419
420 for id in project_ids {
421 WORKSPACE_DB
422 .delete_workspace_by_dev_server_project_id(id)
423 .await
424 .log_err();
425 }
426 Ok(())
427 })
428 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
429 }
430
431 fn delete_dev_server_project(
432 &mut self,
433 id: DevServerProjectId,
434 path: &str,
435 cx: &mut ViewContext<Self>,
436 ) {
437 let answer = cx.prompt(
438 gpui::PromptLevel::Warning,
439 format!("Delete \"{}\"?", path).as_str(),
440 Some("This will delete the remote project. You can always re-add it later."),
441 &["Delete", "Cancel"],
442 );
443
444 cx.spawn(|this, mut cx| async move {
445 let answer = answer.await?;
446
447 if answer != 0 {
448 return Ok(());
449 }
450
451 this.update(&mut cx, |this, cx| {
452 this.dev_server_store
453 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
454 })?
455 .await?;
456
457 WORKSPACE_DB
458 .delete_workspace_by_dev_server_project_id(id)
459 .await
460 .log_err();
461
462 Ok(())
463 })
464 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
465 }
466
467 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
468 match &self.mode {
469 Mode::Default(None) => {}
470 Mode::Default(Some(create_project)) => {
471 self.create_dev_server_project(create_project.dev_server_id, cx);
472 }
473 Mode::CreateDevServer(state) => {
474 if !state.creating {
475 self.create_or_update_dev_server(
476 state.manual_setup,
477 state.dev_server_id,
478 state.access_token.clone(),
479 cx,
480 );
481 }
482 }
483 }
484 }
485
486 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
487 match self.mode {
488 Mode::Default(None) => cx.emit(DismissEvent),
489 _ => {
490 self.mode = Mode::Default(None);
491 self.focus_handle(cx).focus(cx);
492 cx.notify();
493 }
494 }
495 }
496
497 fn render_dev_server(
498 &mut self,
499 dev_server: &DevServer,
500 create_project: Option<bool>,
501 cx: &mut ViewContext<Self>,
502 ) -> impl IntoElement {
503 let dev_server_id = dev_server.id;
504 let status = dev_server.status;
505 let dev_server_name = dev_server.name.clone();
506 let manual_setup = dev_server.ssh_connection_string.is_none();
507
508 v_flex()
509 .w_full()
510 .child(
511 h_flex().group("dev-server").justify_between().child(
512 h_flex()
513 .gap_2()
514 .child(
515 div()
516 .id(("status", dev_server.id.0))
517 .relative()
518 .child(Icon::new(IconName::Server).size(IconSize::Small))
519 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
520 Indicator::dot().color(match status {
521 DevServerStatus::Online => Color::Created,
522 DevServerStatus::Offline => Color::Hidden,
523 }),
524 ))
525 .tooltip(move |cx| {
526 Tooltip::text(
527 match status {
528 DevServerStatus::Online => "Online",
529 DevServerStatus::Offline => "Offline",
530 },
531 cx,
532 )
533 }),
534 )
535 .child(
536 div()
537 .max_w(rems(26.))
538 .overflow_hidden()
539 .whitespace_nowrap()
540 .child(Label::new(dev_server_name.clone())),
541 )
542 .child(
543 h_flex()
544 .visible_on_hover("dev-server")
545 .gap_1()
546 .child(
547 IconButton::new("edit-dev-server", IconName::Pencil)
548 .on_click(cx.listener(move |this, _, cx| {
549 this.mode = Mode::CreateDevServer(CreateDevServer {
550 dev_server_id: Some(dev_server_id),
551 creating: false,
552 access_token: None,
553 manual_setup,
554 });
555 let dev_server_name = dev_server_name.clone();
556 this.dev_server_name_input.update(
557 cx,
558 move |input, cx| {
559 input.editor().update(cx, move |editor, cx| {
560 editor.set_text(dev_server_name, cx)
561 })
562 },
563 )
564 }))
565 .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
566 )
567 .child({
568 let dev_server_id = dev_server.id;
569 IconButton::new("remove-dev-server", IconName::Trash)
570 .on_click(cx.listener(move |this, _, cx| {
571 this.delete_dev_server(dev_server_id, cx)
572 }))
573 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
574 }),
575 ),
576 ),
577 )
578 .child(
579 v_flex()
580 .w_full()
581 .bg(cx.theme().colors().background)
582 .border_1()
583 .border_color(cx.theme().colors().border_variant)
584 .rounded_md()
585 .my_1()
586 .py_0p5()
587 .px_3()
588 .child(
589 List::new()
590 .empty_message("No projects.")
591 .children(
592 self.dev_server_store
593 .read(cx)
594 .projects_for_server(dev_server.id)
595 .iter()
596 .map(|p| self.render_dev_server_project(p, cx)),
597 )
598 .when(
599 create_project.is_none()
600 && dev_server.status == DevServerStatus::Online,
601 |el| {
602 el.child(
603 ListItem::new("new-remote_project")
604 .start_slot(Icon::new(IconName::Plus))
605 .child(Label::new("Open folder…"))
606 .on_click(cx.listener(move |this, _, cx| {
607 this.mode =
608 Mode::Default(Some(CreateDevServerProject {
609 dev_server_id,
610 creating: false,
611 _opening: None,
612 }));
613 this.project_path_input
614 .read(cx)
615 .focus_handle(cx)
616 .focus(cx);
617 cx.notify();
618 })),
619 )
620 },
621 )
622 .when_some(create_project, |el, creating| {
623 el.child(self.render_create_new_project(creating, cx))
624 }),
625 ),
626 )
627 }
628
629 fn render_create_new_project(
630 &mut self,
631 creating: bool,
632 _: &mut ViewContext<Self>,
633 ) -> impl IntoElement {
634 ListItem::new("create-remote-project")
635 .disabled(true)
636 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
637 .child(self.project_path_input.clone())
638 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
639 el.child(
640 Icon::new(IconName::ArrowCircle)
641 .size(IconSize::Medium)
642 .with_animation(
643 "arrow-circle",
644 Animation::new(Duration::from_secs(2)).repeat(),
645 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
646 ),
647 )
648 }))
649 }
650
651 fn render_dev_server_project(
652 &mut self,
653 project: &DevServerProject,
654 cx: &mut ViewContext<Self>,
655 ) -> impl IntoElement {
656 let dev_server_project_id = project.id;
657 let project_id = project.project_id;
658 let is_online = project_id.is_some();
659 let project_path = project.path.clone();
660
661 ListItem::new(("remote-project", dev_server_project_id.0))
662 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
663 .child(
664 Label::new(project.path.clone())
665 )
666 .on_click(cx.listener(move |_, _, cx| {
667 if let Some(project_id) = project_id {
668 if let Some(app_state) = AppState::global(cx).upgrade() {
669 workspace::join_dev_server_project(project_id, app_state, None, cx)
670 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
671 }
672 } else {
673 cx.spawn(|_, mut cx| async move {
674 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();
675 }).detach();
676 }
677 }))
678 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
679 .on_click(cx.listener(move |this, _, cx| {
680 this.delete_dev_server_project(dev_server_project_id, &project_path, cx)
681 }))
682 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
683 }
684
685 fn render_create_dev_server(
686 &mut self,
687 state: CreateDevServer,
688 cx: &mut ViewContext<Self>,
689 ) -> impl IntoElement {
690 let CreateDevServer {
691 creating,
692 dev_server_id,
693 access_token,
694 manual_setup,
695 } = state.clone();
696
697 let status = dev_server_id
698 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
699 .unwrap_or_default();
700
701 let name = self.dev_server_name_input.update(cx, |input, cx| {
702 input.editor().update(cx, |editor, cx| {
703 if editor.text(cx).is_empty() {
704 if manual_setup {
705 editor.set_placeholder_text("example-server", cx)
706 } else {
707 editor.set_placeholder_text("ssh host", cx)
708 }
709 }
710 editor.text(cx)
711 })
712 });
713
714 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
715 .header(
716 ModalHeader::new()
717 .headline("Create Dev Server")
718 .show_back_button(true),
719 )
720 .section(
721 Section::new()
722 .header(if manual_setup { "Server Name".into()} else { "SSH arguments".into()})
723 .child(
724 div()
725 .max_w(rems(16.))
726 .child(self.dev_server_name_input.clone())
727 ),
728 )
729 .section(
730 Section::new_contained()
731 .header("Connection Method".into())
732 .child(
733 v_flex()
734 .w_full()
735 .gap_y(Spacing::Large.rems(cx))
736 .child(v_flex().child(RadioWithLabel::new(
737 "use-server-name-in-ssh",
738 Label::new("Connect via SSH (default)"),
739 !manual_setup,
740 cx.listener({
741 let state = state.clone();
742 move |this, _, cx| {
743 this.mode = Mode::CreateDevServer(CreateDevServer {
744 manual_setup: false,
745 ..state.clone()
746 });
747 cx.notify()
748 }
749 }),
750 ))
751 .child(RadioWithLabel::new(
752 "use-server-name-in-ssh",
753 Label::new("Manual Setup"),
754 manual_setup,
755 cx.listener({
756 let state = state.clone();
757 move |this, _, cx| {
758 this.mode = Mode::CreateDevServer(CreateDevServer {
759 manual_setup: true,
760 ..state.clone()
761 });
762 cx.notify()
763 }}),
764 )))
765 .when(dev_server_id.is_none(), |el| {
766 el.child(
767 if manual_setup {
768 Label::new(
769 "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine."
770 )
771 } else {
772 Label::new(
773 "Enter the command you use to ssh into this server.\n\
774 For example: `ssh me@my.server` or `gh cs ssh -c example`."
775 )
776 }.size(LabelSize::Small).color(Color::Muted))
777 })
778 .when(dev_server_id.is_some() && access_token.is_none(),|el|{
779 el.child(
780 if manual_setup {
781 Label::new(
782 "Note: updating the dev server generate a new token"
783 )
784 } else {
785 Label::new(
786 "Enter the command you use to ssh into this server.\n\
787 For example: `ssh me@my.server` or `gh cs ssh -c example`."
788 )
789 }.size(LabelSize::Small).color(Color::Muted)
790 )
791 })
792 .when_some(access_token.clone(), {
793 |el, access_token| {
794 el.child(
795 self.render_dev_server_token_creating(access_token, name, manual_setup, status, creating, cx)
796 )
797 }}))
798 )
799 .footer(ModalFooter::new().end_slot(
800 if status == DevServerStatus::Online {
801 Button::new("create-dev-server", "Done")
802 .style(ButtonStyle::Filled)
803 .layer(ElevationIndex::ModalSurface)
804 .on_click(cx.listener(move |this, _, cx| {
805 cx.focus(&this.focus_handle);
806 this.mode = Mode::Default(None);
807 cx.notify();
808 }))
809 } else {
810 Button::new("create-dev-server", if manual_setup { "Create"} else { "Connect"})
811 .style(ButtonStyle::Filled)
812 .layer(ElevationIndex::ModalSurface)
813 .disabled(creating)
814 .on_click(cx.listener({
815 let access_token = access_token.clone();
816 move |this, _, cx| {
817 this.create_or_update_dev_server(manual_setup, dev_server_id, access_token.clone(), cx);
818 }}))
819 }
820 ))
821 }
822
823 fn render_dev_server_token_creating(
824 &self,
825 access_token: String,
826 dev_server_name: String,
827 manual_setup: bool,
828 status: DevServerStatus,
829 creating: bool,
830 cx: &mut ViewContext<Self>,
831 ) -> Div {
832 self.markdown.update(cx, |markdown, cx| {
833 if manual_setup {
834 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);
835 } else {
836 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);
837 }
838 });
839
840 v_flex()
841 .pl_2()
842 .pt_2()
843 .gap_2()
844 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
845 .map(|el| {
846 if status == DevServerStatus::Offline && !manual_setup && !creating {
847 el.child(
848 h_flex()
849 .gap_2()
850 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
851 .child(Label::new("Not connected")),
852 )
853 } else if status == DevServerStatus::Offline {
854 el.child(Self::render_loading_spinner("Waiting for connection…"))
855 } else {
856 el.child(Label::new("🎊 Connection established!"))
857 }
858 })
859 }
860
861 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
862 h_flex()
863 .gap_2()
864 .child(
865 Icon::new(IconName::ArrowCircle)
866 .size(IconSize::Medium)
867 .with_animation(
868 "arrow-circle",
869 Animation::new(Duration::from_secs(2)).repeat(),
870 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
871 ),
872 )
873 .child(Label::new(label))
874 }
875
876 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
877 let dev_servers = self.dev_server_store.read(cx).dev_servers();
878
879 let Mode::Default(create_dev_server_project) = &self.mode else {
880 unreachable!()
881 };
882
883 let mut is_creating = None;
884 let mut creating_dev_server = None;
885 if let Some(CreateDevServerProject {
886 creating,
887 dev_server_id,
888 ..
889 }) = create_dev_server_project
890 {
891 is_creating = Some(*creating);
892 creating_dev_server = Some(*dev_server_id);
893 };
894
895 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
896 .header(
897 ModalHeader::new()
898 .show_dismiss_button(true)
899 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
900 )
901 .section(
902 Section::new().child(
903 div().mb_4().child(
904 List::new()
905 .empty_message("No dev servers registered.")
906 .header(Some(
907 ListHeader::new("Dev Servers").end_slot(
908 Button::new("register-dev-server-button", "New Server")
909 .icon(IconName::Plus)
910 .icon_position(IconPosition::Start)
911 .tooltip(|cx| {
912 Tooltip::text("Register a new dev server", cx)
913 })
914 .on_click(cx.listener(|this, _, cx| {
915 this.mode =
916 Mode::CreateDevServer(CreateDevServer::default());
917 this.dev_server_name_input.update(
918 cx,
919 |text_field, cx| {
920 text_field.editor().update(cx, |editor, cx| {
921 editor.set_text("", cx);
922 });
923 },
924 );
925 cx.notify();
926 })),
927 ),
928 ))
929 .children(dev_servers.iter().map(|dev_server| {
930 let creating = if creating_dev_server == Some(dev_server.id) {
931 is_creating
932 } else {
933 None
934 };
935 self.render_dev_server(dev_server, creating, cx)
936 .into_any_element()
937 })),
938 ),
939 ),
940 )
941 }
942}
943
944fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
945 element
946 .read(cx)
947 .editor()
948 .read(cx)
949 .text(cx)
950 .trim()
951 .to_string()
952}
953
954impl ModalView for DevServerProjects {}
955
956impl FocusableView for DevServerProjects {
957 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
958 self.focus_handle.clone()
959 }
960}
961
962impl EventEmitter<DismissEvent> for DevServerProjects {}
963
964impl Render for DevServerProjects {
965 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
966 div()
967 .track_focus(&self.focus_handle)
968 .elevation_3(cx)
969 .key_context("DevServerModal")
970 .on_action(cx.listener(Self::cancel))
971 .on_action(cx.listener(Self::confirm))
972 .capture_any_mouse_down(cx.listener(|this, _, cx| {
973 this.focus_handle(cx).focus(cx);
974 }))
975 .on_mouse_down_out(cx.listener(|this, _, cx| {
976 if matches!(this.mode, Mode::Default(None)) {
977 cx.emit(DismissEvent)
978 } else {
979 this.focus_handle(cx).focus(cx);
980 cx.stop_propagation()
981 }
982 }))
983 .w(rems(34.))
984 .max_h(rems(40.))
985 .child(match &self.mode {
986 Mode::Default(_) => self.render_default(cx).into_any_element(),
987 Mode::CreateDevServer(state) => self
988 .render_create_dev_server(state.clone(), cx)
989 .into_any_element(),
990 })
991 }
992}
993
994pub fn reconnect_to_dev_server(
995 workspace: View<Workspace>,
996 dev_server: DevServer,
997 cx: &mut WindowContext,
998) -> Task<anyhow::Result<()>> {
999 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1000 return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1001 };
1002 let dev_server_store = dev_server_projects::Store::global(cx);
1003 let get_access_token = dev_server_store.update(cx, |store, cx| {
1004 store.regenerate_dev_server_token(dev_server.id, cx)
1005 });
1006
1007 cx.spawn(|mut cx| async move {
1008 let access_token = get_access_token.await?.access_token;
1009
1010 spawn_ssh_task(
1011 workspace,
1012 dev_server_store,
1013 dev_server.id,
1014 ssh_connection_string.to_string(),
1015 access_token,
1016 &mut cx,
1017 )
1018 .await
1019 })
1020}
1021
1022pub async fn spawn_ssh_task(
1023 workspace: View<Workspace>,
1024 dev_server_store: Model<dev_server_projects::Store>,
1025 dev_server_id: DevServerId,
1026 ssh_connection_string: String,
1027 access_token: String,
1028 cx: &mut AsyncWindowContext,
1029) -> anyhow::Result<()> {
1030 let terminal_panel = workspace
1031 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1032 .ok()
1033 .flatten()
1034 .with_context(|| anyhow!("No terminal panel"))?;
1035
1036 let command = "sh".to_string();
1037 let args = vec![
1038 "-x".to_string(),
1039 "-c".to_string(),
1040 format!(
1041 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 {}"#,
1042 access_token
1043 ),
1044 ];
1045
1046 let ssh_connection_string = ssh_connection_string.to_string();
1047
1048 let terminal = terminal_panel
1049 .update(cx, |terminal_panel, cx| {
1050 terminal_panel.spawn_in_new_terminal(
1051 SpawnInTerminal {
1052 id: task::TaskId("ssh-remote".into()),
1053 full_label: "Install zed over ssh".into(),
1054 label: "Install zed over ssh".into(),
1055 command,
1056 args,
1057 command_label: ssh_connection_string.clone(),
1058 cwd: Some(TerminalWorkDir::Ssh {
1059 ssh_command: ssh_connection_string,
1060 path: None,
1061 }),
1062 env: Default::default(),
1063 use_new_terminal: true,
1064 allow_concurrent_runs: false,
1065 reveal: RevealStrategy::Always,
1066 },
1067 cx,
1068 )
1069 })?
1070 .await?;
1071
1072 terminal
1073 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1074 .await;
1075
1076 // 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.
1077 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1078 == DevServerStatus::Offline
1079 {
1080 cx.background_executor()
1081 .timer(Duration::from_millis(200))
1082 .await
1083 }
1084
1085 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1086 == DevServerStatus::Offline
1087 {
1088 return Err(anyhow!("couldn't reconnect"))?;
1089 }
1090
1091 Ok(())
1092}