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,
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 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 }
737 _ => {
738 self.mode = Mode::Default(None);
739 self.focus_handle(cx).focus(cx);
740 cx.notify();
741 }
742 }
743 }
744
745 fn render_dev_server(
746 &mut self,
747 dev_server: &DevServer,
748 create_project: Option<bool>,
749 cx: &mut ViewContext<Self>,
750 ) -> impl IntoElement {
751 let dev_server_id = dev_server.id;
752 let status = dev_server.status;
753 let dev_server_name = dev_server.name.clone();
754 let kind = if dev_server.ssh_connection_string.is_some() {
755 NewServerKind::LegacySSH
756 } else {
757 NewServerKind::Manual
758 };
759
760 v_flex()
761 .w_full()
762 .child(
763 h_flex().group("dev-server").justify_between().child(
764 h_flex()
765 .gap_2()
766 .child(
767 div()
768 .id(("status", dev_server.id.0))
769 .relative()
770 .child(Icon::new(IconName::Server).size(IconSize::Small))
771 .child(div().absolute().bottom_0().left(rems_from_px(8.0)).child(
772 Indicator::dot().color(match status {
773 DevServerStatus::Online => Color::Created,
774 DevServerStatus::Offline => Color::Hidden,
775 }),
776 ))
777 .tooltip(move |cx| {
778 Tooltip::text(
779 match status {
780 DevServerStatus::Online => "Online",
781 DevServerStatus::Offline => "Offline",
782 },
783 cx,
784 )
785 }),
786 )
787 .child(
788 div()
789 .max_w(rems(26.))
790 .overflow_hidden()
791 .whitespace_nowrap()
792 .child(Label::new(dev_server_name.clone())),
793 )
794 .child(
795 h_flex()
796 .visible_on_hover("dev-server")
797 .gap_1()
798 .child(if dev_server.ssh_connection_string.is_some() {
799 let dev_server = dev_server.clone();
800 IconButton::new("reconnect-dev-server", IconName::ArrowCircle)
801 .on_click(cx.listener(move |this, _, cx| {
802 let Some(workspace) = this.workspace.upgrade() else {
803 return;
804 };
805
806 reconnect_to_dev_server(
807 workspace,
808 dev_server.clone(),
809 cx,
810 )
811 .detach_and_prompt_err(
812 "Failed to reconnect",
813 cx,
814 |_, _| None,
815 );
816 }))
817 .tooltip(|cx| Tooltip::text("Reconnect", cx))
818 } else {
819 IconButton::new("edit-dev-server", IconName::Pencil)
820 .on_click(cx.listener(move |this, _, cx| {
821 this.mode = Mode::CreateDevServer(CreateDevServer {
822 dev_server_id: Some(dev_server_id),
823 kind,
824 ..Default::default()
825 });
826 let dev_server_name = dev_server_name.clone();
827 this.dev_server_name_input.update(
828 cx,
829 move |input, cx| {
830 input.editor().update(cx, move |editor, cx| {
831 editor.set_text(dev_server_name, cx)
832 })
833 },
834 )
835 }))
836 .tooltip(|cx| Tooltip::text("Edit dev server", cx))
837 })
838 .child({
839 let dev_server_id = dev_server.id;
840 IconButton::new("remove-dev-server", IconName::Trash)
841 .on_click(cx.listener(move |this, _, cx| {
842 this.delete_dev_server(dev_server_id, cx)
843 }))
844 .tooltip(|cx| Tooltip::text("Remove dev server", cx))
845 }),
846 ),
847 ),
848 )
849 .child(
850 v_flex()
851 .w_full()
852 .bg(cx.theme().colors().background)
853 .border_1()
854 .border_color(cx.theme().colors().border_variant)
855 .rounded_md()
856 .my_1()
857 .py_0p5()
858 .px_3()
859 .child(
860 List::new()
861 .empty_message("No projects.")
862 .children(
863 self.dev_server_store
864 .read(cx)
865 .projects_for_server(dev_server.id)
866 .iter()
867 .map(|p| self.render_dev_server_project(p, cx)),
868 )
869 .when(
870 create_project.is_none()
871 && dev_server.status == DevServerStatus::Online,
872 |el| {
873 el.child(
874 ListItem::new("new-remote_project")
875 .start_slot(Icon::new(IconName::Plus))
876 .child(Label::new("Open folder…"))
877 .on_click(cx.listener(move |this, _, cx| {
878 this.mode =
879 Mode::Default(Some(CreateDevServerProject {
880 dev_server_id,
881 creating: false,
882 _opening: None,
883 }));
884 this.project_path_input
885 .read(cx)
886 .focus_handle(cx)
887 .focus(cx);
888 cx.notify();
889 })),
890 )
891 },
892 )
893 .when_some(create_project, |el, creating| {
894 el.child(self.render_create_new_project(creating, cx))
895 }),
896 ),
897 )
898 }
899
900 fn render_ssh_connection(
901 &mut self,
902 ix: usize,
903 ssh_connection: SshConnection,
904 cx: &mut ViewContext<Self>,
905 ) -> impl IntoElement {
906 v_flex()
907 .w_full()
908 .child(
909 h_flex().group("ssh-server").justify_between().child(
910 h_flex()
911 .gap_2()
912 .child(
913 div()
914 .id(("status", ix))
915 .relative()
916 .child(Icon::new(IconName::Server).size(IconSize::Small)),
917 )
918 .child(
919 div()
920 .max_w(rems(26.))
921 .overflow_hidden()
922 .whitespace_nowrap()
923 .child(Label::new(ssh_connection.host.clone())),
924 )
925 .child(h_flex().visible_on_hover("ssh-server").gap_1().child({
926 IconButton::new("remove-dev-server", IconName::Trash)
927 .on_click(
928 cx.listener(move |this, _, cx| this.delete_ssh_server(ix, cx)),
929 )
930 .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
931 })),
932 ),
933 )
934 .child(
935 v_flex()
936 .w_full()
937 .bg(cx.theme().colors().background)
938 .border_1()
939 .border_color(cx.theme().colors().border_variant)
940 .rounded_md()
941 .my_1()
942 .py_0p5()
943 .px_3()
944 .child(
945 List::new()
946 .empty_message("No projects.")
947 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
948 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
949 }))
950 .child(
951 ListItem::new("new-remote_project")
952 .start_slot(Icon::new(IconName::Plus))
953 .child(Label::new("Open folder…"))
954 .on_click(cx.listener(move |this, _, cx| {
955 this.create_ssh_project(ix, ssh_connection.clone(), cx);
956 })),
957 ),
958 ),
959 )
960 }
961
962 fn render_ssh_project(
963 &self,
964 server_ix: usize,
965 server: &SshConnection,
966 ix: usize,
967 project: &SshProject,
968 cx: &ViewContext<Self>,
969 ) -> impl IntoElement {
970 let project = project.clone();
971 let server = server.clone();
972 ListItem::new(("remote-project", ix))
973 .start_slot(Icon::new(IconName::FileTree))
974 .child(Label::new(project.paths.join(", ")))
975 .on_click(cx.listener(move |this, _, cx| {
976 let Some(app_state) = this
977 .workspace
978 .update(cx, |workspace, _| workspace.app_state().clone())
979 .log_err()
980 else {
981 return;
982 };
983 let project = project.clone();
984 let server = server.clone();
985 cx.spawn(|_, mut cx| async move {
986 let result = open_ssh_project(
987 server.into(),
988 project.paths.into_iter().map(PathBuf::from).collect(),
989 app_state,
990 OpenOptions::default(),
991 &mut cx,
992 )
993 .await;
994 if let Err(e) = result {
995 log::error!("Failed to connect: {:?}", e);
996 cx.prompt(
997 gpui::PromptLevel::Critical,
998 "Failed to connect",
999 Some(&e.to_string()),
1000 &["Ok"],
1001 )
1002 .await
1003 .ok();
1004 }
1005 })
1006 .detach();
1007 }))
1008 .end_hover_slot::<AnyElement>(Some(
1009 IconButton::new("remove-remote-project", IconName::Trash)
1010 .on_click(
1011 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
1012 )
1013 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
1014 .into_any_element(),
1015 ))
1016 }
1017
1018 fn update_settings_file(
1019 &mut self,
1020 cx: &mut ViewContext<Self>,
1021 f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
1022 ) {
1023 let Some(fs) = self
1024 .workspace
1025 .update(cx, |workspace, _| workspace.app_state().fs.clone())
1026 .log_err()
1027 else {
1028 return;
1029 };
1030 update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
1031 }
1032
1033 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
1034 self.update_settings_file(cx, move |setting| {
1035 if let Some(connections) = setting.ssh_connections.as_mut() {
1036 connections.remove(server);
1037 }
1038 });
1039 }
1040
1041 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
1042 self.update_settings_file(cx, move |setting| {
1043 if let Some(server) = setting
1044 .ssh_connections
1045 .as_mut()
1046 .and_then(|connections| connections.get_mut(server))
1047 {
1048 server.projects.remove(project);
1049 }
1050 });
1051 }
1052
1053 fn add_ssh_server(
1054 &mut self,
1055 connection_options: remote::SshConnectionOptions,
1056 cx: &mut ViewContext<Self>,
1057 ) {
1058 self.update_settings_file(cx, move |setting| {
1059 setting
1060 .ssh_connections
1061 .get_or_insert(Default::default())
1062 .push(SshConnection {
1063 host: connection_options.host,
1064 username: connection_options.username,
1065 port: connection_options.port,
1066 projects: vec![],
1067 })
1068 });
1069 }
1070
1071 fn render_create_new_project(
1072 &mut self,
1073 creating: bool,
1074 _: &mut ViewContext<Self>,
1075 ) -> impl IntoElement {
1076 ListItem::new("create-remote-project")
1077 .disabled(true)
1078 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
1079 .child(self.project_path_input.clone())
1080 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
1081 el.child(
1082 Icon::new(IconName::ArrowCircle)
1083 .size(IconSize::Medium)
1084 .with_animation(
1085 "arrow-circle",
1086 Animation::new(Duration::from_secs(2)).repeat(),
1087 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1088 ),
1089 )
1090 }))
1091 }
1092
1093 fn render_dev_server_project(
1094 &mut self,
1095 project: &DevServerProject,
1096 cx: &mut ViewContext<Self>,
1097 ) -> impl IntoElement {
1098 let dev_server_project_id = project.id;
1099 let project_id = project.project_id;
1100 let is_online = project_id.is_some();
1101
1102 ListItem::new(("remote-project", dev_server_project_id.0))
1103 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
1104 .child(
1105 Label::new(project.paths.join(", "))
1106 )
1107 .on_click(cx.listener(move |_, _, cx| {
1108 if let Some(project_id) = project_id {
1109 if let Some(app_state) = AppState::global(cx).upgrade() {
1110 workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
1111 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
1112 }
1113 } else {
1114 cx.spawn(|_, mut cx| async move {
1115 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();
1116 }).detach();
1117 }
1118 }))
1119 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
1120 .on_click(cx.listener(move |this, _, cx| {
1121 this.delete_dev_server_project(dev_server_project_id, cx)
1122 }))
1123 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
1124 }
1125
1126 fn render_create_dev_server(
1127 &self,
1128 state: &CreateDevServer,
1129 cx: &mut ViewContext<Self>,
1130 ) -> impl IntoElement {
1131 let creating = state.creating.is_some();
1132 let dev_server_id = state.dev_server_id;
1133 let access_token = state.access_token.clone();
1134 let ssh_prompt = state.ssh_prompt.clone();
1135 let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh()
1136 || Client::global(cx).status().borrow().is_signed_out();
1137
1138 let mut kind = state.kind;
1139 if use_direct_ssh && kind == NewServerKind::LegacySSH {
1140 kind = NewServerKind::DirectSSH;
1141 }
1142
1143 let status = dev_server_id
1144 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
1145 .unwrap_or_default();
1146
1147 let name = self.dev_server_name_input.update(cx, |input, cx| {
1148 input.editor().update(cx, |editor, cx| {
1149 if editor.text(cx).is_empty() {
1150 match kind {
1151 NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
1152 NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
1153 NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
1154 }
1155 }
1156 editor.text(cx)
1157 })
1158 });
1159
1160 const MANUAL_SETUP_MESSAGE: &str =
1161 "Generate a token for this server and follow the steps to set Zed up on that machine.";
1162 const SSH_SETUP_MESSAGE: &str =
1163 "Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
1164
1165 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
1166 .header(
1167 ModalHeader::new()
1168 .headline("Create Dev Server")
1169 .show_back_button(true),
1170 )
1171 .section(
1172 Section::new()
1173 .header(if kind == NewServerKind::Manual {
1174 "Server Name".into()
1175 } else {
1176 "SSH arguments".into()
1177 })
1178 .child(
1179 div()
1180 .max_w(rems(16.))
1181 .child(self.dev_server_name_input.clone()),
1182 ),
1183 )
1184 .section(
1185 Section::new_contained()
1186 .header("Connection Method".into())
1187 .child(
1188 v_flex()
1189 .w_full()
1190 .px_2()
1191 .gap_y(Spacing::Large.rems(cx))
1192 .when(ssh_prompt.is_none(), |el| {
1193 el.child(
1194 v_flex()
1195 .when(use_direct_ssh, |el| {
1196 el.child(RadioWithLabel::new(
1197 "use-server-name-in-ssh",
1198 Label::new("Connect via SSH (default)"),
1199 NewServerKind::DirectSSH == kind,
1200 cx.listener({
1201 move |this, _, cx| {
1202 if let Mode::CreateDevServer(
1203 CreateDevServer { kind, .. },
1204 ) = &mut this.mode
1205 {
1206 *kind = NewServerKind::DirectSSH;
1207 }
1208 cx.notify()
1209 }
1210 }),
1211 ))
1212 })
1213 .when(!use_direct_ssh, |el| {
1214 el.child(RadioWithLabel::new(
1215 "use-server-name-in-ssh",
1216 Label::new("Configure over SSH (default)"),
1217 kind == NewServerKind::LegacySSH,
1218 cx.listener({
1219 move |this, _, cx| {
1220 if let Mode::CreateDevServer(
1221 CreateDevServer { kind, .. },
1222 ) = &mut this.mode
1223 {
1224 *kind = NewServerKind::LegacySSH;
1225 }
1226 cx.notify()
1227 }
1228 }),
1229 ))
1230 })
1231 .child(RadioWithLabel::new(
1232 "use-server-name-in-ssh",
1233 Label::new("Configure manually"),
1234 kind == NewServerKind::Manual,
1235 cx.listener({
1236 move |this, _, cx| {
1237 if let Mode::CreateDevServer(
1238 CreateDevServer { kind, .. },
1239 ) = &mut this.mode
1240 {
1241 *kind = NewServerKind::Manual;
1242 }
1243 cx.notify()
1244 }
1245 }),
1246 )),
1247 )
1248 })
1249 .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
1250 el.child(
1251 if kind == NewServerKind::Manual {
1252 Label::new(MANUAL_SETUP_MESSAGE)
1253 } else {
1254 Label::new(SSH_SETUP_MESSAGE)
1255 }
1256 .size(LabelSize::Small)
1257 .color(Color::Muted),
1258 )
1259 })
1260 .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
1261 .when(dev_server_id.is_some() && access_token.is_none(), |el| {
1262 el.child(
1263 if kind == NewServerKind::Manual {
1264 Label::new(
1265 "Note: updating the dev server generate a new token",
1266 )
1267 } else {
1268 Label::new(SSH_SETUP_MESSAGE)
1269 }
1270 .size(LabelSize::Small)
1271 .color(Color::Muted),
1272 )
1273 })
1274 .when_some(access_token.clone(), {
1275 |el, access_token| {
1276 el.child(self.render_dev_server_token_creating(
1277 access_token,
1278 name,
1279 kind,
1280 status,
1281 creating,
1282 cx,
1283 ))
1284 }
1285 }),
1286 ),
1287 )
1288 .footer(
1289 ModalFooter::new().end_slot(if status == DevServerStatus::Online {
1290 Button::new("create-dev-server", "Done")
1291 .style(ButtonStyle::Filled)
1292 .layer(ElevationIndex::ModalSurface)
1293 .on_click(cx.listener(move |this, _, cx| {
1294 cx.focus(&this.focus_handle);
1295 this.mode = Mode::Default(None);
1296 cx.notify();
1297 }))
1298 } else {
1299 Button::new(
1300 "create-dev-server",
1301 if kind == NewServerKind::Manual {
1302 if dev_server_id.is_some() {
1303 "Update"
1304 } else {
1305 "Create"
1306 }
1307 } else if dev_server_id.is_some() {
1308 "Reconnect"
1309 } else {
1310 "Connect"
1311 },
1312 )
1313 .style(ButtonStyle::Filled)
1314 .layer(ElevationIndex::ModalSurface)
1315 .disabled(creating && dev_server_id.is_none())
1316 .on_click(cx.listener({
1317 let access_token = access_token.clone();
1318 move |this, _, cx| {
1319 if kind == NewServerKind::DirectSSH {
1320 this.create_ssh_server(cx);
1321 return;
1322 }
1323 this.create_or_update_dev_server(
1324 kind,
1325 dev_server_id,
1326 access_token.clone(),
1327 cx,
1328 );
1329 }
1330 }))
1331 }),
1332 )
1333 }
1334
1335 fn render_dev_server_token_creating(
1336 &self,
1337 access_token: String,
1338 dev_server_name: String,
1339 kind: NewServerKind,
1340 status: DevServerStatus,
1341 creating: bool,
1342 cx: &mut ViewContext<Self>,
1343 ) -> Div {
1344 self.markdown.update(cx, |markdown, cx| {
1345 if kind == NewServerKind::Manual {
1346 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);
1347 } else {
1348 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);
1349 }
1350 });
1351
1352 v_flex()
1353 .pl_2()
1354 .pt_2()
1355 .gap_2()
1356 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1357 .map(|el| {
1358 if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1359 {
1360 el.child(
1361 h_flex()
1362 .gap_2()
1363 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1364 .child(Label::new("Not connected")),
1365 )
1366 } else if status == DevServerStatus::Offline {
1367 el.child(Self::render_loading_spinner("Waiting for connection…"))
1368 } else {
1369 el.child(Label::new("🎊 Connection established!"))
1370 }
1371 })
1372 }
1373
1374 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1375 h_flex()
1376 .gap_2()
1377 .child(
1378 Icon::new(IconName::ArrowCircle)
1379 .size(IconSize::Medium)
1380 .with_animation(
1381 "arrow-circle",
1382 Animation::new(Duration::from_secs(2)).repeat(),
1383 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1384 ),
1385 )
1386 .child(Label::new(label))
1387 }
1388
1389 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1390 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1391 let ssh_connections = SshSettings::get_global(cx)
1392 .ssh_connections()
1393 .collect::<Vec<_>>();
1394
1395 let Mode::Default(create_dev_server_project) = &self.mode else {
1396 unreachable!()
1397 };
1398
1399 let mut is_creating = None;
1400 let mut creating_dev_server = None;
1401 if let Some(CreateDevServerProject {
1402 creating,
1403 dev_server_id,
1404 ..
1405 }) = create_dev_server_project
1406 {
1407 is_creating = Some(*creating);
1408 creating_dev_server = Some(*dev_server_id);
1409 };
1410
1411 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1412 .header(
1413 ModalHeader::new()
1414 .show_dismiss_button(true)
1415 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1416 )
1417 .section(
1418 Section::new().child(
1419 div().child(
1420 List::new()
1421 .empty_message("No dev servers registered yet.")
1422 .header(Some(
1423 ListHeader::new("Connections").end_slot(
1424 Button::new("register-dev-server-button", "Connect New Server")
1425 .icon(IconName::Plus)
1426 .icon_position(IconPosition::Start)
1427 .icon_color(Color::Muted)
1428 .on_click(cx.listener(|this, _, cx| {
1429 this.mode = Mode::CreateDevServer(CreateDevServer {
1430 kind: if SshSettings::get_global(cx)
1431 .use_direct_ssh()
1432 {
1433 NewServerKind::DirectSSH
1434 } else {
1435 NewServerKind::LegacySSH
1436 },
1437 ..Default::default()
1438 });
1439 this.dev_server_name_input.update(
1440 cx,
1441 |text_field, cx| {
1442 text_field.editor().update(cx, |editor, cx| {
1443 editor.set_text("", cx);
1444 });
1445 },
1446 );
1447 cx.notify();
1448 })),
1449 ),
1450 ))
1451 .children(ssh_connections.iter().cloned().enumerate().map(
1452 |(ix, connection)| {
1453 self.render_ssh_connection(ix, connection, cx)
1454 .into_any_element()
1455 },
1456 ))
1457 .children(dev_servers.iter().map(|dev_server| {
1458 let creating = if creating_dev_server == Some(dev_server.id) {
1459 is_creating
1460 } else {
1461 None
1462 };
1463 self.render_dev_server(dev_server, creating, cx)
1464 .into_any_element()
1465 })),
1466 ),
1467 ),
1468 )
1469 }
1470}
1471
1472fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1473 element
1474 .read(cx)
1475 .editor()
1476 .read(cx)
1477 .text(cx)
1478 .trim()
1479 .to_string()
1480}
1481
1482impl ModalView for DevServerProjects {}
1483
1484impl FocusableView for DevServerProjects {
1485 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1486 self.focus_handle.clone()
1487 }
1488}
1489
1490impl EventEmitter<DismissEvent> for DevServerProjects {}
1491
1492impl Render for DevServerProjects {
1493 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1494 div()
1495 .track_focus(&self.focus_handle)
1496 .p_2()
1497 .elevation_3(cx)
1498 .key_context("DevServerModal")
1499 .on_action(cx.listener(Self::cancel))
1500 .on_action(cx.listener(Self::confirm))
1501 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1502 this.focus_handle(cx).focus(cx);
1503 }))
1504 .on_mouse_down_out(cx.listener(|this, _, cx| {
1505 if matches!(this.mode, Mode::Default(None)) {
1506 cx.emit(DismissEvent)
1507 }
1508 }))
1509 .w(rems(34.))
1510 .max_h(rems(40.))
1511 .child(match &self.mode {
1512 Mode::Default(_) => self.render_default(cx).into_any_element(),
1513 Mode::CreateDevServer(state) => {
1514 self.render_create_dev_server(state, cx).into_any_element()
1515 }
1516 })
1517 }
1518}
1519
1520pub fn reconnect_to_dev_server_project(
1521 workspace: View<Workspace>,
1522 dev_server: DevServer,
1523 dev_server_project_id: DevServerProjectId,
1524 replace_current_window: bool,
1525 cx: &mut WindowContext,
1526) -> Task<Result<()>> {
1527 let store = dev_server_projects::Store::global(cx);
1528 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1529 cx.spawn(|mut cx| async move {
1530 reconnect.await?;
1531
1532 cx.background_executor()
1533 .timer(Duration::from_millis(1000))
1534 .await;
1535
1536 if let Some(project_id) = store.update(&mut cx, |store, _| {
1537 store
1538 .dev_server_project(dev_server_project_id)
1539 .and_then(|p| p.project_id)
1540 })? {
1541 workspace
1542 .update(&mut cx, move |_, cx| {
1543 open_dev_server_project(
1544 replace_current_window,
1545 dev_server_project_id,
1546 project_id,
1547 cx,
1548 )
1549 })?
1550 .await?;
1551 }
1552
1553 Ok(())
1554 })
1555}
1556
1557pub fn reconnect_to_dev_server(
1558 workspace: View<Workspace>,
1559 dev_server: DevServer,
1560 cx: &mut WindowContext,
1561) -> Task<Result<()>> {
1562 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1563 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1564 };
1565 let dev_server_store = dev_server_projects::Store::global(cx);
1566 let get_access_token = dev_server_store.update(cx, |store, cx| {
1567 store.regenerate_dev_server_token(dev_server.id, cx)
1568 });
1569
1570 cx.spawn(|mut cx| async move {
1571 let access_token = get_access_token.await?.access_token;
1572
1573 spawn_ssh_task(
1574 workspace,
1575 dev_server_store,
1576 dev_server.id,
1577 ssh_connection_string.to_string(),
1578 access_token,
1579 &mut cx,
1580 )
1581 .await
1582 })
1583}
1584
1585pub async fn spawn_ssh_task(
1586 workspace: View<Workspace>,
1587 dev_server_store: Model<dev_server_projects::Store>,
1588 dev_server_id: DevServerId,
1589 ssh_connection_string: String,
1590 access_token: String,
1591 cx: &mut AsyncWindowContext,
1592) -> Result<()> {
1593 let terminal_panel = workspace
1594 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1595 .ok()
1596 .flatten()
1597 .with_context(|| anyhow!("No terminal panel"))?;
1598
1599 let command = "sh".to_string();
1600 let args = vec![
1601 "-x".to_string(),
1602 "-c".to_string(),
1603 format!(
1604 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 {}"#,
1605 access_token
1606 ),
1607 ];
1608
1609 let ssh_connection_string = ssh_connection_string.to_string();
1610 let (command, args) = wrap_for_ssh(
1611 &SshCommand::DevServer(ssh_connection_string.clone()),
1612 Some((&command, &args)),
1613 None,
1614 HashMap::default(),
1615 None,
1616 );
1617
1618 let terminal = terminal_panel
1619 .update(cx, |terminal_panel, cx| {
1620 terminal_panel.spawn_in_new_terminal(
1621 SpawnInTerminal {
1622 id: task::TaskId("ssh-remote".into()),
1623 full_label: "Install zed over ssh".into(),
1624 label: "Install zed over ssh".into(),
1625 command,
1626 args,
1627 command_label: ssh_connection_string.clone(),
1628 cwd: None,
1629 use_new_terminal: true,
1630 allow_concurrent_runs: false,
1631 reveal: RevealStrategy::Always,
1632 hide: HideStrategy::Never,
1633 env: Default::default(),
1634 shell: Default::default(),
1635 },
1636 cx,
1637 )
1638 })?
1639 .await?;
1640
1641 terminal
1642 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1643 .await;
1644
1645 // 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.
1646 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1647 == DevServerStatus::Offline
1648 {
1649 cx.background_executor()
1650 .timer(Duration::from_millis(200))
1651 .await
1652 }
1653
1654 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1655 == DevServerStatus::Offline
1656 {
1657 return Err(anyhow!("couldn't reconnect"))?;
1658 }
1659
1660 Ok(())
1661}