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}