1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use anyhow::Context;
7use anyhow::Result;
8use client::Client;
9use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
10use editor::Editor;
11use gpui::AsyncWindowContext;
12use gpui::PathPromptOptions;
13use gpui::Subscription;
14use gpui::Task;
15use gpui::WeakView;
16use gpui::{
17 percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
18 FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
19};
20use markdown::Markdown;
21use markdown::MarkdownStyle;
22use project::terminals::wrap_for_ssh;
23use project::terminals::SshCommand;
24use rpc::proto::RegenerateDevServerTokenResponse;
25use rpc::{
26 proto::{CreateDevServerResponse, DevServerStatus},
27 ErrorCode, ErrorExt,
28};
29use settings::update_settings_file;
30use settings::Settings;
31use task::HideStrategy;
32use task::RevealStrategy;
33use task::SpawnInTerminal;
34use terminal_view::terminal_panel::TerminalPanel;
35use ui::ElevationIndex;
36use ui::Section;
37use ui::{
38 prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
39 RadioWithLabel, Tooltip,
40};
41use ui_input::{FieldLabelLayout, TextField};
42use util::paths::PathWithPosition;
43use util::ResultExt;
44use workspace::notifications::NotifyResultExt;
45use workspace::OpenOptions;
46use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
47
48use crate::open_dev_server_project;
49use crate::ssh_connections::connect_over_ssh;
50use crate::ssh_connections::open_ssh_project;
51use crate::ssh_connections::RemoteSettingsContent;
52use crate::ssh_connections::SshConnection;
53use crate::ssh_connections::SshConnectionModal;
54use crate::ssh_connections::SshProject;
55use crate::ssh_connections::SshPrompt;
56use crate::ssh_connections::SshSettings;
57use crate::OpenRemote;
58
59pub struct DevServerProjects {
60 mode: Mode,
61 focus_handle: FocusHandle,
62 scroll_handle: ScrollHandle,
63 dev_server_store: Model<dev_server_projects::Store>,
64 workspace: WeakView<Workspace>,
65 project_path_input: View<Editor>,
66 dev_server_name_input: View<TextField>,
67 markdown: View<Markdown>,
68 _dev_server_subscription: Subscription,
69}
70
71#[derive(Default)]
72struct CreateDevServer {
73 creating: Option<Task<Option<()>>>,
74 dev_server_id: Option<DevServerId>,
75 access_token: Option<String>,
76 ssh_prompt: Option<View<SshPrompt>>,
77 kind: NewServerKind,
78}
79
80struct CreateDevServerProject {
81 dev_server_id: DevServerId,
82 creating: bool,
83 _opening: Option<Subscription>,
84}
85
86enum Mode {
87 Default(Option<CreateDevServerProject>),
88 CreateDevServer(CreateDevServer),
89}
90
91#[derive(Default, PartialEq, Eq, Clone, Copy)]
92enum NewServerKind {
93 DirectSSH,
94 #[default]
95 LegacySSH,
96 Manual,
97}
98
99impl DevServerProjects {
100 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
101 workspace.register_action(|workspace, _: &OpenRemote, cx| {
102 let handle = cx.view().downgrade();
103 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
104 });
105 }
106
107 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
108 workspace.update(cx, |workspace, cx| {
109 let handle = cx.view().downgrade();
110 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
111 })
112 }
113
114 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
115 let project_path_input = cx.new_view(|cx| {
116 let mut editor = Editor::single_line(cx);
117 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
118 editor
119 });
120 let dev_server_name_input = cx.new_view(|cx| {
121 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
122 });
123
124 let focus_handle = cx.focus_handle();
125 let dev_server_store = dev_server_projects::Store::global(cx);
126
127 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
128 cx.notify();
129 });
130
131 let mut base_style = cx.text_style();
132 base_style.refine(&gpui::TextStyleRefinement {
133 color: Some(cx.theme().colors().editor_foreground),
134 ..Default::default()
135 });
136
137 let markdown_style = MarkdownStyle {
138 base_text_style: base_style,
139 code_block: gpui::StyleRefinement {
140 text: Some(gpui::TextStyleRefinement {
141 font_family: Some("Zed Plex Mono".into()),
142 ..Default::default()
143 }),
144 ..Default::default()
145 },
146 link: gpui::TextStyleRefinement {
147 color: Some(Color::Accent.color(cx)),
148 ..Default::default()
149 },
150 syntax: cx.theme().syntax().clone(),
151 selection_background_color: cx.theme().players().local().selection,
152 ..Default::default()
153 };
154 let markdown =
155 cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
156
157 Self {
158 mode: Mode::Default(None),
159 focus_handle,
160 scroll_handle: ScrollHandle::new(),
161 dev_server_store,
162 project_path_input,
163 dev_server_name_input,
164 markdown,
165 workspace,
166 _dev_server_subscription: subscription,
167 }
168 }
169
170 pub fn create_dev_server_project(
171 &mut self,
172 dev_server_id: DevServerId,
173 cx: &mut ViewContext<Self>,
174 ) {
175 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
176
177 if path == "" {
178 return;
179 }
180
181 if !path.starts_with('/') && !path.starts_with('~') {
182 path = format!("~/{}", path);
183 }
184
185 if self
186 .dev_server_store
187 .read(cx)
188 .projects_for_server(dev_server_id)
189 .iter()
190 .any(|p| p.paths.iter().any(|p| p == &path))
191 {
192 cx.spawn(|_, mut cx| async move {
193 cx.prompt(
194 gpui::PromptLevel::Critical,
195 "Failed to create project",
196 Some(&format!("{} is already open on this dev server.", path)),
197 &["Ok"],
198 )
199 .await
200 })
201 .detach_and_log_err(cx);
202 return;
203 }
204
205 let create = {
206 let path = path.clone();
207 self.dev_server_store.update(cx, |store, cx| {
208 store.create_dev_server_project(dev_server_id, path, cx)
209 })
210 };
211
212 cx.spawn(|this, mut cx| async move {
213 let result = create.await;
214 this.update(&mut cx, |this, cx| {
215 if let Ok(result) = &result {
216 if let Some(dev_server_project_id) =
217 result.dev_server_project.as_ref().map(|p| p.id)
218 {
219 let subscription =
220 cx.observe(&this.dev_server_store, move |this, store, cx| {
221 if let Some(project_id) = store
222 .read(cx)
223 .dev_server_project(DevServerProjectId(dev_server_project_id))
224 .and_then(|p| p.project_id)
225 {
226 this.project_path_input.update(cx, |editor, cx| {
227 editor.set_text("", cx);
228 });
229 this.mode = Mode::Default(None);
230 if let Some(app_state) = AppState::global(cx).upgrade() {
231 workspace::join_dev_server_project(
232 DevServerProjectId(dev_server_project_id),
233 project_id,
234 app_state,
235 None,
236 cx,
237 )
238 .detach_and_prompt_err(
239 "Could not join project",
240 cx,
241 |_, _| None,
242 )
243 }
244 }
245 });
246
247 this.mode = Mode::Default(Some(CreateDevServerProject {
248 dev_server_id,
249 creating: true,
250 _opening: Some(subscription),
251 }));
252 }
253 } else {
254 this.mode = Mode::Default(Some(CreateDevServerProject {
255 dev_server_id,
256 creating: false,
257 _opening: None,
258 }));
259 }
260 })
261 .log_err();
262 result
263 })
264 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
265 match e.error_code() {
266 ErrorCode::DevServerOffline => Some(
267 "The dev server is offline. Please log in and check it is connected."
268 .to_string(),
269 ),
270 ErrorCode::DevServerProjectPathDoesNotExist => {
271 Some(format!("The path `{}` does not exist on the server.", path))
272 }
273 _ => None,
274 }
275 });
276
277 self.mode = Mode::Default(Some(CreateDevServerProject {
278 dev_server_id,
279 creating: true,
280 _opening: None,
281 }));
282 }
283
284 fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
285 let host = get_text(&self.dev_server_name_input, cx);
286 if host.is_empty() {
287 return;
288 }
289
290 let mut host = host.trim_start_matches("ssh ");
291 let mut username: Option<String> = None;
292 let mut port: Option<u16> = None;
293
294 if let Some((u, rest)) = host.split_once('@') {
295 host = rest;
296 username = Some(u.to_string());
297 }
298 if let Some((rest, p)) = host.split_once(':') {
299 host = rest;
300 port = p.parse().ok()
301 }
302
303 if let Some((rest, p)) = host.split_once(" -p") {
304 host = rest;
305 port = p.trim().parse().ok()
306 }
307
308 let connection_options = remote::SshConnectionOptions {
309 host: host.to_string(),
310 username,
311 port,
312 password: None,
313 };
314 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
315 let connection = connect_over_ssh(connection_options.clone(), ssh_prompt.clone(), cx)
316 .prompt_err("Failed to connect", cx, |_, _| None);
317
318 let creating = cx.spawn(move |this, mut cx| async move {
319 match connection.await {
320 Some(_) => this
321 .update(&mut cx, |this, cx| {
322 this.add_ssh_server(connection_options, cx);
323 this.mode = Mode::Default(None);
324 cx.notify()
325 })
326 .log_err(),
327 None => this
328 .update(&mut cx, |this, cx| {
329 this.mode = Mode::CreateDevServer(CreateDevServer {
330 kind: NewServerKind::DirectSSH,
331 ..Default::default()
332 });
333 cx.notify()
334 })
335 .log_err(),
336 };
337 None
338 });
339 self.mode = Mode::CreateDevServer(CreateDevServer {
340 kind: NewServerKind::DirectSSH,
341 ssh_prompt: Some(ssh_prompt.clone()),
342 creating: Some(creating),
343 ..Default::default()
344 });
345 }
346
347 fn create_ssh_project(
348 &mut self,
349 ix: usize,
350 ssh_connection: SshConnection,
351 cx: &mut ViewContext<Self>,
352 ) {
353 let Some(workspace) = self.workspace.upgrade() else {
354 return;
355 };
356
357 let connection_options = ssh_connection.into();
358 workspace.update(cx, |_, cx| {
359 cx.defer(move |workspace, cx| {
360 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
361 let prompt = workspace
362 .active_modal::<SshConnectionModal>(cx)
363 .unwrap()
364 .read(cx)
365 .prompt
366 .clone();
367
368 let connect = connect_over_ssh(connection_options, prompt, cx).prompt_err(
369 "Failed to connect",
370 cx,
371 |_, _| None,
372 );
373 cx.spawn(|workspace, mut cx| async move {
374 let Some(session) = connect.await else {
375 workspace
376 .update(&mut cx, |workspace, cx| {
377 let weak = cx.view().downgrade();
378 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
379 })
380 .log_err();
381 return;
382 };
383 let Ok((app_state, project, paths)) =
384 workspace.update(&mut cx, |workspace, cx| {
385 let app_state = workspace.app_state().clone();
386 let project = project::Project::ssh(
387 session,
388 app_state.client.clone(),
389 app_state.node_runtime.clone(),
390 app_state.user_store.clone(),
391 app_state.languages.clone(),
392 app_state.fs.clone(),
393 cx,
394 );
395 let paths = workspace.prompt_for_open_path(
396 PathPromptOptions {
397 files: true,
398 directories: true,
399 multiple: true,
400 },
401 project::DirectoryLister::Project(project.clone()),
402 cx,
403 );
404 (app_state, project, paths)
405 })
406 else {
407 return;
408 };
409
410 let Ok(Some(paths)) = paths.await else {
411 workspace
412 .update(&mut cx, |workspace, cx| {
413 let weak = cx.view().downgrade();
414 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
415 })
416 .log_err();
417 return;
418 };
419
420 let Some(options) = cx
421 .update(|cx| (app_state.build_window_options)(None, cx))
422 .log_err()
423 else {
424 return;
425 };
426
427 cx.open_window(options, |cx| {
428 cx.activate_window();
429
430 let fs = app_state.fs.clone();
431 update_settings_file::<SshSettings>(fs, cx, {
432 let paths = paths
433 .iter()
434 .map(|path| path.to_string_lossy().to_string())
435 .collect();
436 move |setting, _| {
437 if let Some(server) = setting
438 .ssh_connections
439 .as_mut()
440 .and_then(|connections| connections.get_mut(ix))
441 {
442 server.projects.push(SshProject { paths })
443 }
444 }
445 });
446
447 let tasks = paths
448 .into_iter()
449 .map(|path| {
450 project.update(cx, |project, cx| {
451 project.find_or_create_worktree(&path, true, cx)
452 })
453 })
454 .collect::<Vec<_>>();
455 cx.spawn(|_| async move {
456 for task in tasks {
457 task.await?;
458 }
459 Ok(())
460 })
461 .detach_and_prompt_err(
462 "Failed to open path",
463 cx,
464 |_, _| None,
465 );
466
467 cx.new_view(|cx| {
468 Workspace::new(None, project.clone(), app_state.clone(), cx)
469 })
470 })
471 .log_err();
472 })
473 .detach()
474 })
475 })
476 }
477
478 fn create_or_update_dev_server(
479 &mut self,
480 kind: NewServerKind,
481 existing_id: Option<DevServerId>,
482 access_token: Option<String>,
483 cx: &mut ViewContext<Self>,
484 ) {
485 let name = get_text(&self.dev_server_name_input, cx);
486 if name.is_empty() {
487 return;
488 }
489
490 let manual_setup = match kind {
491 NewServerKind::DirectSSH => unreachable!(),
492 NewServerKind::LegacySSH => false,
493 NewServerKind::Manual => true,
494 };
495
496 let ssh_connection_string = if manual_setup {
497 None
498 } else if name.contains(' ') {
499 Some(name.clone())
500 } else {
501 Some(format!("ssh {}", name))
502 };
503
504 let dev_server = self.dev_server_store.update(cx, {
505 let access_token = access_token.clone();
506 |store, cx| {
507 let ssh_connection_string = ssh_connection_string.clone();
508 if let Some(dev_server_id) = existing_id {
509 let rename = store.rename_dev_server(
510 dev_server_id,
511 name.clone(),
512 ssh_connection_string,
513 cx,
514 );
515 let token = if let Some(access_token) = access_token {
516 Task::ready(Ok(RegenerateDevServerTokenResponse {
517 dev_server_id: dev_server_id.0,
518 access_token,
519 }))
520 } else {
521 store.regenerate_dev_server_token(dev_server_id, cx)
522 };
523 cx.spawn(|_, _| async move {
524 rename.await?;
525 let response = token.await?;
526 Ok(CreateDevServerResponse {
527 dev_server_id: dev_server_id.0,
528 name,
529 access_token: response.access_token,
530 })
531 })
532 } else {
533 store.create_dev_server(name, ssh_connection_string.clone(), cx)
534 }
535 }
536 });
537
538 let workspace = self.workspace.clone();
539 let store = dev_server_projects::Store::global(cx);
540
541 let task = cx
542 .spawn({
543 |this, mut cx| async move {
544 let result = dev_server.await;
545
546 match result {
547 Ok(dev_server) => {
548 if let Some(ssh_connection_string) = ssh_connection_string {
549 this.update(&mut cx, |this, cx| {
550 if let Mode::CreateDevServer(CreateDevServer {
551 access_token,
552 dev_server_id,
553 ..
554 }) = &mut this.mode
555 {
556 access_token.replace(dev_server.access_token.clone());
557 dev_server_id
558 .replace(DevServerId(dev_server.dev_server_id));
559 }
560 cx.notify();
561 })?;
562
563 spawn_ssh_task(
564 workspace
565 .upgrade()
566 .ok_or_else(|| anyhow!("workspace dropped"))?,
567 store,
568 DevServerId(dev_server.dev_server_id),
569 ssh_connection_string,
570 dev_server.access_token.clone(),
571 &mut cx,
572 )
573 .await
574 .log_err();
575 }
576
577 this.update(&mut cx, |this, cx| {
578 this.focus_handle.focus(cx);
579 this.mode = Mode::CreateDevServer(CreateDevServer {
580 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
581 access_token: Some(dev_server.access_token),
582 kind,
583 ..Default::default()
584 });
585 cx.notify();
586 })?;
587 Ok(())
588 }
589 Err(e) => {
590 this.update(&mut cx, |this, cx| {
591 this.mode = Mode::CreateDevServer(CreateDevServer {
592 dev_server_id: existing_id,
593 access_token: None,
594 kind,
595 ..Default::default()
596 });
597 cx.notify()
598 })
599 .log_err();
600
601 return Err(e);
602 }
603 }
604 }
605 })
606 .prompt_err("Failed to create server", cx, |_, _| None);
607
608 self.mode = Mode::CreateDevServer(CreateDevServer {
609 creating: Some(task),
610 dev_server_id: existing_id,
611 access_token,
612 kind,
613 ..Default::default()
614 });
615 cx.notify()
616 }
617
618 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
619 let store = self.dev_server_store.read(cx);
620 let prompt = if store.projects_for_server(id).is_empty()
621 && store
622 .dev_server(id)
623 .is_some_and(|server| server.status == DevServerStatus::Offline)
624 {
625 None
626 } else {
627 Some(cx.prompt(
628 gpui::PromptLevel::Warning,
629 "Are you sure?",
630 Some("This will delete the dev server and all of its remote projects."),
631 &["Delete", "Cancel"],
632 ))
633 };
634
635 cx.spawn(|this, mut cx| async move {
636 if let Some(prompt) = prompt {
637 if prompt.await? != 0 {
638 return Ok(());
639 }
640 }
641
642 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
643 this.dev_server_store.update(cx, |store, _| {
644 store
645 .projects_for_server(id)
646 .into_iter()
647 .map(|project| project.id)
648 .collect()
649 })
650 })?;
651
652 this.update(&mut cx, |this, cx| {
653 this.dev_server_store
654 .update(cx, |store, cx| store.delete_dev_server(id, cx))
655 })?
656 .await?;
657
658 for id in project_ids {
659 WORKSPACE_DB
660 .delete_workspace_by_dev_server_project_id(id)
661 .await
662 .log_err();
663 }
664 Ok(())
665 })
666 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
667 }
668
669 fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
670 let answer = cx.prompt(
671 gpui::PromptLevel::Warning,
672 "Delete this project?",
673 Some("This will delete the remote project. You can always re-add it later."),
674 &["Delete", "Cancel"],
675 );
676
677 cx.spawn(|this, mut cx| async move {
678 let answer = answer.await?;
679
680 if answer != 0 {
681 return Ok(());
682 }
683
684 this.update(&mut cx, |this, cx| {
685 this.dev_server_store
686 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
687 })?
688 .await?;
689
690 WORKSPACE_DB
691 .delete_workspace_by_dev_server_project_id(id)
692 .await
693 .log_err();
694
695 Ok(())
696 })
697 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
698 }
699
700 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
701 match &self.mode {
702 Mode::Default(None) => {}
703 Mode::Default(Some(create_project)) => {
704 self.create_dev_server_project(create_project.dev_server_id, cx);
705 }
706 Mode::CreateDevServer(state) => {
707 if let Some(prompt) = state.ssh_prompt.as_ref() {
708 prompt.update(cx, |prompt, cx| {
709 prompt.confirm(cx);
710 });
711 return;
712 }
713 if state.kind == NewServerKind::DirectSSH {
714 self.create_ssh_server(cx);
715 return;
716 }
717 if state.creating.is_none() || state.dev_server_id.is_some() {
718 self.create_or_update_dev_server(
719 state.kind,
720 state.dev_server_id,
721 state.access_token.clone(),
722 cx,
723 );
724 }
725 }
726 }
727 }
728
729 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
730 match &self.mode {
731 Mode::Default(None) => cx.emit(DismissEvent),
732 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
733 self.mode = Mode::CreateDevServer(CreateDevServer {
734 kind: NewServerKind::DirectSSH,
735 ..Default::default()
736 });
737 cx.notify();
738 return;
739 }
740 _ => {
741 self.mode = Mode::Default(None);
742 self.focus_handle(cx).focus(cx);
743 cx.notify();
744 }
745 }
746 }
747
748 fn render_dev_server(
749 &mut self,
750 dev_server: &DevServer,
751 create_project: Option<bool>,
752 cx: &mut ViewContext<Self>,
753 ) -> impl IntoElement {
754 let dev_server_id = dev_server.id;
755 let status = dev_server.status;
756 let dev_server_name = dev_server.name.clone();
757 let kind = if dev_server.ssh_connection_string.is_some() {
758 NewServerKind::LegacySSH
759 } else {
760 NewServerKind::Manual
761 };
762
763 v_flex()
764 .w_full()
765 .child(
766 h_flex().group("dev-server").justify_between().child(
767 h_flex()
768 .gap_2()
769 .child(
770 div()
771 .id(("status", dev_server.id.0))
772 .relative()
773 .child(Icon::new(IconName::Server).size(IconSize::Small))
774 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
775 Indicator::dot().color(match status {
776 DevServerStatus::Online => Color::Created,
777 DevServerStatus::Offline => Color::Hidden,
778 }),
779 ))
780 .tooltip(move |cx| {
781 Tooltip::text(
782 match status {
783 DevServerStatus::Online => "Online",
784 DevServerStatus::Offline => "Offline",
785 },
786 cx,
787 )
788 }),
789 )
790 .child(
791 div()
792 .max_w(rems(26.))
793 .overflow_hidden()
794 .whitespace_nowrap()
795 .child(Label::new(dev_server_name.clone())),
796 )
797 .child(
798 h_flex()
799 .visible_on_hover("dev-server")
800 .gap_1()
801 .child(if dev_server.ssh_connection_string.is_some() {
802 let dev_server = dev_server.clone();
803 IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
804 .on_click(cx.listener(move |this, _, cx| {
805 let Some(workspace) = this.workspace.upgrade() else {
806 return;
807 };
808
809 reconnect_to_dev_server(
810 workspace,
811 dev_server.clone(),
812 cx,
813 )
814 .detach_and_prompt_err(
815 "Failed to reconnect",
816 cx,
817 |_, _| None,
818 );
819 }))
820 .tooltip(|cx| Tooltip::text("Reconnect", cx))
821 } else {
822 IconButton::new("edit-dev-server", IconName::Pencil)
823 .on_click(cx.listener(move |this, _, cx| {
824 this.mode = Mode::CreateDevServer(CreateDevServer {
825 dev_server_id: Some(dev_server_id),
826 kind,
827 ..Default::default()
828 });
829 let dev_server_name = dev_server_name.clone();
830 this.dev_server_name_input.update(
831 cx,
832 move |input, cx| {
833 input.editor().update(cx, move |editor, cx| {
834 editor.set_text(dev_server_name, cx)
835 })
836 },
837 )
838 }))
839 .tooltip(|cx| Tooltip::text("Edit dev server", cx))
840 })
841 .child({
842 let dev_server_id = dev_server.id;
843 IconButton::new("remove-dev-server", IconName::Trash)
844 .on_click(cx.listener(move |this, _, cx| {
845 this.delete_dev_server(dev_server_id, cx)
846 }))
847 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
848 }),
849 ),
850 ),
851 )
852 .child(
853 v_flex()
854 .w_full()
855 .bg(cx.theme().colors().background)
856 .border_1()
857 .border_color(cx.theme().colors().border_variant)
858 .rounded_md()
859 .my_1()
860 .py_0p5()
861 .px_3()
862 .child(
863 List::new()
864 .empty_message("No projects.")
865 .children(
866 self.dev_server_store
867 .read(cx)
868 .projects_for_server(dev_server.id)
869 .iter()
870 .map(|p| self.render_dev_server_project(p, cx)),
871 )
872 .when(
873 create_project.is_none()
874 && dev_server.status == DevServerStatus::Online,
875 |el| {
876 el.child(
877 ListItem::new("new-remote_project")
878 .start_slot(Icon::new(IconName::Plus))
879 .child(Label::new("Open folder…"))
880 .on_click(cx.listener(move |this, _, cx| {
881 this.mode =
882 Mode::Default(Some(CreateDevServerProject {
883 dev_server_id,
884 creating: false,
885 _opening: None,
886 }));
887 this.project_path_input
888 .read(cx)
889 .focus_handle(cx)
890 .focus(cx);
891 cx.notify();
892 })),
893 )
894 },
895 )
896 .when_some(create_project, |el, creating| {
897 el.child(self.render_create_new_project(creating, cx))
898 }),
899 ),
900 )
901 }
902
903 fn render_ssh_connection(
904 &mut self,
905 ix: usize,
906 ssh_connection: SshConnection,
907 cx: &mut ViewContext<Self>,
908 ) -> impl IntoElement {
909 v_flex()
910 .w_full()
911 .child(
912 h_flex().group("ssh-server").justify_between().child(
913 h_flex()
914 .gap_2()
915 .child(
916 div()
917 .id(("status", ix))
918 .relative()
919 .child(Icon::new(IconName::Server).size(IconSize::Small)),
920 )
921 .child(
922 div()
923 .max_w(rems(26.))
924 .overflow_hidden()
925 .whitespace_nowrap()
926 .child(Label::new(ssh_connection.host.clone())),
927 )
928 .child(h_flex().visible_on_hover("ssh-server").gap_1().child({
929 IconButton::new("remove-dev-server", IconName::Trash)
930 .on_click(
931 cx.listener(move |this, _, cx| this.delete_ssh_server(ix, cx)),
932 )
933 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
934 })),
935 ),
936 )
937 .child(
938 v_flex()
939 .w_full()
940 .bg(cx.theme().colors().background)
941 .border_1()
942 .border_color(cx.theme().colors().border_variant)
943 .rounded_md()
944 .my_1()
945 .py_0p5()
946 .px_3()
947 .child(
948 List::new()
949 .empty_message("No projects.")
950 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
951 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
952 }))
953 .child(
954 ListItem::new("new-remote_project")
955 .start_slot(Icon::new(IconName::Plus))
956 .child(Label::new("Open folder…"))
957 .on_click(cx.listener(move |this, _, cx| {
958 this.create_ssh_project(ix, ssh_connection.clone(), cx);
959 })),
960 ),
961 ),
962 )
963 }
964
965 fn render_ssh_project(
966 &self,
967 server_ix: usize,
968 server: &SshConnection,
969 ix: usize,
970 project: &SshProject,
971 cx: &ViewContext<Self>,
972 ) -> impl IntoElement {
973 let project = project.clone();
974 let server = server.clone();
975 ListItem::new(("remote-project", ix))
976 .start_slot(Icon::new(IconName::FileTree))
977 .child(Label::new(project.paths.join(", ")))
978 .on_click(cx.listener(move |this, _, cx| {
979 let Some(app_state) = this
980 .workspace
981 .update(cx, |workspace, _| workspace.app_state().clone())
982 .log_err()
983 else {
984 return;
985 };
986 let project = project.clone();
987 let server = server.clone();
988 cx.spawn(|_, mut cx| async move {
989 let result = open_ssh_project(
990 server.into(),
991 project
992 .paths
993 .into_iter()
994 .map(|path| PathWithPosition::from_path(PathBuf::from(path)))
995 .collect(),
996 app_state,
997 OpenOptions::default(),
998 &mut cx,
999 )
1000 .await;
1001 if let Err(e) = result {
1002 log::error!("Failed to connect: {:?}", e);
1003 cx.prompt(
1004 gpui::PromptLevel::Critical,
1005 "Failed to connect",
1006 Some(&e.to_string()),
1007 &["Ok"],
1008 )
1009 .await
1010 .ok();
1011 }
1012 })
1013 .detach();
1014 }))
1015 .end_hover_slot::<AnyElement>(Some(
1016 IconButton::new("remove-remote-project", IconName::Trash)
1017 .on_click(
1018 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
1019 )
1020 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
1021 .into_any_element(),
1022 ))
1023 }
1024
1025 fn update_settings_file(
1026 &mut self,
1027 cx: &mut ViewContext<Self>,
1028 f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
1029 ) {
1030 let Some(fs) = self
1031 .workspace
1032 .update(cx, |workspace, _| workspace.app_state().fs.clone())
1033 .log_err()
1034 else {
1035 return;
1036 };
1037 update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
1038 }
1039
1040 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
1041 self.update_settings_file(cx, move |setting| {
1042 if let Some(connections) = setting.ssh_connections.as_mut() {
1043 connections.remove(server);
1044 }
1045 });
1046 }
1047
1048 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
1049 self.update_settings_file(cx, move |setting| {
1050 if let Some(server) = setting
1051 .ssh_connections
1052 .as_mut()
1053 .and_then(|connections| connections.get_mut(server))
1054 {
1055 server.projects.remove(project);
1056 }
1057 });
1058 }
1059
1060 fn add_ssh_server(
1061 &mut self,
1062 connection_options: remote::SshConnectionOptions,
1063 cx: &mut ViewContext<Self>,
1064 ) {
1065 self.update_settings_file(cx, move |setting| {
1066 setting
1067 .ssh_connections
1068 .get_or_insert(Default::default())
1069 .push(SshConnection {
1070 host: connection_options.host,
1071 username: connection_options.username,
1072 port: connection_options.port,
1073 projects: vec![],
1074 })
1075 });
1076 }
1077
1078 fn render_create_new_project(
1079 &mut self,
1080 creating: bool,
1081 _: &mut ViewContext<Self>,
1082 ) -> impl IntoElement {
1083 ListItem::new("create-remote-project")
1084 .disabled(true)
1085 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
1086 .child(self.project_path_input.clone())
1087 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
1088 el.child(
1089 Icon::new(IconName::ArrowCircle)
1090 .size(IconSize::Medium)
1091 .with_animation(
1092 "arrow-circle",
1093 Animation::new(Duration::from_secs(2)).repeat(),
1094 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1095 ),
1096 )
1097 }))
1098 }
1099
1100 fn render_dev_server_project(
1101 &mut self,
1102 project: &DevServerProject,
1103 cx: &mut ViewContext<Self>,
1104 ) -> impl IntoElement {
1105 let dev_server_project_id = project.id;
1106 let project_id = project.project_id;
1107 let is_online = project_id.is_some();
1108
1109 ListItem::new(("remote-project", dev_server_project_id.0))
1110 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
1111 .child(
1112 Label::new(project.paths.join(", "))
1113 )
1114 .on_click(cx.listener(move |_, _, cx| {
1115 if let Some(project_id) = project_id {
1116 if let Some(app_state) = AppState::global(cx).upgrade() {
1117 workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
1118 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
1119 }
1120 } else {
1121 cx.spawn(|_, mut cx| async move {
1122 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();
1123 }).detach();
1124 }
1125 }))
1126 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
1127 .on_click(cx.listener(move |this, _, cx| {
1128 this.delete_dev_server_project(dev_server_project_id, cx)
1129 }))
1130 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
1131 }
1132
1133 fn render_create_dev_server(
1134 &self,
1135 state: &CreateDevServer,
1136 cx: &mut ViewContext<Self>,
1137 ) -> impl IntoElement {
1138 let creating = state.creating.is_some();
1139 let dev_server_id = state.dev_server_id;
1140 let access_token = state.access_token.clone();
1141 let ssh_prompt = state.ssh_prompt.clone();
1142 let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh();
1143
1144 let mut kind = state.kind;
1145 if use_direct_ssh && kind == NewServerKind::LegacySSH {
1146 kind = NewServerKind::DirectSSH;
1147 }
1148
1149 let status = dev_server_id
1150 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
1151 .unwrap_or_default();
1152
1153 let name = self.dev_server_name_input.update(cx, |input, cx| {
1154 input.editor().update(cx, |editor, cx| {
1155 if editor.text(cx).is_empty() {
1156 match kind {
1157 NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
1158 NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
1159 NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
1160 }
1161 }
1162 editor.text(cx)
1163 })
1164 });
1165
1166 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.";
1167 const SSH_SETUP_MESSAGE: &str =
1168 "Enter the command you use to ssh into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
1169
1170 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
1171 .header(
1172 ModalHeader::new()
1173 .headline("Create Dev Server")
1174 .show_back_button(true),
1175 )
1176 .section(
1177 Section::new()
1178 .header(if kind == NewServerKind::Manual {
1179 "Server Name".into()
1180 } else {
1181 "SSH arguments".into()
1182 })
1183 .child(
1184 div()
1185 .max_w(rems(16.))
1186 .child(self.dev_server_name_input.clone()),
1187 ),
1188 )
1189 .section(
1190 Section::new_contained()
1191 .header("Connection Method".into())
1192 .child(
1193 v_flex()
1194 .w_full()
1195 .gap_y(Spacing::Large.rems(cx))
1196 .when(ssh_prompt.is_none(), |el| {
1197 el.child(
1198 v_flex()
1199 .when(use_direct_ssh, |el| {
1200 el.child(RadioWithLabel::new(
1201 "use-server-name-in-ssh",
1202 Label::new("Connect via SSH (default)"),
1203 NewServerKind::DirectSSH == kind,
1204 cx.listener({
1205 move |this, _, cx| {
1206 if let Mode::CreateDevServer(
1207 CreateDevServer { kind, .. },
1208 ) = &mut this.mode
1209 {
1210 *kind = NewServerKind::DirectSSH;
1211 }
1212 cx.notify()
1213 }
1214 }),
1215 ))
1216 })
1217 .when(!use_direct_ssh, |el| {
1218 el.child(RadioWithLabel::new(
1219 "use-server-name-in-ssh",
1220 Label::new("Configure over SSH (default)"),
1221 kind == NewServerKind::LegacySSH,
1222 cx.listener({
1223 move |this, _, cx| {
1224 if let Mode::CreateDevServer(
1225 CreateDevServer { kind, .. },
1226 ) = &mut this.mode
1227 {
1228 *kind = NewServerKind::LegacySSH;
1229 }
1230 cx.notify()
1231 }
1232 }),
1233 ))
1234 })
1235 .child(RadioWithLabel::new(
1236 "use-server-name-in-ssh",
1237 Label::new("Configure manually"),
1238 kind == NewServerKind::Manual,
1239 cx.listener({
1240 move |this, _, cx| {
1241 if let Mode::CreateDevServer(
1242 CreateDevServer { kind, .. },
1243 ) = &mut this.mode
1244 {
1245 *kind = NewServerKind::Manual;
1246 }
1247 cx.notify()
1248 }
1249 }),
1250 )),
1251 )
1252 })
1253 .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
1254 el.child(
1255 if kind == NewServerKind::Manual {
1256 Label::new(MANUAL_SETUP_MESSAGE)
1257 } else {
1258 Label::new(SSH_SETUP_MESSAGE)
1259 }
1260 .size(LabelSize::Small)
1261 .color(Color::Muted),
1262 )
1263 })
1264 .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
1265 .when(dev_server_id.is_some() && access_token.is_none(), |el| {
1266 el.child(
1267 if kind == NewServerKind::Manual {
1268 Label::new(
1269 "Note: updating the dev server generate a new token",
1270 )
1271 } else {
1272 Label::new(SSH_SETUP_MESSAGE)
1273 }
1274 .size(LabelSize::Small)
1275 .color(Color::Muted),
1276 )
1277 })
1278 .when_some(access_token.clone(), {
1279 |el, access_token| {
1280 el.child(self.render_dev_server_token_creating(
1281 access_token,
1282 name,
1283 kind,
1284 status,
1285 creating,
1286 cx,
1287 ))
1288 }
1289 }),
1290 ),
1291 )
1292 .footer(
1293 ModalFooter::new().end_slot(if status == DevServerStatus::Online {
1294 Button::new("create-dev-server", "Done")
1295 .style(ButtonStyle::Filled)
1296 .layer(ElevationIndex::ModalSurface)
1297 .on_click(cx.listener(move |this, _, cx| {
1298 cx.focus(&this.focus_handle);
1299 this.mode = Mode::Default(None);
1300 cx.notify();
1301 }))
1302 } else {
1303 Button::new(
1304 "create-dev-server",
1305 if kind == NewServerKind::Manual {
1306 if dev_server_id.is_some() {
1307 "Update"
1308 } else {
1309 "Create"
1310 }
1311 } else {
1312 if dev_server_id.is_some() {
1313 "Reconnect"
1314 } else {
1315 "Connect"
1316 }
1317 },
1318 )
1319 .style(ButtonStyle::Filled)
1320 .layer(ElevationIndex::ModalSurface)
1321 .disabled(creating && dev_server_id.is_none())
1322 .on_click(cx.listener({
1323 let access_token = access_token.clone();
1324 move |this, _, cx| {
1325 if kind == NewServerKind::DirectSSH {
1326 this.create_ssh_server(cx);
1327 return;
1328 }
1329 this.create_or_update_dev_server(
1330 kind,
1331 dev_server_id,
1332 access_token.clone(),
1333 cx,
1334 );
1335 }
1336 }))
1337 }),
1338 )
1339 }
1340
1341 fn render_dev_server_token_creating(
1342 &self,
1343 access_token: String,
1344 dev_server_name: String,
1345 kind: NewServerKind,
1346 status: DevServerStatus,
1347 creating: bool,
1348 cx: &mut ViewContext<Self>,
1349 ) -> Div {
1350 self.markdown.update(cx, |markdown, cx| {
1351 if kind == NewServerKind::Manual {
1352 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);
1353 } else {
1354 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);
1355 }
1356 });
1357
1358 v_flex()
1359 .pl_2()
1360 .pt_2()
1361 .gap_2()
1362 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1363 .map(|el| {
1364 if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1365 {
1366 el.child(
1367 h_flex()
1368 .gap_2()
1369 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1370 .child(Label::new("Not connected")),
1371 )
1372 } else if status == DevServerStatus::Offline {
1373 el.child(Self::render_loading_spinner("Waiting for connection…"))
1374 } else {
1375 el.child(Label::new("🎊 Connection established!"))
1376 }
1377 })
1378 }
1379
1380 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1381 h_flex()
1382 .gap_2()
1383 .child(
1384 Icon::new(IconName::ArrowCircle)
1385 .size(IconSize::Medium)
1386 .with_animation(
1387 "arrow-circle",
1388 Animation::new(Duration::from_secs(2)).repeat(),
1389 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1390 ),
1391 )
1392 .child(Label::new(label))
1393 }
1394
1395 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1396 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1397 let ssh_connections = SshSettings::get_global(cx)
1398 .ssh_connections()
1399 .collect::<Vec<_>>();
1400
1401 let Mode::Default(create_dev_server_project) = &self.mode else {
1402 unreachable!()
1403 };
1404
1405 let mut is_creating = None;
1406 let mut creating_dev_server = None;
1407 if let Some(CreateDevServerProject {
1408 creating,
1409 dev_server_id,
1410 ..
1411 }) = create_dev_server_project
1412 {
1413 is_creating = Some(*creating);
1414 creating_dev_server = Some(*dev_server_id);
1415 };
1416 let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
1417
1418 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1419 .header(
1420 ModalHeader::new()
1421 .show_dismiss_button(true)
1422 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1423 )
1424 .when(is_signed_out, |modal| {
1425 modal
1426 .section(Section::new().child(v_flex().mb_4().child(Label::new(
1427 "You are not currently signed in to Zed. Currently the remote development features are only available to signed in users. Please sign in to continue.",
1428 ))))
1429 .footer(
1430 ModalFooter::new().end_slot(
1431 Button::new("sign_in", "Sign in")
1432 .icon(IconName::Github)
1433 .icon_position(IconPosition::Start)
1434 .style(ButtonStyle::Filled)
1435 .full_width()
1436 .on_click(cx.listener(|_, _, cx| {
1437 let client = Client::global(cx).clone();
1438 cx.spawn(|_, mut cx| async move {
1439 client
1440 .authenticate_and_connect(true, &cx)
1441 .await
1442 .notify_async_err(&mut cx);
1443 })
1444 .detach();
1445 cx.emit(gpui::DismissEvent);
1446 })),
1447 ),
1448 )
1449 })
1450 .when(!is_signed_out, |modal| {
1451 modal.section(
1452 Section::new().child(
1453 div().mb_4().child(
1454 List::new()
1455 .empty_message("No dev servers registered.")
1456 .header(Some(
1457 ListHeader::new("Connections").end_slot(
1458 Button::new("register-dev-server-button", "Connect")
1459 .icon(IconName::Plus)
1460 .icon_position(IconPosition::Start)
1461 .tooltip(|cx| {
1462 Tooltip::text("Connect to a new server", cx)
1463 })
1464 .on_click(cx.listener(|this, _, cx| {
1465 this.mode = Mode::CreateDevServer(
1466 CreateDevServer {
1467 kind: if SshSettings::get_global(cx).use_direct_ssh() { NewServerKind::DirectSSH } else { NewServerKind::LegacySSH },
1468 ..Default::default()
1469 }
1470 );
1471 this.dev_server_name_input.update(
1472 cx,
1473 |text_field, cx| {
1474 text_field.editor().update(
1475 cx,
1476 |editor, cx| {
1477 editor.set_text("", cx);
1478 },
1479 );
1480 },
1481 );
1482 cx.notify();
1483 })),
1484 ),
1485 ))
1486 .children(ssh_connections.iter().cloned().enumerate().map(|(ix, connection)| {
1487 self.render_ssh_connection(ix, connection, cx)
1488 .into_any_element()
1489 }))
1490 .children(dev_servers.iter().map(|dev_server| {
1491 let creating = if creating_dev_server == Some(dev_server.id) {
1492 is_creating
1493 } else {
1494 None
1495 };
1496 self.render_dev_server(dev_server, creating, cx)
1497 .into_any_element()
1498 })),
1499 ),
1500 ),
1501 )
1502 })
1503 }
1504}
1505
1506fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1507 element
1508 .read(cx)
1509 .editor()
1510 .read(cx)
1511 .text(cx)
1512 .trim()
1513 .to_string()
1514}
1515
1516impl ModalView for DevServerProjects {}
1517
1518impl FocusableView for DevServerProjects {
1519 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1520 self.focus_handle.clone()
1521 }
1522}
1523
1524impl EventEmitter<DismissEvent> for DevServerProjects {}
1525
1526impl Render for DevServerProjects {
1527 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1528 div()
1529 .track_focus(&self.focus_handle)
1530 .elevation_3(cx)
1531 .key_context("DevServerModal")
1532 .on_action(cx.listener(Self::cancel))
1533 .on_action(cx.listener(Self::confirm))
1534 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1535 this.focus_handle(cx).focus(cx);
1536 }))
1537 .on_mouse_down_out(cx.listener(|this, _, cx| {
1538 if matches!(this.mode, Mode::Default(None)) {
1539 cx.emit(DismissEvent)
1540 }
1541 }))
1542 .w(rems(34.))
1543 .max_h(rems(40.))
1544 .child(match &self.mode {
1545 Mode::Default(_) => self.render_default(cx).into_any_element(),
1546 Mode::CreateDevServer(state) => {
1547 self.render_create_dev_server(state, cx).into_any_element()
1548 }
1549 })
1550 }
1551}
1552
1553pub fn reconnect_to_dev_server_project(
1554 workspace: View<Workspace>,
1555 dev_server: DevServer,
1556 dev_server_project_id: DevServerProjectId,
1557 replace_current_window: bool,
1558 cx: &mut WindowContext,
1559) -> Task<Result<()>> {
1560 let store = dev_server_projects::Store::global(cx);
1561 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1562 cx.spawn(|mut cx| async move {
1563 reconnect.await?;
1564
1565 cx.background_executor()
1566 .timer(Duration::from_millis(1000))
1567 .await;
1568
1569 if let Some(project_id) = store.update(&mut cx, |store, _| {
1570 store
1571 .dev_server_project(dev_server_project_id)
1572 .and_then(|p| p.project_id)
1573 })? {
1574 workspace
1575 .update(&mut cx, move |_, cx| {
1576 open_dev_server_project(
1577 replace_current_window,
1578 dev_server_project_id,
1579 project_id,
1580 cx,
1581 )
1582 })?
1583 .await?;
1584 }
1585
1586 Ok(())
1587 })
1588}
1589
1590pub fn reconnect_to_dev_server(
1591 workspace: View<Workspace>,
1592 dev_server: DevServer,
1593 cx: &mut WindowContext,
1594) -> Task<Result<()>> {
1595 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1596 return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1597 };
1598 let dev_server_store = dev_server_projects::Store::global(cx);
1599 let get_access_token = dev_server_store.update(cx, |store, cx| {
1600 store.regenerate_dev_server_token(dev_server.id, cx)
1601 });
1602
1603 cx.spawn(|mut cx| async move {
1604 let access_token = get_access_token.await?.access_token;
1605
1606 spawn_ssh_task(
1607 workspace,
1608 dev_server_store,
1609 dev_server.id,
1610 ssh_connection_string.to_string(),
1611 access_token,
1612 &mut cx,
1613 )
1614 .await
1615 })
1616}
1617
1618pub async fn spawn_ssh_task(
1619 workspace: View<Workspace>,
1620 dev_server_store: Model<dev_server_projects::Store>,
1621 dev_server_id: DevServerId,
1622 ssh_connection_string: String,
1623 access_token: String,
1624 cx: &mut AsyncWindowContext,
1625) -> Result<()> {
1626 let terminal_panel = workspace
1627 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1628 .ok()
1629 .flatten()
1630 .with_context(|| anyhow!("No terminal panel"))?;
1631
1632 let command = "sh".to_string();
1633 let args = vec![
1634 "-x".to_string(),
1635 "-c".to_string(),
1636 format!(
1637 r#"~/.local/bin/zed -v >/dev/stderr || (curl -f https://zed.dev/install.sh || wget -qO- https://zed.dev/install.sh) | sh && ZED_HEADLESS=1 ~/.local/bin/zed --dev-server-token {}"#,
1638 access_token
1639 ),
1640 ];
1641
1642 let ssh_connection_string = ssh_connection_string.to_string();
1643 let (command, args) = wrap_for_ssh(
1644 &SshCommand::DevServer(ssh_connection_string.clone()),
1645 Some((&command, &args)),
1646 None,
1647 HashMap::default(),
1648 None,
1649 );
1650
1651 let terminal = terminal_panel
1652 .update(cx, |terminal_panel, cx| {
1653 terminal_panel.spawn_in_new_terminal(
1654 SpawnInTerminal {
1655 id: task::TaskId("ssh-remote".into()),
1656 full_label: "Install zed over ssh".into(),
1657 label: "Install zed over ssh".into(),
1658 command,
1659 args,
1660 command_label: ssh_connection_string.clone(),
1661 cwd: None,
1662 use_new_terminal: true,
1663 allow_concurrent_runs: false,
1664 reveal: RevealStrategy::Always,
1665 hide: HideStrategy::Never,
1666 env: Default::default(),
1667 shell: Default::default(),
1668 },
1669 cx,
1670 )
1671 })?
1672 .await?;
1673
1674 terminal
1675 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1676 .await;
1677
1678 // 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.
1679 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1680 == DevServerStatus::Offline
1681 {
1682 cx.background_executor()
1683 .timer(Duration::from_millis(200))
1684 .await
1685 }
1686
1687 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1688 == DevServerStatus::Offline
1689 {
1690 return Err(anyhow!("couldn't reconnect"))?;
1691 }
1692
1693 Ok(())
1694}