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 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
728 .header(
729 ModalHeader::new()
730 .headline("Create Dev Server")
731 .show_back_button(true),
732 )
733 .section(
734 Section::new()
735 .header(if manual_setup { "Server Name".into()} else { "SSH arguments".into()})
736 .child(
737 div()
738 .max_w(rems(16.))
739 .child(self.dev_server_name_input.clone())
740 ),
741 )
742 .section(
743 Section::new_contained()
744 .header("Connection Method".into())
745 .child(
746 v_flex()
747 .w_full()
748 .gap_y(Spacing::Large.rems(cx))
749 .child(v_flex().child(RadioWithLabel::new(
750 "use-server-name-in-ssh",
751 Label::new("Connect via SSH (default)"),
752 !manual_setup,
753 cx.listener({
754 move |this, _, cx| {
755 if let Mode::CreateDevServer(CreateDevServer{ manual_setup, .. }) = &mut this.mode {
756 *manual_setup = false;
757 }
758 cx.notify()
759 }
760 }),
761 ))
762 .child(RadioWithLabel::new(
763 "use-server-name-in-ssh",
764 Label::new("Manual Setup"),
765 manual_setup,
766 cx.listener({
767 move |this, _, cx| {
768 if let Mode::CreateDevServer(CreateDevServer{ manual_setup, .. }) = &mut this.mode {
769 *manual_setup = true;
770 }
771 cx.notify()
772 }}),
773 )))
774 .when(dev_server_id.is_none(), |el| {
775 el.child(
776 if manual_setup {
777 Label::new(
778 "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine."
779 )
780 } else {
781 Label::new(
782 "Enter the command you use to ssh into this server.\n\
783 For example: `ssh me@my.server` or `gh cs ssh -c example`."
784 )
785 }.size(LabelSize::Small).color(Color::Muted))
786 })
787 .when(dev_server_id.is_some() && access_token.is_none(),|el|{
788 el.child(
789 if manual_setup {
790 Label::new(
791 "Note: updating the dev server generate a new token"
792 )
793 } else {
794 Label::new(
795 "Enter the command you use to ssh into this server.\n\
796 For example: `ssh me@my.server` or `gh cs ssh -c example`."
797 )
798 }.size(LabelSize::Small).color(Color::Muted)
799 )
800 })
801 .when_some(access_token.clone(), {
802 |el, access_token| {
803 el.child(
804 self.render_dev_server_token_creating(access_token, name, manual_setup, status, creating, cx)
805 )
806 }}))
807 )
808 .footer(ModalFooter::new().end_slot(
809 if status == DevServerStatus::Online {
810 Button::new("create-dev-server", "Done")
811 .style(ButtonStyle::Filled)
812 .layer(ElevationIndex::ModalSurface)
813 .on_click(cx.listener(move |this, _, cx| {
814 cx.focus(&this.focus_handle);
815 this.mode = Mode::Default(None);
816 cx.notify();
817 }))
818 } else {
819 Button::new("create-dev-server", if manual_setup { if dev_server_id.is_some() { "Update" } else { "Create"} } else { if dev_server_id.is_some() { "Reconnect" } else { "Connect"} })
820 .style(ButtonStyle::Filled)
821 .layer(ElevationIndex::ModalSurface)
822 .disabled(creating && dev_server_id.is_none())
823 .on_click(cx.listener({
824 let access_token = access_token.clone();
825 move |this, _, cx| {
826 this.create_or_update_dev_server(manual_setup, dev_server_id, access_token.clone(), cx);
827 }}))
828 }
829 ))
830 }
831
832 fn render_dev_server_token_creating(
833 &self,
834 access_token: String,
835 dev_server_name: String,
836 manual_setup: bool,
837 status: DevServerStatus,
838 creating: bool,
839 cx: &mut ViewContext<Self>,
840 ) -> Div {
841 self.markdown.update(cx, |markdown, cx| {
842 if manual_setup {
843 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);
844 } else {
845 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);
846 }
847 });
848
849 v_flex()
850 .pl_2()
851 .pt_2()
852 .gap_2()
853 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
854 .map(|el| {
855 if status == DevServerStatus::Offline && !manual_setup && !creating {
856 el.child(
857 h_flex()
858 .gap_2()
859 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
860 .child(Label::new("Not connected")),
861 )
862 } else if status == DevServerStatus::Offline {
863 el.child(Self::render_loading_spinner("Waiting for connection…"))
864 } else {
865 el.child(Label::new("🎊 Connection established!"))
866 }
867 })
868 }
869
870 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
871 h_flex()
872 .gap_2()
873 .child(
874 Icon::new(IconName::ArrowCircle)
875 .size(IconSize::Medium)
876 .with_animation(
877 "arrow-circle",
878 Animation::new(Duration::from_secs(2)).repeat(),
879 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
880 ),
881 )
882 .child(Label::new(label))
883 }
884
885 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
886 let dev_servers = self.dev_server_store.read(cx).dev_servers();
887
888 let Mode::Default(create_dev_server_project) = &self.mode else {
889 unreachable!()
890 };
891
892 let mut is_creating = None;
893 let mut creating_dev_server = None;
894 if let Some(CreateDevServerProject {
895 creating,
896 dev_server_id,
897 ..
898 }) = create_dev_server_project
899 {
900 is_creating = Some(*creating);
901 creating_dev_server = Some(*dev_server_id);
902 };
903
904 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
905 .header(
906 ModalHeader::new()
907 .show_dismiss_button(true)
908 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
909 )
910 .section(
911 Section::new().child(
912 div().mb_4().child(
913 List::new()
914 .empty_message("No dev servers registered.")
915 .header(Some(
916 ListHeader::new("Dev Servers").end_slot(
917 Button::new("register-dev-server-button", "New Server")
918 .icon(IconName::Plus)
919 .icon_position(IconPosition::Start)
920 .tooltip(|cx| {
921 Tooltip::text("Register a new dev server", cx)
922 })
923 .on_click(cx.listener(|this, _, cx| {
924 this.mode =
925 Mode::CreateDevServer(CreateDevServer::default());
926 this.dev_server_name_input.update(
927 cx,
928 |text_field, cx| {
929 text_field.editor().update(cx, |editor, cx| {
930 editor.set_text("", cx);
931 });
932 },
933 );
934 cx.notify();
935 })),
936 ),
937 ))
938 .children(dev_servers.iter().map(|dev_server| {
939 let creating = if creating_dev_server == Some(dev_server.id) {
940 is_creating
941 } else {
942 None
943 };
944 self.render_dev_server(dev_server, creating, cx)
945 .into_any_element()
946 })),
947 ),
948 ),
949 )
950 }
951}
952
953fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
954 element
955 .read(cx)
956 .editor()
957 .read(cx)
958 .text(cx)
959 .trim()
960 .to_string()
961}
962
963impl ModalView for DevServerProjects {}
964
965impl FocusableView for DevServerProjects {
966 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
967 self.focus_handle.clone()
968 }
969}
970
971impl EventEmitter<DismissEvent> for DevServerProjects {}
972
973impl Render for DevServerProjects {
974 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
975 div()
976 .track_focus(&self.focus_handle)
977 .elevation_3(cx)
978 .key_context("DevServerModal")
979 .on_action(cx.listener(Self::cancel))
980 .on_action(cx.listener(Self::confirm))
981 .capture_any_mouse_down(cx.listener(|this, _, cx| {
982 this.focus_handle(cx).focus(cx);
983 }))
984 .on_mouse_down_out(cx.listener(|this, _, cx| {
985 if matches!(this.mode, Mode::Default(None)) {
986 cx.emit(DismissEvent)
987 }
988 }))
989 .w(rems(34.))
990 .max_h(rems(40.))
991 .child(match &self.mode {
992 Mode::Default(_) => self.render_default(cx).into_any_element(),
993 Mode::CreateDevServer(state) => {
994 self.render_create_dev_server(state, cx).into_any_element()
995 }
996 })
997 }
998}
999
1000pub fn reconnect_to_dev_server(
1001 workspace: View<Workspace>,
1002 dev_server: DevServer,
1003 cx: &mut WindowContext,
1004) -> Task<anyhow::Result<()>> {
1005 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1006 return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1007 };
1008 let dev_server_store = dev_server_projects::Store::global(cx);
1009 let get_access_token = dev_server_store.update(cx, |store, cx| {
1010 store.regenerate_dev_server_token(dev_server.id, cx)
1011 });
1012
1013 cx.spawn(|mut cx| async move {
1014 let access_token = get_access_token.await?.access_token;
1015
1016 spawn_ssh_task(
1017 workspace,
1018 dev_server_store,
1019 dev_server.id,
1020 ssh_connection_string.to_string(),
1021 access_token,
1022 &mut cx,
1023 )
1024 .await
1025 })
1026}
1027
1028pub async fn spawn_ssh_task(
1029 workspace: View<Workspace>,
1030 dev_server_store: Model<dev_server_projects::Store>,
1031 dev_server_id: DevServerId,
1032 ssh_connection_string: String,
1033 access_token: String,
1034 cx: &mut AsyncWindowContext,
1035) -> anyhow::Result<()> {
1036 let terminal_panel = workspace
1037 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1038 .ok()
1039 .flatten()
1040 .with_context(|| anyhow!("No terminal panel"))?;
1041
1042 let command = "sh".to_string();
1043 let args = vec![
1044 "-x".to_string(),
1045 "-c".to_string(),
1046 format!(
1047 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 {}"#,
1048 access_token
1049 ),
1050 ];
1051
1052 let ssh_connection_string = ssh_connection_string.to_string();
1053
1054 let terminal = terminal_panel
1055 .update(cx, |terminal_panel, cx| {
1056 terminal_panel.spawn_in_new_terminal(
1057 SpawnInTerminal {
1058 id: task::TaskId("ssh-remote".into()),
1059 full_label: "Install zed over ssh".into(),
1060 label: "Install zed over ssh".into(),
1061 command,
1062 args,
1063 command_label: ssh_connection_string.clone(),
1064 cwd: Some(TerminalWorkDir::Ssh {
1065 ssh_command: ssh_connection_string,
1066 path: None,
1067 }),
1068 env: Default::default(),
1069 use_new_terminal: true,
1070 allow_concurrent_runs: false,
1071 reveal: RevealStrategy::Always,
1072 },
1073 cx,
1074 )
1075 })?
1076 .await?;
1077
1078 terminal
1079 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1080 .await;
1081
1082 // 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.
1083 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1084 == DevServerStatus::Offline
1085 {
1086 cx.background_executor()
1087 .timer(Duration::from_millis(200))
1088 .await
1089 }
1090
1091 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1092 == DevServerStatus::Offline
1093 {
1094 return Err(anyhow!("couldn't reconnect"))?;
1095 }
1096
1097 Ok(())
1098}