remote_projects.rs

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