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