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