1use std::time::Duration;
2
3use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
4use editor::Editor;
5use feature_flags::FeatureFlagAppExt;
6use feature_flags::FeatureFlagViewExt;
7use gpui::Subscription;
8use gpui::{
9 percentage, Action, Animation, AnimationExt, AnyElement, AppContext, ClipboardItem,
10 DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation,
11 View, ViewContext,
12};
13use markdown::Markdown;
14use markdown::MarkdownStyle;
15use rpc::{
16 proto::{CreateDevServerResponse, DevServerStatus, RegenerateDevServerTokenResponse},
17 ErrorCode, ErrorExt,
18};
19use ui::CheckboxWithLabel;
20use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
21use ui_text_field::{FieldLabelLayout, TextField};
22use util::ResultExt;
23use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
24
25use crate::OpenRemote;
26
27pub struct DevServerProjects {
28 mode: Mode,
29 focus_handle: FocusHandle,
30 scroll_handle: ScrollHandle,
31 dev_server_store: Model<dev_server_projects::Store>,
32 project_path_input: View<Editor>,
33 dev_server_name_input: View<TextField>,
34 use_server_name_in_ssh: Selection,
35 rename_dev_server_input: View<TextField>,
36 markdown: View<Markdown>,
37 _dev_server_subscription: Subscription,
38}
39
40#[derive(Default, Clone)]
41struct CreateDevServer {
42 creating: bool,
43 dev_server: Option<CreateDevServerResponse>,
44 // ssh_connection_string: Option<String>,
45}
46
47#[derive(Clone)]
48struct EditDevServer {
49 dev_server_id: DevServerId,
50 state: EditDevServerState,
51}
52
53#[derive(Clone, PartialEq)]
54enum EditDevServerState {
55 Default,
56 RenamingDevServer,
57 RegeneratingToken,
58 RegeneratedToken(RegenerateDevServerTokenResponse),
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 EditDevServer(EditDevServer),
71}
72
73impl DevServerProjects {
74 pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
75 cx.observe_flag::<feature_flags::Remoting, _>(|enabled, workspace, _| {
76 if enabled {
77 Self::register_open_remote_action(workspace);
78 }
79 })
80 .detach();
81
82 if cx.has_flag::<feature_flags::Remoting>() {
83 Self::register_open_remote_action(workspace);
84 }
85 }
86
87 fn register_open_remote_action(workspace: &mut Workspace) {
88 workspace.register_action(|workspace, _: &OpenRemote, cx| {
89 workspace.toggle_modal(cx, |cx| Self::new(cx))
90 });
91 }
92
93 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
94 workspace.update(cx, |workspace, cx| {
95 workspace.toggle_modal(cx, |cx| Self::new(cx))
96 })
97 }
98
99 pub fn new(cx: &mut ViewContext<Self>) -> Self {
100 let project_path_input = cx.new_view(|cx| {
101 let mut editor = Editor::single_line(cx);
102 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
103 editor
104 });
105 let dev_server_name_input =
106 cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
107 let rename_dev_server_input =
108 cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
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 markdown_style = MarkdownStyle {
118 code_block: gpui::TextStyleRefinement {
119 font_family: Some("Zed Mono".into()),
120 color: Some(cx.theme().colors().editor_foreground),
121 background_color: Some(cx.theme().colors().editor_background),
122 ..Default::default()
123 },
124 inline_code: Default::default(),
125 block_quote: Default::default(),
126 link: Default::default(),
127 rule_color: Default::default(),
128 block_quote_border_color: Default::default(),
129 syntax: cx.theme().syntax().clone(),
130 selection_background_color: cx.theme().players().local().selection,
131 };
132 let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx));
133
134 Self {
135 mode: Mode::Default(None),
136 focus_handle,
137 scroll_handle: ScrollHandle::new(),
138 dev_server_store,
139 project_path_input,
140 dev_server_name_input,
141 rename_dev_server_input,
142 markdown,
143 use_server_name_in_ssh: Selection::Unselected,
144 _dev_server_subscription: subscription,
145 }
146 }
147
148 pub fn create_dev_server_project(
149 &mut self,
150 dev_server_id: DevServerId,
151 cx: &mut ViewContext<Self>,
152 ) {
153 let path = self.project_path_input.read(cx).text(cx).trim().to_string();
154
155 if path == "" {
156 return;
157 }
158
159 if self
160 .dev_server_store
161 .read(cx)
162 .projects_for_server(dev_server_id)
163 .iter()
164 .any(|p| p.path == path)
165 {
166 cx.spawn(|_, mut cx| async move {
167 cx.prompt(
168 gpui::PromptLevel::Critical,
169 "Failed to create project",
170 Some(&format!(
171 "Project {} already exists for this dev server.",
172 path
173 )),
174 &["Ok"],
175 )
176 .await
177 })
178 .detach_and_log_err(cx);
179 return;
180 }
181
182 let create = {
183 let path = path.clone();
184 self.dev_server_store.update(cx, |store, cx| {
185 store.create_dev_server_project(dev_server_id, path, cx)
186 })
187 };
188
189 cx.spawn(|this, mut cx| async move {
190 let result = create.await;
191 this.update(&mut cx, |this, cx| {
192 if let Ok(result) = &result {
193 if let Some(dev_server_project_id) =
194 result.dev_server_project.as_ref().map(|p| p.id)
195 {
196 let subscription =
197 cx.observe(&this.dev_server_store, move |this, store, cx| {
198 if let Some(project_id) = store
199 .read(cx)
200 .dev_server_project(DevServerProjectId(dev_server_project_id))
201 .and_then(|p| p.project_id)
202 {
203 this.project_path_input.update(cx, |editor, cx| {
204 editor.set_text("", cx);
205 });
206 this.mode = Mode::Default(None);
207 if let Some(app_state) = AppState::global(cx).upgrade() {
208 workspace::join_dev_server_project(
209 project_id, app_state, None, 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_dev_server(&mut self, cx: &mut ViewContext<Self>) {
258 let name = get_text(&self.dev_server_name_input, cx);
259 if name.is_empty() {
260 return;
261 }
262
263 let ssh_connection_string = if self.use_server_name_in_ssh == Selection::Selected {
264 Some(name.clone())
265 } else {
266 None
267 };
268
269 let dev_server = self.dev_server_store.update(cx, |store, cx| {
270 store.create_dev_server(name, ssh_connection_string, cx)
271 });
272
273 cx.spawn(|this, mut cx| async move {
274 let result = dev_server.await;
275
276 this.update(&mut cx, |this, cx| match &result {
277 Ok(dev_server) => {
278 this.focus_handle.focus(cx);
279 this.mode = Mode::CreateDevServer(CreateDevServer {
280 creating: false,
281 dev_server: Some(dev_server.clone()),
282 });
283 }
284 Err(_) => {
285 this.mode = Mode::CreateDevServer(Default::default());
286 }
287 })
288 .log_err();
289 result
290 })
291 .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
292
293 self.mode = Mode::CreateDevServer(CreateDevServer {
294 creating: true,
295 dev_server: None,
296 });
297 cx.notify()
298 }
299
300 fn rename_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
301 let name = get_text(&self.rename_dev_server_input, cx);
302
303 let Some(dev_server) = self.dev_server_store.read(cx).dev_server(id) else {
304 return;
305 };
306
307 if name.is_empty() || dev_server.name == name {
308 return;
309 }
310
311 let request = self
312 .dev_server_store
313 .update(cx, |store, cx| store.rename_dev_server(id, name, cx));
314
315 self.mode = Mode::EditDevServer(EditDevServer {
316 dev_server_id: id,
317 state: EditDevServerState::RenamingDevServer,
318 });
319
320 cx.spawn(|this, mut cx| async move {
321 request.await?;
322 this.update(&mut cx, move |this, cx| {
323 this.mode = Mode::EditDevServer(EditDevServer {
324 dev_server_id: id,
325 state: EditDevServerState::Default,
326 });
327 cx.notify();
328 })
329 })
330 .detach_and_prompt_err("Failed to rename dev server", cx, |_, _| None);
331 }
332
333 fn refresh_dev_server_token(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
334 let answer = cx.prompt(
335 gpui::PromptLevel::Warning,
336 "Are you sure?",
337 Some("This will invalidate the existing dev server token."),
338 &["Generate", "Cancel"],
339 );
340 cx.spawn(|this, mut cx| async move {
341 let answer = answer.await?;
342
343 if answer != 0 {
344 return Ok(());
345 }
346
347 let response = this
348 .update(&mut cx, move |this, cx| {
349 let request = this
350 .dev_server_store
351 .update(cx, |store, cx| store.regenerate_dev_server_token(id, cx));
352 this.mode = Mode::EditDevServer(EditDevServer {
353 dev_server_id: id,
354 state: EditDevServerState::RegeneratingToken,
355 });
356 cx.notify();
357 request
358 })?
359 .await?;
360
361 this.update(&mut cx, move |this, cx| {
362 this.mode = Mode::EditDevServer(EditDevServer {
363 dev_server_id: id,
364 state: EditDevServerState::RegeneratedToken(response),
365 });
366 cx.notify();
367 })
368 .log_err();
369
370 Ok(())
371 })
372 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
373 }
374
375 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
376 let answer = cx.prompt(
377 gpui::PromptLevel::Warning,
378 "Are you sure?",
379 Some("This will delete the dev server and all of its remote projects."),
380 &["Delete", "Cancel"],
381 );
382
383 cx.spawn(|this, mut cx| async move {
384 let answer = answer.await?;
385
386 if answer != 0 {
387 return Ok(());
388 }
389
390 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
391 this.dev_server_store.update(cx, |store, _| {
392 store
393 .projects_for_server(id)
394 .into_iter()
395 .map(|project| project.id)
396 .collect()
397 })
398 })?;
399
400 this.update(&mut cx, |this, cx| {
401 this.dev_server_store
402 .update(cx, |store, cx| store.delete_dev_server(id, cx))
403 })?
404 .await?;
405
406 for id in project_ids {
407 WORKSPACE_DB
408 .delete_workspace_by_dev_server_project_id(id)
409 .await
410 .log_err();
411 }
412 Ok(())
413 })
414 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
415 }
416
417 fn delete_dev_server_project(
418 &mut self,
419 id: DevServerProjectId,
420 path: &str,
421 cx: &mut ViewContext<Self>,
422 ) {
423 let answer = cx.prompt(
424 gpui::PromptLevel::Warning,
425 format!("Delete \"{}\"?", path).as_str(),
426 Some("This will delete the remote project. You can always re-add it later."),
427 &["Delete", "Cancel"],
428 );
429
430 cx.spawn(|this, mut cx| async move {
431 let answer = answer.await?;
432
433 if answer != 0 {
434 return Ok(());
435 }
436
437 this.update(&mut cx, |this, cx| {
438 this.dev_server_store
439 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
440 })?
441 .await?;
442
443 WORKSPACE_DB
444 .delete_workspace_by_dev_server_project_id(id)
445 .await
446 .log_err();
447
448 Ok(())
449 })
450 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
451 }
452
453 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
454 match &self.mode {
455 Mode::Default(None) => {}
456 Mode::Default(Some(create_project)) => {
457 self.create_dev_server_project(create_project.dev_server_id, cx);
458 }
459 Mode::CreateDevServer(state) => {
460 if !state.creating && state.dev_server.is_none() {
461 self.create_dev_server(cx);
462 }
463 }
464 Mode::EditDevServer(edit_dev_server) => {
465 if self
466 .rename_dev_server_input
467 .read(cx)
468 .editor()
469 .read(cx)
470 .is_focused(cx)
471 {
472 self.rename_dev_server(edit_dev_server.dev_server_id, cx);
473 }
474 }
475 }
476 }
477
478 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
479 match self.mode {
480 Mode::Default(None) => cx.emit(DismissEvent),
481 _ => {
482 self.mode = Mode::Default(None);
483 self.focus_handle(cx).focus(cx);
484 cx.notify();
485 }
486 }
487 }
488
489 fn render_dev_server(
490 &mut self,
491 dev_server: &DevServer,
492 create_project: Option<bool>,
493 cx: &mut ViewContext<Self>,
494 ) -> impl IntoElement {
495 let dev_server_id = dev_server.id;
496 let status = dev_server.status;
497 let dev_server_name = dev_server.name.clone();
498
499 v_flex()
500 .w_full()
501 .child(
502 h_flex().group("dev-server").justify_between().child(
503 h_flex()
504 .gap_2()
505 .child(
506 div()
507 .id(("status", dev_server.id.0))
508 .relative()
509 .child(Icon::new(IconName::Server).size(IconSize::Small))
510 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
511 Indicator::dot().color(match status {
512 DevServerStatus::Online => Color::Created,
513 DevServerStatus::Offline => Color::Hidden,
514 }),
515 ))
516 .tooltip(move |cx| {
517 Tooltip::text(
518 match status {
519 DevServerStatus::Online => "Online",
520 DevServerStatus::Offline => "Offline",
521 },
522 cx,
523 )
524 }),
525 )
526 .child(dev_server_name.clone())
527 .child(
528 h_flex()
529 .visible_on_hover("dev-server")
530 .gap_1()
531 .child(
532 IconButton::new("edit-dev-server", IconName::Pencil)
533 .on_click(cx.listener(move |this, _, cx| {
534 this.mode = Mode::EditDevServer(EditDevServer {
535 dev_server_id,
536 state: EditDevServerState::Default,
537 });
538 let dev_server_name = dev_server_name.clone();
539 this.rename_dev_server_input.update(
540 cx,
541 move |input, cx| {
542 input.editor().update(cx, move |editor, cx| {
543 editor.set_text(dev_server_name, cx)
544 })
545 },
546 )
547 }))
548 .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
549 )
550 .child({
551 let dev_server_id = dev_server.id;
552 IconButton::new("remove-dev-server", IconName::Trash)
553 .on_click(cx.listener(move |this, _, cx| {
554 this.delete_dev_server(dev_server_id, cx)
555 }))
556 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
557 }),
558 ),
559 ),
560 )
561 .child(
562 v_flex()
563 .w_full()
564 .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
565 .border_1()
566 .border_color(cx.theme().colors().border_variant)
567 .rounded_md()
568 .my_1()
569 .py_0p5()
570 .px_3()
571 .child(
572 List::new()
573 .empty_message("No projects.")
574 .children(
575 self.dev_server_store
576 .read(cx)
577 .projects_for_server(dev_server.id)
578 .iter()
579 .map(|p| self.render_dev_server_project(p, cx)),
580 )
581 .when(
582 create_project.is_none()
583 && dev_server.status == DevServerStatus::Online,
584 |el| {
585 el.child(
586 ListItem::new("new-remote_project")
587 .start_slot(Icon::new(IconName::Plus))
588 .child(Label::new("Open folder…"))
589 .on_click(cx.listener(move |this, _, cx| {
590 this.mode =
591 Mode::Default(Some(CreateDevServerProject {
592 dev_server_id,
593 creating: false,
594 _opening: None,
595 }));
596 this.project_path_input
597 .read(cx)
598 .focus_handle(cx)
599 .focus(cx);
600 cx.notify();
601 })),
602 )
603 },
604 )
605 .when_some(create_project, |el, creating| {
606 el.child(self.render_create_new_project(creating, cx))
607 }),
608 ),
609 )
610 }
611
612 fn render_create_new_project(
613 &mut self,
614 creating: bool,
615 _: &mut ViewContext<Self>,
616 ) -> impl IntoElement {
617 ListItem::new("create-remote-project")
618 .disabled(true)
619 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
620 .child(self.project_path_input.clone())
621 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
622 el.child(
623 Icon::new(IconName::ArrowCircle)
624 .size(IconSize::Medium)
625 .with_animation(
626 "arrow-circle",
627 Animation::new(Duration::from_secs(2)).repeat(),
628 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
629 ),
630 )
631 }))
632 }
633
634 fn render_dev_server_project(
635 &mut self,
636 project: &DevServerProject,
637 cx: &mut ViewContext<Self>,
638 ) -> impl IntoElement {
639 let dev_server_project_id = project.id;
640 let project_id = project.project_id;
641 let is_online = project_id.is_some();
642 let project_path = project.path.clone();
643
644 ListItem::new(("remote-project", dev_server_project_id.0))
645 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
646 .child(
647 Label::new(project.path.clone())
648 )
649 .on_click(cx.listener(move |_, _, cx| {
650 if let Some(project_id) = project_id {
651 if let Some(app_state) = AppState::global(cx).upgrade() {
652 workspace::join_dev_server_project(project_id, app_state, None, cx)
653 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
654 }
655 } else {
656 cx.spawn(|_, mut cx| async move {
657 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();
658 }).detach();
659 }
660 }))
661 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
662 .on_click(cx.listener(move |this, _, cx| {
663 this.delete_dev_server_project(dev_server_project_id, &project_path, cx)
664 }))
665 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
666 }
667
668 fn render_create_dev_server(
669 &mut self,
670 state: CreateDevServer,
671 cx: &mut ViewContext<Self>,
672 ) -> impl IntoElement {
673 let CreateDevServer {
674 creating,
675 dev_server,
676 } = state;
677
678 self.dev_server_name_input.update(cx, |input, cx| {
679 input.set_disabled(creating || dev_server.is_some(), cx);
680 });
681
682 v_flex()
683 .id("scroll-container")
684 .h_full()
685 .overflow_y_scroll()
686 .track_scroll(&self.scroll_handle)
687 .px_1()
688 .pt_0p5()
689 .gap_px()
690 .child(
691 ModalHeader::new("create-dev-server")
692 .show_back_button(true)
693 .child(Headline::new("New dev server").size(HeadlineSize::Small)),
694 )
695 .child(
696 ModalContent::new().child(
697 v_flex()
698 .w_full()
699 .child(
700 v_flex()
701 .pb_2()
702 .w_full()
703 .px_2()
704 .child(
705 div()
706 .pl_2()
707 .max_w(rems(16.))
708 .child(self.dev_server_name_input.clone()),
709 )
710 )
711 .child(
712 h_flex()
713 .pb_2()
714 .items_end()
715 .w_full()
716 .px_2()
717 .border_b_1()
718 .border_color(cx.theme().colors().border)
719 .child(
720 div()
721 .pl_1()
722 .pb(px(3.))
723 .when(!creating && dev_server.is_none(), |div| {
724 div
725 .child(
726 CheckboxWithLabel::new(
727 "use-server-name-in-ssh",
728 Label::new("Use name as ssh connection string"),
729 self.use_server_name_in_ssh,
730 cx.listener(move |this, &new_selection, _| {
731 this.use_server_name_in_ssh = new_selection;
732 })
733 )
734 )
735 .child(
736 Button::new("create-dev-server", "Create").on_click(
737 cx.listener(move |this, _, cx| {
738 this.create_dev_server(cx);
739 })
740 )
741 )
742 })
743 .when(creating && dev_server.is_none(), |div| {
744 div
745 .child(
746 CheckboxWithLabel::new(
747 "use-server-name-in-ssh",
748 Label::new("Use SSH for terminals"),
749 self.use_server_name_in_ssh,
750 |&_, _| {}
751 )
752 )
753 .child(
754 Button::new("create-dev-server", "Creating...")
755 .disabled(true),
756 )
757 }),
758 )
759 )
760 .when(dev_server.is_none(), |div| {
761 let server_name = get_text(&self.dev_server_name_input, cx);
762 let server_name_trimmed = server_name.trim();
763 let ssh_host_name = if server_name_trimmed.is_empty() {
764 "user@host"
765 } else {
766 server_name_trimmed
767 };
768 div.px_2().child(Label::new(format!(
769 "Once you have created a dev server, you will be given a command to run on the server to register it.\n\n\
770 If you enable SSH, then the terminal will automatically `ssh {ssh_host_name}` on open."
771 )))
772 })
773 .when_some(dev_server.clone(), |div, dev_server| {
774 let status = self
775 .dev_server_store
776 .read(cx)
777 .dev_server_status(DevServerId(dev_server.dev_server_id));
778
779 div.child(
780 self.render_dev_server_token_instructions(&dev_server.access_token, &dev_server.name, status, cx)
781 )
782 }),
783 )
784 )
785 }
786
787 fn render_dev_server_token_instructions(
788 &self,
789 access_token: &str,
790 dev_server_name: &str,
791 status: DevServerStatus,
792 cx: &mut ViewContext<Self>,
793 ) -> Div {
794 let instructions = SharedString::from(format!("zed --dev-server-token {}", access_token));
795 self.markdown.update(cx, |markdown, cx| {
796 if !markdown.source().contains(access_token) {
797 markdown.reset(format!("```\n{}\n```", instructions), cx);
798 }
799 });
800
801 v_flex()
802 .pl_2()
803 .pt_2()
804 .gap_2()
805 .child(
806 h_flex()
807 .justify_between()
808 .w_full()
809 .child(Label::new(format!(
810 "Please log into `{}` and run:",
811 dev_server_name
812 )))
813 .child(
814 Button::new("copy-access-token", "Copy Instructions")
815 .icon(Some(IconName::Copy))
816 .icon_size(IconSize::Small)
817 .on_click({
818 let instructions = instructions.clone();
819 cx.listener(move |_, _, cx| {
820 cx.write_to_clipboard(ClipboardItem::new(
821 instructions.to_string(),
822 ))
823 })
824 }),
825 ),
826 )
827 .child(v_flex().w_full().child(self.markdown.clone()))
828 .when(status == DevServerStatus::Offline, |this| {
829 this.child(Self::render_loading_spinner("Waiting for connection…"))
830 })
831 .when(status == DevServerStatus::Online, |this| {
832 this.child(Label::new("🎊 Connection established!")).child(
833 h_flex()
834 .justify_end()
835 .child(Button::new("done", "Done").on_click(
836 cx.listener(|_, _, cx| cx.dispatch_action(menu::Cancel.boxed_clone())),
837 )),
838 )
839 })
840 }
841
842 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
843 h_flex()
844 .gap_2()
845 .child(
846 Icon::new(IconName::ArrowCircle)
847 .size(IconSize::Medium)
848 .with_animation(
849 "arrow-circle",
850 Animation::new(Duration::from_secs(2)).repeat(),
851 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
852 ),
853 )
854 .child(Label::new(label))
855 }
856
857 fn render_edit_dev_server(
858 &mut self,
859 edit_dev_server: EditDevServer,
860 cx: &mut ViewContext<Self>,
861 ) -> impl IntoElement {
862 let dev_server_id = edit_dev_server.dev_server_id;
863 let dev_server = self
864 .dev_server_store
865 .read(cx)
866 .dev_server(dev_server_id)
867 .cloned();
868
869 let dev_server_name = dev_server
870 .as_ref()
871 .map(|dev_server| dev_server.name.clone())
872 .unwrap_or_default();
873
874 let dev_server_status = dev_server
875 .map(|dev_server| dev_server.status)
876 .unwrap_or(DevServerStatus::Offline);
877
878 let disabled = matches!(
879 edit_dev_server.state,
880 EditDevServerState::RenamingDevServer | EditDevServerState::RegeneratingToken
881 );
882 self.rename_dev_server_input.update(cx, |input, cx| {
883 input.set_disabled(disabled, cx);
884 });
885
886 let rename_dev_server_input_text = self
887 .rename_dev_server_input
888 .read(cx)
889 .editor()
890 .read(cx)
891 .text(cx);
892
893 let content = v_flex().w_full().gap_2().child(
894 h_flex()
895 .pb_2()
896 .border_b_1()
897 .border_color(cx.theme().colors().border)
898 .items_end()
899 .w_full()
900 .px_2()
901 .child(
902 div()
903 .pl_2()
904 .max_w(rems(16.))
905 .child(self.rename_dev_server_input.clone()),
906 )
907 .child(
908 div()
909 .pl_1()
910 .pb(px(3.))
911 .when(
912 edit_dev_server.state != EditDevServerState::RenamingDevServer,
913 |div| {
914 div.child(
915 Button::new("rename-dev-server", "Rename")
916 .disabled(
917 rename_dev_server_input_text.trim().is_empty()
918 || rename_dev_server_input_text == dev_server_name,
919 )
920 .on_click(cx.listener(move |this, _, cx| {
921 this.rename_dev_server(dev_server_id, cx);
922 cx.notify();
923 })),
924 )
925 },
926 )
927 .when(
928 edit_dev_server.state == EditDevServerState::RenamingDevServer,
929 |div| {
930 div.child(
931 Button::new("rename-dev-server", "Renaming...").disabled(true),
932 )
933 },
934 ),
935 ),
936 );
937
938 let content = content.child(match edit_dev_server.state {
939 EditDevServerState::RegeneratingToken => {
940 Self::render_loading_spinner("Generating token...")
941 }
942 EditDevServerState::RegeneratedToken(response) => self
943 .render_dev_server_token_instructions(
944 &response.access_token,
945 &dev_server_name,
946 dev_server_status,
947 cx,
948 ),
949 _ => h_flex().items_end().w_full().child(
950 Button::new("regenerate-dev-server-token", "Generate new access token")
951 .icon(IconName::Update)
952 .on_click(cx.listener(move |this, _, cx| {
953 this.refresh_dev_server_token(dev_server_id, cx);
954 cx.notify();
955 })),
956 ),
957 });
958
959 v_flex()
960 .id("scroll-container")
961 .h_full()
962 .overflow_y_scroll()
963 .track_scroll(&self.scroll_handle)
964 .px_1()
965 .pt_0p5()
966 .gap_px()
967 .child(
968 ModalHeader::new("edit-dev-server")
969 .show_back_button(true)
970 .child(
971 Headline::new(format!("Edit {}", &dev_server_name))
972 .size(HeadlineSize::Small),
973 ),
974 )
975 .child(ModalContent::new().child(v_flex().w_full().child(content)))
976 }
977
978 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
979 let dev_servers = self.dev_server_store.read(cx).dev_servers();
980
981 let Mode::Default(create_dev_server_project) = &self.mode else {
982 unreachable!()
983 };
984
985 let mut is_creating = None;
986 let mut creating_dev_server = None;
987 if let Some(CreateDevServerProject {
988 creating,
989 dev_server_id,
990 ..
991 }) = create_dev_server_project
992 {
993 is_creating = Some(*creating);
994 creating_dev_server = Some(*dev_server_id);
995 };
996
997 v_flex()
998 .id("scroll-container")
999 .h_full()
1000 .overflow_y_scroll()
1001 .track_scroll(&self.scroll_handle)
1002 .px_1()
1003 .pt_0p5()
1004 .gap_px()
1005 .child(
1006 ModalHeader::new("remote-projects")
1007 .show_dismiss_button(true)
1008 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
1009 )
1010 .child(
1011 ModalContent::new().child(
1012 List::new()
1013 .empty_message("No dev servers registered.")
1014 .header(Some(
1015 ListHeader::new("Dev Servers").end_slot(
1016 Button::new("register-dev-server-button", "New Server")
1017 .icon(IconName::Plus)
1018 .icon_position(IconPosition::Start)
1019 .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
1020 .on_click(cx.listener(|this, _, cx| {
1021 this.mode =
1022 Mode::CreateDevServer(CreateDevServer::default());
1023 this.dev_server_name_input.update(cx, |text_field, cx| {
1024 text_field.editor().update(cx, |editor, cx| {
1025 editor.set_text("", cx);
1026 });
1027 });
1028 this.use_server_name_in_ssh = Selection::Unselected;
1029 cx.notify();
1030 })),
1031 ),
1032 ))
1033 .children(dev_servers.iter().map(|dev_server| {
1034 let creating = if creating_dev_server == Some(dev_server.id) {
1035 is_creating
1036 } else {
1037 None
1038 };
1039 self.render_dev_server(dev_server, creating, cx)
1040 .into_any_element()
1041 })),
1042 ),
1043 )
1044 }
1045}
1046
1047fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1048 element
1049 .read(cx)
1050 .editor()
1051 .read(cx)
1052 .text(cx)
1053 .trim()
1054 .to_string()
1055}
1056
1057impl ModalView for DevServerProjects {}
1058
1059impl FocusableView for DevServerProjects {
1060 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1061 self.focus_handle.clone()
1062 }
1063}
1064
1065impl EventEmitter<DismissEvent> for DevServerProjects {}
1066
1067impl Render for DevServerProjects {
1068 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1069 div()
1070 .track_focus(&self.focus_handle)
1071 .elevation_3(cx)
1072 .key_context("DevServerModal")
1073 .on_action(cx.listener(Self::cancel))
1074 .on_action(cx.listener(Self::confirm))
1075 .on_mouse_down_out(cx.listener(|this, _, cx| {
1076 if matches!(this.mode, Mode::Default(None)) {
1077 cx.emit(DismissEvent)
1078 } else {
1079 this.focus_handle(cx).focus(cx);
1080 cx.stop_propagation()
1081 }
1082 }))
1083 .pb_4()
1084 .w(rems(34.))
1085 .min_h(rems(20.))
1086 .max_h(rems(40.))
1087 .child(match &self.mode {
1088 Mode::Default(_) => self.render_default(cx).into_any_element(),
1089 Mode::CreateDevServer(state) => self
1090 .render_create_dev_server(state.clone(), cx)
1091 .into_any_element(),
1092 Mode::EditDevServer(state) => self
1093 .render_edit_dev_server(state.clone(), cx)
1094 .into_any_element(),
1095 })
1096 }
1097}