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