dev_servers.rs

  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}