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