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