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