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