1use std::time::Duration;
2
3use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
4use feature_flags::FeatureFlagViewExt;
5use gpui::{
6 percentage, Action, Animation, AnimationExt, AppContext, ClipboardItem, DismissEvent,
7 EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View,
8 ViewContext,
9};
10use rpc::{
11 proto::{self, CreateDevServerResponse, DevServerStatus},
12 ErrorCode, ErrorExt,
13};
14use settings::Settings;
15use theme::ThemeSettings;
16use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip};
17use ui_text_field::{FieldLabelLayout, TextField};
18use util::ResultExt;
19use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
20
21use crate::OpenRemote;
22
23pub struct DevServerProjects {
24 mode: Mode,
25 focus_handle: FocusHandle,
26 scroll_handle: ScrollHandle,
27 dev_server_store: Model<dev_server_projects::Store>,
28 project_path_input: View<TextField>,
29 dev_server_name_input: View<TextField>,
30 _subscription: gpui::Subscription,
31}
32
33#[derive(Default)]
34struct CreateDevServer {
35 creating: bool,
36 dev_server: Option<CreateDevServerResponse>,
37}
38
39struct CreateDevServerProject {
40 dev_server_id: DevServerId,
41 creating: bool,
42 dev_server_project: Option<proto::DevServerProject>,
43}
44
45enum Mode {
46 Default,
47 CreateDevServerProject(CreateDevServerProject),
48 CreateDevServer(CreateDevServer),
49}
50
51impl DevServerProjects {
52 pub fn register(_: &mut Workspace, cx: &mut ViewContext<Workspace>) {
53 cx.observe_flag::<feature_flags::Remoting, _>(|enabled, workspace, _| {
54 if enabled {
55 workspace.register_action(|workspace, _: &OpenRemote, cx| {
56 workspace.toggle_modal(cx, |cx| Self::new(cx))
57 });
58 }
59 })
60 .detach();
61 }
62
63 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
64 workspace.update(cx, |workspace, cx| {
65 workspace.toggle_modal(cx, |cx| Self::new(cx))
66 })
67 }
68
69 pub fn new(cx: &mut ViewContext<Self>) -> Self {
70 let project_path_input = cx.new_view(|cx| TextField::new(cx, "", "Project path"));
71 let dev_server_name_input =
72 cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked));
73
74 let focus_handle = cx.focus_handle();
75 let dev_server_store = dev_server_projects::Store::global(cx);
76
77 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
78 cx.notify();
79 });
80
81 Self {
82 mode: Mode::Default,
83 focus_handle,
84 scroll_handle: ScrollHandle::new(),
85 dev_server_store,
86 project_path_input,
87 dev_server_name_input,
88 _subscription: subscription,
89 }
90 }
91
92 pub fn create_dev_server_project(
93 &mut self,
94 dev_server_id: DevServerId,
95 cx: &mut ViewContext<Self>,
96 ) {
97 let path = self
98 .project_path_input
99 .read(cx)
100 .editor()
101 .read(cx)
102 .text(cx)
103 .trim()
104 .to_string();
105
106 if path == "" {
107 return;
108 }
109
110 if self
111 .dev_server_store
112 .read(cx)
113 .projects_for_server(dev_server_id)
114 .iter()
115 .any(|p| p.path == path)
116 {
117 cx.spawn(|_, mut cx| async move {
118 cx.prompt(
119 gpui::PromptLevel::Critical,
120 "Failed to create project",
121 Some(&format!(
122 "Project {} already exists for this dev server.",
123 path
124 )),
125 &["Ok"],
126 )
127 .await
128 })
129 .detach_and_log_err(cx);
130 return;
131 }
132
133 let create = {
134 let path = path.clone();
135 self.dev_server_store.update(cx, |store, cx| {
136 store.create_dev_server_project(dev_server_id, path, cx)
137 })
138 };
139
140 cx.spawn(|this, mut cx| async move {
141 let result = create.await;
142 let dev_server_project = result
143 .as_ref()
144 .ok()
145 .and_then(|r| r.dev_server_project.clone());
146 this.update(&mut cx, |this, _| {
147 this.mode = Mode::CreateDevServerProject(CreateDevServerProject {
148 dev_server_id,
149 creating: false,
150 dev_server_project,
151 });
152 })
153 .log_err();
154 result
155 })
156 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
157 match e.error_code() {
158 ErrorCode::DevServerOffline => Some(
159 "The dev server is offline. Please log in and check it is connected."
160 .to_string(),
161 ),
162 ErrorCode::DevServerProjectPathDoesNotExist => {
163 Some(format!("The path `{}` does not exist on the server.", path))
164 }
165 _ => None,
166 }
167 });
168
169 self.project_path_input.update(cx, |input, cx| {
170 input.editor().update(cx, |editor, cx| {
171 editor.set_text("", cx);
172 });
173 });
174
175 self.mode = Mode::CreateDevServerProject(CreateDevServerProject {
176 dev_server_id,
177 creating: true,
178 dev_server_project: None,
179 });
180 }
181
182 pub fn create_dev_server(&mut self, cx: &mut ViewContext<Self>) {
183 let name = self
184 .dev_server_name_input
185 .read(cx)
186 .editor()
187 .read(cx)
188 .text(cx)
189 .trim()
190 .to_string();
191
192 if name == "" {
193 return;
194 }
195
196 let dev_server = self
197 .dev_server_store
198 .update(cx, |store, cx| store.create_dev_server(name.clone(), cx));
199
200 cx.spawn(|this, mut cx| async move {
201 let result = dev_server.await;
202
203 this.update(&mut cx, |this, _| match &result {
204 Ok(dev_server) => {
205 this.mode = Mode::CreateDevServer(CreateDevServer {
206 creating: false,
207 dev_server: Some(dev_server.clone()),
208 });
209 }
210 Err(_) => {
211 this.mode = Mode::CreateDevServer(Default::default());
212 }
213 })
214 .log_err();
215 result
216 })
217 .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
218
219 self.mode = Mode::CreateDevServer(CreateDevServer {
220 creating: true,
221 dev_server: None,
222 });
223 cx.notify()
224 }
225
226 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
227 let answer = cx.prompt(
228 gpui::PromptLevel::Destructive,
229 "Are you sure?",
230 Some("This will delete the dev server and all of its remote projects."),
231 &["Delete", "Cancel"],
232 );
233
234 cx.spawn(|this, mut cx| async move {
235 let answer = answer.await?;
236
237 if answer != 0 {
238 return Ok(());
239 }
240
241 this.update(&mut cx, |this, cx| {
242 this.dev_server_store
243 .update(cx, |store, cx| store.delete_dev_server(id, cx))
244 })?
245 .await
246 })
247 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
248 }
249
250 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
251 match self.mode {
252 Mode::Default => {}
253 Mode::CreateDevServerProject(CreateDevServerProject { dev_server_id, .. }) => {
254 self.create_dev_server_project(dev_server_id, cx);
255 }
256 Mode::CreateDevServer(_) => {
257 self.create_dev_server(cx);
258 }
259 }
260 }
261
262 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
263 match self.mode {
264 Mode::Default => cx.emit(DismissEvent),
265 Mode::CreateDevServerProject(_) | Mode::CreateDevServer(_) => {
266 self.mode = Mode::Default;
267 self.focus_handle(cx).focus(cx);
268 cx.notify();
269 }
270 }
271 }
272
273 fn render_dev_server(
274 &mut self,
275 dev_server: &DevServer,
276 cx: &mut ViewContext<Self>,
277 ) -> impl IntoElement {
278 let dev_server_id = dev_server.id;
279 let status = dev_server.status;
280
281 v_flex()
282 .w_full()
283 .child(
284 h_flex()
285 .group("dev-server")
286 .justify_between()
287 .child(
288 h_flex()
289 .gap_2()
290 .child(
291 div()
292 .id(("status", dev_server.id.0))
293 .relative()
294 .child(Icon::new(IconName::Server).size(IconSize::Small))
295 .child(
296 div().absolute().bottom_0().left(rems_from_px(8.0)).child(
297 Indicator::dot().color(match status {
298 DevServerStatus::Online => Color::Created,
299 DevServerStatus::Offline => Color::Hidden,
300 }),
301 ),
302 )
303 .tooltip(move |cx| {
304 Tooltip::text(
305 match status {
306 DevServerStatus::Online => "Online",
307 DevServerStatus::Offline => "Offline",
308 },
309 cx,
310 )
311 }),
312 )
313 .child(dev_server.name.clone())
314 .child(
315 h_flex()
316 .visible_on_hover("dev-server")
317 .gap_1()
318 .child(
319 IconButton::new("edit-dev-server", IconName::Pencil)
320 .disabled(true) //TODO implement this on the collab side
321 .tooltip(|cx| {
322 Tooltip::text("Coming Soon - Edit dev server", cx)
323 }),
324 )
325 .child({
326 let dev_server_id = dev_server.id;
327 IconButton::new("remove-dev-server", IconName::Trash)
328 .on_click(cx.listener(move |this, _, cx| {
329 this.delete_dev_server(dev_server_id, cx)
330 }))
331 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
332 }),
333 ),
334 )
335 .child(
336 h_flex().gap_1().child(
337 IconButton::new(
338 ("add-remote-project", dev_server_id.0),
339 IconName::Plus,
340 )
341 .tooltip(|cx| Tooltip::text("Add a remote project", cx))
342 .on_click(cx.listener(
343 move |this, _, cx| {
344 this.mode =
345 Mode::CreateDevServerProject(CreateDevServerProject {
346 dev_server_id,
347 creating: false,
348 dev_server_project: None,
349 });
350 this.project_path_input.read(cx).focus_handle(cx).focus(cx);
351 cx.notify();
352 },
353 )),
354 ),
355 ),
356 )
357 .child(
358 v_flex()
359 .w_full()
360 .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
361 .border()
362 .border_color(cx.theme().colors().border_variant)
363 .rounded_md()
364 .my_1()
365 .py_0p5()
366 .px_3()
367 .child(
368 List::new().empty_message("No projects.").children(
369 self.dev_server_store
370 .read(cx)
371 .projects_for_server(dev_server.id)
372 .iter()
373 .map(|p| self.render_dev_server_project(p, cx)),
374 ),
375 ),
376 )
377 }
378
379 fn render_dev_server_project(
380 &mut self,
381 project: &DevServerProject,
382 cx: &mut ViewContext<Self>,
383 ) -> impl IntoElement {
384 let dev_server_project_id = project.id;
385 let project_id = project.project_id;
386 let is_online = project_id.is_some();
387
388 ListItem::new(("remote-project", dev_server_project_id.0))
389 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
390 .child(
391 Label::new(project.path.clone())
392 )
393 .on_click(cx.listener(move |_, _, cx| {
394 if let Some(project_id) = project_id {
395 if let Some(app_state) = AppState::global(cx).upgrade() {
396 workspace::join_dev_server_project(project_id, app_state, None, cx)
397 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
398 }
399 } else {
400 cx.spawn(|_, mut cx| async move {
401 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();
402 }).detach();
403 }
404 }))
405 }
406
407 fn render_create_dev_server(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
408 let Mode::CreateDevServer(CreateDevServer {
409 creating,
410 dev_server,
411 }) = &self.mode
412 else {
413 unreachable!()
414 };
415
416 self.dev_server_name_input.update(cx, |input, cx| {
417 input.set_disabled(*creating || dev_server.is_some(), cx);
418 });
419
420 v_flex()
421 .id("scroll-container")
422 .h_full()
423 .overflow_y_scroll()
424 .track_scroll(&self.scroll_handle)
425 .px_1()
426 .pt_0p5()
427 .gap_px()
428 .child(
429 ModalHeader::new("remote-projects")
430 .show_back_button(true)
431 .child(Headline::new("New dev server").size(HeadlineSize::Small)),
432 )
433 .child(
434 ModalContent::new().child(
435 v_flex()
436 .w_full()
437 .child(
438 h_flex()
439 .pb_2()
440 .items_end()
441 .w_full()
442 .px_2()
443 .border_b_1()
444 .border_color(cx.theme().colors().border)
445 .child(
446 div()
447 .pl_2()
448 .max_w(rems(16.))
449 .child(self.dev_server_name_input.clone()),
450 )
451 .child(
452 div()
453 .pl_1()
454 .pb(px(3.))
455 .when(!*creating && dev_server.is_none(), |div| {
456 div.child(Button::new("create-dev-server", "Create").on_click(
457 cx.listener(move |this, _, cx| {
458 this.create_dev_server(cx);
459 }),
460 ))
461 })
462 .when(*creating && dev_server.is_none(), |div| {
463 div.child(
464 Button::new("create-dev-server", "Creating...")
465 .disabled(true),
466 )
467 }),
468 )
469 )
470 .when(dev_server.is_none(), |div| {
471 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))
472 })
473 .when_some(dev_server.clone(), |div, dev_server| {
474 let status = self
475 .dev_server_store
476 .read(cx)
477 .dev_server_status(DevServerId(dev_server.dev_server_id));
478
479 let instructions = SharedString::from(format!(
480 "zed --dev-server-token {}",
481 dev_server.access_token
482 ));
483 div.child(
484 v_flex()
485 .pl_2()
486 .pt_2()
487 .gap_2()
488 .child(
489 h_flex().justify_between().w_full()
490 .child(Label::new(format!(
491 "Please log into `{}` and run:",
492 dev_server.name
493 )))
494 .child(
495 Button::new("copy-access-token", "Copy Instructions")
496 .icon(Some(IconName::Copy))
497 .icon_size(IconSize::Small)
498 .on_click({
499 let instructions = instructions.clone();
500 cx.listener(move |_, _, cx| {
501 cx.write_to_clipboard(ClipboardItem::new(
502 instructions.to_string(),
503 ))
504 })})
505 )
506 )
507 .child(
508 v_flex()
509 .w_full()
510 .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct
511 .border()
512 .border_color(cx.theme().colors().border_variant)
513 .rounded_md()
514 .my_1()
515 .py_0p5()
516 .px_3()
517 .font_family(ThemeSettings::get_global(cx).buffer_font.family.clone())
518 .child(Label::new(instructions))
519 )
520 .when(status == DevServerStatus::Offline, |this| {
521 this.child(
522
523 h_flex()
524 .gap_2()
525 .child(
526 Icon::new(IconName::ArrowCircle)
527 .size(IconSize::Medium)
528 .with_animation(
529 "arrow-circle",
530 Animation::new(Duration::from_secs(2)).repeat(),
531 |icon, delta| {
532 icon.transform(Transformation::rotate(percentage(delta)))
533 },
534 ),
535 )
536 .child(
537 Label::new("Waiting for connection…"),
538 )
539 )
540 })
541 .when(status == DevServerStatus::Online, |this| {
542 this.child(Label::new("🎊 Connection established!"))
543 .child(
544 h_flex().justify_end().child(
545 Button::new("done", "Done").on_click(cx.listener(
546 |_, _, cx| {
547 cx.dispatch_action(menu::Cancel.boxed_clone())
548 },
549 ))
550 ),
551 )
552 }),
553 )
554 }),
555 )
556 )
557 }
558
559 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
560 let dev_servers = self.dev_server_store.read(cx).dev_servers();
561
562 v_flex()
563 .id("scroll-container")
564 .h_full()
565 .overflow_y_scroll()
566 .track_scroll(&self.scroll_handle)
567 .px_1()
568 .pt_0p5()
569 .gap_px()
570 .child(
571 ModalHeader::new("remote-projects")
572 .show_dismiss_button(true)
573 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
574 )
575 .child(
576 ModalContent::new().child(
577 List::new()
578 .empty_message("No dev servers registered.")
579 .header(Some(
580 ListHeader::new("Dev Servers").end_slot(
581 Button::new("register-dev-server-button", "New Server")
582 .icon(IconName::Plus)
583 .icon_position(IconPosition::Start)
584 .tooltip(|cx| Tooltip::text("Register a new dev server", cx))
585 .on_click(cx.listener(|this, _, cx| {
586 this.mode = Mode::CreateDevServer(Default::default());
587
588 this.dev_server_name_input.update(cx, |input, cx| {
589 input.editor().update(cx, |editor, cx| {
590 editor.set_text("", cx);
591 });
592 input.focus_handle(cx).focus(cx)
593 });
594
595 cx.notify();
596 })),
597 ),
598 ))
599 .children(dev_servers.iter().map(|dev_server| {
600 self.render_dev_server(dev_server, cx).into_any_element()
601 })),
602 ),
603 )
604 }
605
606 fn render_create_dev_server_project(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
607 let Mode::CreateDevServerProject(CreateDevServerProject {
608 dev_server_id,
609 creating,
610 dev_server_project,
611 }) = &self.mode
612 else {
613 unreachable!()
614 };
615
616 let dev_server = self
617 .dev_server_store
618 .read(cx)
619 .dev_server(*dev_server_id)
620 .cloned();
621
622 let (dev_server_name, dev_server_status) = dev_server
623 .map(|server| (server.name, server.status))
624 .unwrap_or((SharedString::from(""), DevServerStatus::Offline));
625
626 v_flex()
627 .px_1()
628 .pt_0p5()
629 .gap_px()
630 .child(
631 v_flex().py_0p5().px_1().child(
632 h_flex()
633 .px_1()
634 .py_0p5()
635 .child(
636 IconButton::new("back", IconName::ArrowLeft)
637 .style(ButtonStyle::Transparent)
638 .on_click(cx.listener(|_, _: &gpui::ClickEvent, cx| {
639 cx.dispatch_action(menu::Cancel.boxed_clone())
640 })),
641 )
642 .child(Headline::new("Add remote project").size(HeadlineSize::Small)),
643 ),
644 )
645 .child(
646 h_flex()
647 .ml_5()
648 .gap_2()
649 .child(
650 div()
651 .id(("status", dev_server_id.0))
652 .relative()
653 .child(Icon::new(IconName::Server))
654 .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child(
655 Indicator::dot().color(match dev_server_status {
656 DevServerStatus::Online => Color::Created,
657 DevServerStatus::Offline => Color::Hidden,
658 }),
659 ))
660 .tooltip(move |cx| {
661 Tooltip::text(
662 match dev_server_status {
663 DevServerStatus::Online => "Online",
664 DevServerStatus::Offline => "Offline",
665 },
666 cx,
667 )
668 }),
669 )
670 .child(dev_server_name.clone()),
671 )
672 .child(
673 h_flex()
674 .ml_5()
675 .gap_2()
676 .child(self.project_path_input.clone())
677 .when(!*creating && dev_server_project.is_none(), |div| {
678 div.child(Button::new("create-remote-server", "Create").on_click({
679 let dev_server_id = *dev_server_id;
680 cx.listener(move |this, _, cx| {
681 this.create_dev_server_project(dev_server_id, cx)
682 })
683 }))
684 })
685 .when(*creating, |div| {
686 div.child(Button::new("create-dev-server", "Creating...").disabled(true))
687 }),
688 )
689 .when_some(dev_server_project.clone(), |div, dev_server_project| {
690 let status = self
691 .dev_server_store
692 .read(cx)
693 .dev_server_project(DevServerProjectId(dev_server_project.id))
694 .map(|project| {
695 if project.project_id.is_some() {
696 DevServerStatus::Online
697 } else {
698 DevServerStatus::Offline
699 }
700 })
701 .unwrap_or(DevServerStatus::Offline);
702 div.child(
703 v_flex()
704 .ml_5()
705 .ml_8()
706 .gap_2()
707 .when(status == DevServerStatus::Offline, |this| {
708 this.child(Label::new("Waiting for project..."))
709 })
710 .when(status == DevServerStatus::Online, |this| {
711 this.child(Label::new("Project online! 🎊")).child(
712 Button::new("done", "Done").on_click(cx.listener(|_, _, cx| {
713 cx.dispatch_action(menu::Cancel.boxed_clone())
714 })),
715 )
716 }),
717 )
718 })
719 }
720}
721impl ModalView for DevServerProjects {}
722
723impl FocusableView for DevServerProjects {
724 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
725 self.focus_handle.clone()
726 }
727}
728
729impl EventEmitter<DismissEvent> for DevServerProjects {}
730
731impl Render for DevServerProjects {
732 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
733 div()
734 .track_focus(&self.focus_handle)
735 .elevation_3(cx)
736 .key_context("DevServerModal")
737 .on_action(cx.listener(Self::cancel))
738 .on_action(cx.listener(Self::confirm))
739 .on_mouse_down_out(cx.listener(|this, _, cx| {
740 if matches!(this.mode, Mode::Default) {
741 cx.emit(DismissEvent)
742 }
743 }))
744 .pb_4()
745 .w(rems(34.))
746 .min_h(rems(20.))
747 .max_h(rems(40.))
748 .child(match &self.mode {
749 Mode::Default => self.render_default(cx).into_any_element(),
750 Mode::CreateDevServerProject(_) => {
751 self.render_create_dev_server_project(cx).into_any_element()
752 }
753 Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(),
754 })
755 }
756}