1use std::time::Duration;
2
3use anyhow::Context;
4use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
5use editor::Editor;
6use feature_flags::FeatureFlagAppExt;
7use feature_flags::FeatureFlagViewExt;
8use gpui::Subscription;
9use gpui::Task;
10use gpui::WeakView;
11use gpui::{
12 percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
13 FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
14};
15use markdown::Markdown;
16use markdown::MarkdownStyle;
17use rpc::proto::RegenerateDevServerTokenResponse;
18use rpc::{
19 proto::{CreateDevServerResponse, DevServerStatus},
20 ErrorCode, ErrorExt,
21};
22use task::RevealStrategy;
23use task::SpawnInTerminal;
24use task::TerminalWorkDir;
25use terminal_view::terminal_panel::TerminalPanel;
26use ui::ElevationIndex;
27use ui::Section;
28use ui::{
29 prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
30 RadioWithLabel, Tooltip,
31};
32use ui_text_field::{FieldLabelLayout, TextField};
33use util::ResultExt;
34use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
35
36use crate::OpenRemote;
37
38pub struct DevServerProjects {
39 mode: Mode,
40 focus_handle: FocusHandle,
41 scroll_handle: ScrollHandle,
42 dev_server_store: Model<dev_server_projects::Store>,
43 workspace: WeakView<Workspace>,
44 project_path_input: View<Editor>,
45 dev_server_name_input: View<TextField>,
46 markdown: View<Markdown>,
47 _dev_server_subscription: Subscription,
48}
49
50#[derive(Default, Clone)]
51struct CreateDevServer {
52 creating: bool,
53 dev_server_id: Option<DevServerId>,
54 access_token: Option<String>,
55 manual_setup: bool,
56}
57
58struct CreateDevServerProject {
59 dev_server_id: DevServerId,
60 creating: bool,
61 _opening: Option<Subscription>,
62}
63
64enum Mode {
65 Default(Option<CreateDevServerProject>),
66 CreateDevServer(CreateDevServer),
67}
68
69impl DevServerProjects {
70 pub fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
71 cx.observe_flag::<feature_flags::Remoting, _>(|enabled, workspace, _| {
72 if enabled {
73 Self::register_open_remote_action(workspace);
74 }
75 })
76 .detach();
77
78 if cx.has_flag::<feature_flags::Remoting>() {
79 Self::register_open_remote_action(workspace);
80 }
81 }
82
83 fn register_open_remote_action(workspace: &mut Workspace) {
84 workspace.register_action(|workspace, _: &OpenRemote, cx| {
85 let handle = cx.view().downgrade();
86 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
87 });
88 }
89
90 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
91 workspace.update(cx, |workspace, cx| {
92 let handle = cx.view().downgrade();
93 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
94 })
95 }
96
97 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
98 let project_path_input = cx.new_view(|cx| {
99 let mut editor = Editor::single_line(cx);
100 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
101 editor
102 });
103 let dev_server_name_input = cx.new_view(|cx| {
104 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
105 });
106
107 let focus_handle = cx.focus_handle();
108 let dev_server_store = dev_server_projects::Store::global(cx);
109
110 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
111 cx.notify();
112 });
113
114 let markdown_style = MarkdownStyle {
115 code_block: gpui::TextStyleRefinement {
116 font_family: Some("Zed Mono".into()),
117 color: Some(cx.theme().colors().editor_foreground),
118 background_color: Some(cx.theme().colors().editor_background),
119 ..Default::default()
120 },
121 inline_code: Default::default(),
122 block_quote: Default::default(),
123 link: gpui::TextStyleRefinement {
124 color: Some(Color::Accent.color(cx)),
125 ..Default::default()
126 },
127 rule_color: Default::default(),
128 block_quote_border_color: Default::default(),
129 syntax: cx.theme().syntax().clone(),
130 selection_background_color: cx.theme().players().local().selection,
131 };
132 let markdown = cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx));
133
134 Self {
135 mode: Mode::Default(None),
136 focus_handle,
137 scroll_handle: ScrollHandle::new(),
138 dev_server_store,
139 project_path_input,
140 dev_server_name_input,
141 markdown,
142 workspace,
143 _dev_server_subscription: subscription,
144 }
145 }
146
147 pub fn create_dev_server_project(
148 &mut self,
149 dev_server_id: DevServerId,
150 cx: &mut ViewContext<Self>,
151 ) {
152 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
153
154 if path == "" {
155 return;
156 }
157
158 if !path.starts_with('/') && !path.starts_with('~') {
159 path = format!("~/{}", path);
160 }
161
162 if self
163 .dev_server_store
164 .read(cx)
165 .projects_for_server(dev_server_id)
166 .iter()
167 .any(|p| p.path == path)
168 {
169 cx.spawn(|_, mut cx| async move {
170 cx.prompt(
171 gpui::PromptLevel::Critical,
172 "Failed to create project",
173 Some(&format!(
174 "Project {} already exists for this dev server.",
175 path
176 )),
177 &["Ok"],
178 )
179 .await
180 })
181 .detach_and_log_err(cx);
182 return;
183 }
184
185 let create = {
186 let path = path.clone();
187 self.dev_server_store.update(cx, |store, cx| {
188 store.create_dev_server_project(dev_server_id, path, cx)
189 })
190 };
191
192 cx.spawn(|this, mut cx| async move {
193 let result = create.await;
194 this.update(&mut cx, |this, cx| {
195 if let Ok(result) = &result {
196 if let Some(dev_server_project_id) =
197 result.dev_server_project.as_ref().map(|p| p.id)
198 {
199 let subscription =
200 cx.observe(&this.dev_server_store, move |this, store, cx| {
201 if let Some(project_id) = store
202 .read(cx)
203 .dev_server_project(DevServerProjectId(dev_server_project_id))
204 .and_then(|p| p.project_id)
205 {
206 this.project_path_input.update(cx, |editor, cx| {
207 editor.set_text("", cx);
208 });
209 this.mode = Mode::Default(None);
210 if let Some(app_state) = AppState::global(cx).upgrade() {
211 workspace::join_dev_server_project(
212 project_id, app_state, None, cx,
213 )
214 .detach_and_prompt_err(
215 "Could not join project",
216 cx,
217 |_, _| None,
218 )
219 }
220 }
221 });
222
223 this.mode = Mode::Default(Some(CreateDevServerProject {
224 dev_server_id,
225 creating: true,
226 _opening: Some(subscription),
227 }));
228 }
229 } else {
230 this.mode = Mode::Default(Some(CreateDevServerProject {
231 dev_server_id,
232 creating: false,
233 _opening: None,
234 }));
235 }
236 })
237 .log_err();
238 result
239 })
240 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
241 match e.error_code() {
242 ErrorCode::DevServerOffline => Some(
243 "The dev server is offline. Please log in and check it is connected."
244 .to_string(),
245 ),
246 ErrorCode::DevServerProjectPathDoesNotExist => {
247 Some(format!("The path `{}` does not exist on the server.", path))
248 }
249 _ => None,
250 }
251 });
252
253 self.mode = Mode::Default(Some(CreateDevServerProject {
254 dev_server_id,
255 creating: true,
256 _opening: None,
257 }));
258 }
259
260 pub fn create_or_update_dev_server(
261 &mut self,
262 manual_setup: bool,
263 existing_id: Option<DevServerId>,
264 access_token: Option<String>,
265 cx: &mut ViewContext<Self>,
266 ) {
267 let name = get_text(&self.dev_server_name_input, cx);
268 if name.is_empty() {
269 return;
270 }
271
272 let ssh_connection_string = if manual_setup {
273 None
274 } else if name.contains(' ') {
275 Some(name.clone())
276 } else {
277 Some(format!("ssh {}", name))
278 };
279
280 let dev_server = self.dev_server_store.update(cx, {
281 let access_token = access_token.clone();
282 |store, cx| {
283 let ssh_connection_string = ssh_connection_string.clone();
284 if let Some(dev_server_id) = existing_id {
285 let rename = store.rename_dev_server(
286 dev_server_id,
287 name.clone(),
288 ssh_connection_string,
289 cx,
290 );
291 let token = if let Some(access_token) = access_token {
292 Task::ready(Ok(RegenerateDevServerTokenResponse {
293 dev_server_id: dev_server_id.0,
294 access_token,
295 }))
296 } else {
297 store.regenerate_dev_server_token(dev_server_id, cx)
298 };
299 cx.spawn(|_, _| async move {
300 rename.await?;
301 let response = token.await?;
302 Ok(CreateDevServerResponse {
303 dev_server_id: dev_server_id.0,
304 name,
305 access_token: response.access_token,
306 })
307 })
308 } else {
309 store.create_dev_server(name, ssh_connection_string.clone(), cx)
310 }
311 }
312 });
313
314 let workspace = self.workspace.clone();
315
316 cx.spawn({
317 let access_token = access_token.clone();
318 |this, mut cx| async move {
319 let result = dev_server.await;
320
321 match result {
322 Ok(dev_server) => {
323 if let Some(ssh_connection_string) = ssh_connection_string {
324
325 let access_token = access_token.clone();
326 this.update(&mut cx, |this, cx| {
327 this.focus_handle.focus(cx);
328 this.mode = Mode::CreateDevServer(CreateDevServer {
329 creating: true,
330 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
331 access_token: Some(access_token.unwrap_or(dev_server.access_token.clone())),
332 manual_setup: false,
333 });
334 cx.notify();
335 })?;
336 let terminal_panel = workspace
337 .update(&mut cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
338 .ok()
339 .flatten()
340 .with_context(|| anyhow::anyhow!("No terminal panel"))?;
341
342 let command = "sh".to_string();
343 let args = vec!["-x".to_string(),"-c".to_string(),
344 format!(r#"~/.local/bin/zed -v >/dev/stderr || (curl -sSL https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | bash && ~/.local/bin/zed --dev-server-token {}"#, dev_server.access_token)];
345
346 let terminal = terminal_panel.update(&mut cx, |terminal_panel, cx| {
347 terminal_panel.spawn_in_new_terminal(
348 SpawnInTerminal {
349 id: task::TaskId("ssh-remote".into()),
350 full_label: "Install zed over ssh".into(),
351 label: "Install zed over ssh".into(),
352 command,
353 args,
354 command_label: ssh_connection_string.clone(),
355 cwd: Some(TerminalWorkDir::Ssh { ssh_command: ssh_connection_string, path: None }),
356 env: Default::default(),
357 use_new_terminal: true,
358 allow_concurrent_runs: false,
359 reveal: RevealStrategy::Always,
360 },
361 cx,
362 )
363 })?.await?;
364
365 terminal.update(&mut cx, |terminal, cx| {
366 terminal.wait_for_completed_task(cx)
367 })?.await;
368
369 // There's a race-condition between the task completing successfully, and the server sending us the online status. Make it less likely we'll show the error state.
370 if this.update(&mut cx, |this, cx| {
371 this.dev_server_store.read(cx).dev_server_status(DevServerId(dev_server.dev_server_id))
372 })? == DevServerStatus::Offline {
373 cx.background_executor().timer(Duration::from_millis(200)).await
374 }
375 }
376
377 this.update(&mut cx, |this, cx| {
378 this.focus_handle.focus(cx);
379 this.mode = Mode::CreateDevServer(CreateDevServer {
380 creating: false,
381 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
382 access_token: Some(dev_server.access_token),
383 manual_setup,
384 });
385 cx.notify();
386 })?;
387 Ok(())
388 }
389 Err(e) => {
390 this.update(&mut cx, |this, cx| {
391 this.mode = Mode::CreateDevServer(CreateDevServer { creating:false, dev_server_id: existing_id, access_token: None, manual_setup });
392 cx.notify()
393 })
394 .log_err();
395
396 return Err(e)
397 }
398 }
399 }})
400 .detach_and_prompt_err("Failed to create server", cx, |_, _| None);
401
402 self.mode = Mode::CreateDevServer(CreateDevServer {
403 creating: true,
404 dev_server_id: existing_id,
405 access_token,
406 manual_setup,
407 });
408 cx.notify()
409 }
410
411 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
412 let store = self.dev_server_store.read(cx);
413 let prompt = if store.projects_for_server(id).is_empty()
414 && store
415 .dev_server(id)
416 .is_some_and(|server| server.status == DevServerStatus::Offline)
417 {
418 None
419 } else {
420 Some(cx.prompt(
421 gpui::PromptLevel::Warning,
422 "Are you sure?",
423 Some("This will delete the dev server and all of its remote projects."),
424 &["Delete", "Cancel"],
425 ))
426 };
427
428 cx.spawn(|this, mut cx| async move {
429 if let Some(prompt) = prompt {
430 if prompt.await? != 0 {
431 return Ok(());
432 }
433 }
434
435 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
436 this.dev_server_store.update(cx, |store, _| {
437 store
438 .projects_for_server(id)
439 .into_iter()
440 .map(|project| project.id)
441 .collect()
442 })
443 })?;
444
445 this.update(&mut cx, |this, cx| {
446 this.dev_server_store
447 .update(cx, |store, cx| store.delete_dev_server(id, cx))
448 })?
449 .await?;
450
451 for id in project_ids {
452 WORKSPACE_DB
453 .delete_workspace_by_dev_server_project_id(id)
454 .await
455 .log_err();
456 }
457 Ok(())
458 })
459 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
460 }
461
462 fn delete_dev_server_project(
463 &mut self,
464 id: DevServerProjectId,
465 path: &str,
466 cx: &mut ViewContext<Self>,
467 ) {
468 let answer = cx.prompt(
469 gpui::PromptLevel::Warning,
470 format!("Delete \"{}\"?", path).as_str(),
471 Some("This will delete the remote project. You can always re-add it later."),
472 &["Delete", "Cancel"],
473 );
474
475 cx.spawn(|this, mut cx| async move {
476 let answer = answer.await?;
477
478 if answer != 0 {
479 return Ok(());
480 }
481
482 this.update(&mut cx, |this, cx| {
483 this.dev_server_store
484 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
485 })?
486 .await?;
487
488 WORKSPACE_DB
489 .delete_workspace_by_dev_server_project_id(id)
490 .await
491 .log_err();
492
493 Ok(())
494 })
495 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
496 }
497
498 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
499 match &self.mode {
500 Mode::Default(None) => {}
501 Mode::Default(Some(create_project)) => {
502 self.create_dev_server_project(create_project.dev_server_id, cx);
503 }
504 Mode::CreateDevServer(state) => {
505 if !state.creating {
506 self.create_or_update_dev_server(
507 state.manual_setup,
508 state.dev_server_id,
509 state.access_token.clone(),
510 cx,
511 );
512 }
513 }
514 }
515 }
516
517 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
518 match self.mode {
519 Mode::Default(None) => cx.emit(DismissEvent),
520 _ => {
521 self.mode = Mode::Default(None);
522 self.focus_handle(cx).focus(cx);
523 cx.notify();
524 }
525 }
526 }
527
528 fn render_dev_server(
529 &mut self,
530 dev_server: &DevServer,
531 create_project: Option<bool>,
532 cx: &mut ViewContext<Self>,
533 ) -> impl IntoElement {
534 let dev_server_id = dev_server.id;
535 let status = dev_server.status;
536 let dev_server_name = dev_server.name.clone();
537 let manual_setup = dev_server.ssh_connection_string.is_none();
538
539 v_flex()
540 .w_full()
541 .child(
542 h_flex().group("dev-server").justify_between().child(
543 h_flex()
544 .gap_2()
545 .child(
546 div()
547 .id(("status", dev_server.id.0))
548 .relative()
549 .child(Icon::new(IconName::Server).size(IconSize::Small))
550 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
551 Indicator::dot().color(match status {
552 DevServerStatus::Online => Color::Created,
553 DevServerStatus::Offline => Color::Hidden,
554 }),
555 ))
556 .tooltip(move |cx| {
557 Tooltip::text(
558 match status {
559 DevServerStatus::Online => "Online",
560 DevServerStatus::Offline => "Offline",
561 },
562 cx,
563 )
564 }),
565 )
566 .child(
567 div()
568 .max_w(rems(26.))
569 .overflow_hidden()
570 .whitespace_nowrap()
571 .child(Label::new(dev_server_name.clone())),
572 )
573 .child(
574 h_flex()
575 .visible_on_hover("dev-server")
576 .gap_1()
577 .child(
578 IconButton::new("edit-dev-server", IconName::Pencil)
579 .on_click(cx.listener(move |this, _, cx| {
580 this.mode = Mode::CreateDevServer(CreateDevServer {
581 dev_server_id: Some(dev_server_id),
582 creating: false,
583 access_token: None,
584 manual_setup,
585 });
586 let dev_server_name = dev_server_name.clone();
587 this.dev_server_name_input.update(
588 cx,
589 move |input, cx| {
590 input.editor().update(cx, move |editor, cx| {
591 editor.set_text(dev_server_name, cx)
592 })
593 },
594 )
595 }))
596 .tooltip(|cx| Tooltip::text("Edit dev server", cx)),
597 )
598 .child({
599 let dev_server_id = dev_server.id;
600 IconButton::new("remove-dev-server", IconName::Trash)
601 .on_click(cx.listener(move |this, _, cx| {
602 this.delete_dev_server(dev_server_id, cx)
603 }))
604 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
605 }),
606 ),
607 ),
608 )
609 .child(
610 v_flex()
611 .w_full()
612 .bg(cx.theme().colors().background)
613 .border_1()
614 .border_color(cx.theme().colors().border_variant)
615 .rounded_md()
616 .my_1()
617 .py_0p5()
618 .px_3()
619 .child(
620 List::new()
621 .empty_message("No projects.")
622 .children(
623 self.dev_server_store
624 .read(cx)
625 .projects_for_server(dev_server.id)
626 .iter()
627 .map(|p| self.render_dev_server_project(p, cx)),
628 )
629 .when(
630 create_project.is_none()
631 && dev_server.status == DevServerStatus::Online,
632 |el| {
633 el.child(
634 ListItem::new("new-remote_project")
635 .start_slot(Icon::new(IconName::Plus))
636 .child(Label::new("Open folder…"))
637 .on_click(cx.listener(move |this, _, cx| {
638 this.mode =
639 Mode::Default(Some(CreateDevServerProject {
640 dev_server_id,
641 creating: false,
642 _opening: None,
643 }));
644 this.project_path_input
645 .read(cx)
646 .focus_handle(cx)
647 .focus(cx);
648 cx.notify();
649 })),
650 )
651 },
652 )
653 .when_some(create_project, |el, creating| {
654 el.child(self.render_create_new_project(creating, cx))
655 }),
656 ),
657 )
658 }
659
660 fn render_create_new_project(
661 &mut self,
662 creating: bool,
663 _: &mut ViewContext<Self>,
664 ) -> impl IntoElement {
665 ListItem::new("create-remote-project")
666 .disabled(true)
667 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
668 .child(self.project_path_input.clone())
669 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
670 el.child(
671 Icon::new(IconName::ArrowCircle)
672 .size(IconSize::Medium)
673 .with_animation(
674 "arrow-circle",
675 Animation::new(Duration::from_secs(2)).repeat(),
676 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
677 ),
678 )
679 }))
680 }
681
682 fn render_dev_server_project(
683 &mut self,
684 project: &DevServerProject,
685 cx: &mut ViewContext<Self>,
686 ) -> impl IntoElement {
687 let dev_server_project_id = project.id;
688 let project_id = project.project_id;
689 let is_online = project_id.is_some();
690 let project_path = project.path.clone();
691
692 ListItem::new(("remote-project", dev_server_project_id.0))
693 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
694 .child(
695 Label::new(project.path.clone())
696 )
697 .on_click(cx.listener(move |_, _, cx| {
698 if let Some(project_id) = project_id {
699 if let Some(app_state) = AppState::global(cx).upgrade() {
700 workspace::join_dev_server_project(project_id, app_state, None, cx)
701 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
702 }
703 } else {
704 cx.spawn(|_, mut cx| async move {
705 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();
706 }).detach();
707 }
708 }))
709 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
710 .on_click(cx.listener(move |this, _, cx| {
711 this.delete_dev_server_project(dev_server_project_id, &project_path, cx)
712 }))
713 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
714 }
715
716 fn render_create_dev_server(
717 &mut self,
718 state: CreateDevServer,
719 cx: &mut ViewContext<Self>,
720 ) -> impl IntoElement {
721 let CreateDevServer {
722 creating,
723 dev_server_id,
724 access_token,
725 manual_setup,
726 } = state.clone();
727
728 let status = dev_server_id
729 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
730 .unwrap_or_default();
731
732 let name = self.dev_server_name_input.update(cx, |input, cx| {
733 input.editor().update(cx, |editor, cx| {
734 if editor.text(cx).is_empty() {
735 if manual_setup {
736 editor.set_placeholder_text("example-server", cx)
737 } else {
738 editor.set_placeholder_text("ssh host", cx)
739 }
740 }
741 editor.text(cx)
742 })
743 });
744
745 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
746 .header(
747 ModalHeader::new()
748 .headline("Create Dev Server")
749 .show_back_button(true),
750 )
751 .section(
752 Section::new()
753 .header(if manual_setup { "Server Name".into()} else { "SSH arguments".into()})
754 .child(
755 div()
756 .max_w(rems(16.))
757 .child(self.dev_server_name_input.clone())
758 ),
759 )
760 .section(
761 Section::new_contained()
762 .header("Connection Method".into())
763 .child(
764 v_flex()
765 .w_full()
766 .gap_y(Spacing::Large.rems(cx))
767 .child(v_flex().child(RadioWithLabel::new(
768 "use-server-name-in-ssh",
769 Label::new("Connect via SSH (default)"),
770 !manual_setup,
771 cx.listener({
772 let state = state.clone();
773 move |this, _, cx| {
774 this.mode = Mode::CreateDevServer(CreateDevServer {
775 manual_setup: false,
776 ..state.clone()
777 });
778 cx.notify()
779 }
780 }),
781 ))
782 .child(RadioWithLabel::new(
783 "use-server-name-in-ssh",
784 Label::new("Manual Setup"),
785 manual_setup,
786 cx.listener({
787 let state = state.clone();
788 move |this, _, cx| {
789 this.mode = Mode::CreateDevServer(CreateDevServer {
790 manual_setup: true,
791 ..state.clone()
792 });
793 cx.notify()
794 }}),
795 )))
796 .when(dev_server_id.is_none(), |el| {
797 el.child(
798 if manual_setup {
799 Label::new(
800 "Click create to generate a token for this server. The next step will provide instructions for setting zed up on that machine."
801 )
802 } else {
803 Label::new(
804 "Enter the command you use to ssh into this server.\n\
805 For example: `ssh me@my.server` or `gh cs ssh -c example`."
806 )
807 }.size(LabelSize::Small).color(Color::Muted))
808 })
809 .when(dev_server_id.is_some() && access_token.is_none(),|el|{
810 el.child(
811 if manual_setup {
812 Label::new(
813 "Note: updating the dev server generate a new token"
814 )
815 } else {
816 Label::new(
817 "Enter the command you use to ssh into this server.\n\
818 For example: `ssh me@my.server` or `gh cs ssh -c example`."
819 )
820 }.size(LabelSize::Small).color(Color::Muted)
821 )
822 })
823 .when_some(access_token.clone(), {
824 |el, access_token| {
825 el.child(
826 self.render_dev_server_token_creating(access_token, name, manual_setup, status, creating, cx)
827 )
828 }}))
829 )
830 .footer(ModalFooter::new().end_slot(
831 if status == DevServerStatus::Online {
832 Button::new("create-dev-server", "Done")
833 .style(ButtonStyle::Filled)
834 .layer(ElevationIndex::ModalSurface)
835 .on_click(cx.listener(move |this, _, cx| {
836 cx.focus(&this.focus_handle);
837 this.mode = Mode::Default(None);
838 cx.notify();
839 }))
840 } else {
841 Button::new("create-dev-server", if manual_setup { "Create"} else { "Connect"})
842 .style(ButtonStyle::Filled)
843 .layer(ElevationIndex::ModalSurface)
844 .disabled(creating)
845 .on_click(cx.listener({
846 let access_token = access_token.clone();
847 move |this, _, cx| {
848 this.create_or_update_dev_server(manual_setup, dev_server_id, access_token.clone(), cx);
849 }}))
850 }
851 ))
852 }
853
854 fn render_dev_server_token_creating(
855 &self,
856 access_token: String,
857 dev_server_name: String,
858 manual_setup: bool,
859 status: DevServerStatus,
860 creating: bool,
861 cx: &mut ViewContext<Self>,
862 ) -> Div {
863 self.markdown.update(cx, |markdown, cx| {
864 if manual_setup {
865 markdown.reset(format!("Please log into '{}'. If you don't yet have zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen to start zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
866 } else {
867 markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using manual setup.".to_string(), cx);
868 }
869 });
870
871 v_flex()
872 .pl_2()
873 .pt_2()
874 .gap_2()
875 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
876 .map(|el| {
877 if status == DevServerStatus::Offline && !manual_setup && !creating {
878 el.child(
879 h_flex()
880 .gap_2()
881 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
882 .child(Label::new("Not connected")),
883 )
884 } else if status == DevServerStatus::Offline {
885 el.child(Self::render_loading_spinner("Waiting for connection…"))
886 } else {
887 el.child(Label::new("🎊 Connection established!"))
888 }
889 })
890 }
891
892 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
893 h_flex()
894 .gap_2()
895 .child(
896 Icon::new(IconName::ArrowCircle)
897 .size(IconSize::Medium)
898 .with_animation(
899 "arrow-circle",
900 Animation::new(Duration::from_secs(2)).repeat(),
901 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
902 ),
903 )
904 .child(Label::new(label))
905 }
906
907 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
908 let dev_servers = self.dev_server_store.read(cx).dev_servers();
909
910 let Mode::Default(create_dev_server_project) = &self.mode else {
911 unreachable!()
912 };
913
914 let mut is_creating = None;
915 let mut creating_dev_server = None;
916 if let Some(CreateDevServerProject {
917 creating,
918 dev_server_id,
919 ..
920 }) = create_dev_server_project
921 {
922 is_creating = Some(*creating);
923 creating_dev_server = Some(*dev_server_id);
924 };
925
926 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
927 .header(
928 ModalHeader::new()
929 .show_dismiss_button(true)
930 .child(Headline::new("Remote Projects").size(HeadlineSize::Small)),
931 )
932 .section(
933 Section::new().child(
934 div().mb_4().child(
935 List::new()
936 .empty_message("No dev servers registered.")
937 .header(Some(
938 ListHeader::new("Dev Servers").end_slot(
939 Button::new("register-dev-server-button", "New Server")
940 .icon(IconName::Plus)
941 .icon_position(IconPosition::Start)
942 .tooltip(|cx| {
943 Tooltip::text("Register a new dev server", cx)
944 })
945 .on_click(cx.listener(|this, _, cx| {
946 this.mode =
947 Mode::CreateDevServer(CreateDevServer::default());
948 this.dev_server_name_input.update(
949 cx,
950 |text_field, cx| {
951 text_field.editor().update(cx, |editor, cx| {
952 editor.set_text("", cx);
953 });
954 },
955 );
956 cx.notify();
957 })),
958 ),
959 ))
960 .children(dev_servers.iter().map(|dev_server| {
961 let creating = if creating_dev_server == Some(dev_server.id) {
962 is_creating
963 } else {
964 None
965 };
966 self.render_dev_server(dev_server, creating, cx)
967 .into_any_element()
968 })),
969 ),
970 ),
971 )
972 }
973}
974
975fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
976 element
977 .read(cx)
978 .editor()
979 .read(cx)
980 .text(cx)
981 .trim()
982 .to_string()
983}
984
985impl ModalView for DevServerProjects {}
986
987impl FocusableView for DevServerProjects {
988 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
989 self.focus_handle.clone()
990 }
991}
992
993impl EventEmitter<DismissEvent> for DevServerProjects {}
994
995impl Render for DevServerProjects {
996 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
997 div()
998 .track_focus(&self.focus_handle)
999 .elevation_3(cx)
1000 .key_context("DevServerModal")
1001 .on_action(cx.listener(Self::cancel))
1002 .on_action(cx.listener(Self::confirm))
1003 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1004 this.focus_handle(cx).focus(cx);
1005 }))
1006 .on_mouse_down_out(cx.listener(|this, _, cx| {
1007 if matches!(this.mode, Mode::Default(None)) {
1008 cx.emit(DismissEvent)
1009 } else {
1010 this.focus_handle(cx).focus(cx);
1011 cx.stop_propagation()
1012 }
1013 }))
1014 .w(rems(34.))
1015 .max_h(rems(40.))
1016 .child(match &self.mode {
1017 Mode::Default(_) => self.render_default(cx).into_any_element(),
1018 Mode::CreateDevServer(state) => self
1019 .render_create_dev_server(state.clone(), cx)
1020 .into_any_element(),
1021 })
1022 }
1023}