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