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