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