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::Info,
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, 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}