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