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