1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use anyhow::Context;
7use anyhow::Result;
8use client::Client;
9use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
10use editor::Editor;
11use gpui::AsyncWindowContext;
12use gpui::PathPromptOptions;
13use gpui::Subscription;
14use gpui::Task;
15use gpui::WeakView;
16use gpui::{
17 percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
18 FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
19};
20use markdown::Markdown;
21use markdown::MarkdownStyle;
22use project::terminals::wrap_for_ssh;
23use project::terminals::SshCommand;
24use rpc::proto::RegenerateDevServerTokenResponse;
25use rpc::{
26 proto::{CreateDevServerResponse, DevServerStatus},
27 ErrorCode, ErrorExt,
28};
29use settings::update_settings_file;
30use settings::Settings;
31use task::HideStrategy;
32use task::RevealStrategy;
33use task::SpawnInTerminal;
34use terminal_view::terminal_panel::TerminalPanel;
35use ui::ElevationIndex;
36use ui::Section;
37use ui::{
38 prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
39 RadioWithLabel, Tooltip,
40};
41use ui_input::{FieldLabelLayout, TextField};
42use util::paths::PathWithPosition;
43use util::ResultExt;
44use workspace::notifications::NotifyResultExt;
45use workspace::OpenOptions;
46use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
47
48use crate::open_dev_server_project;
49use crate::ssh_connections::connect_over_ssh;
50use crate::ssh_connections::open_ssh_project;
51use crate::ssh_connections::SshConnection;
52use crate::ssh_connections::SshConnectionModal;
53use crate::ssh_connections::SshProject;
54use crate::ssh_connections::SshPrompt;
55use crate::ssh_connections::SshSettings;
56use crate::OpenRemote;
57
58pub struct DevServerProjects {
59 mode: Mode,
60 focus_handle: FocusHandle,
61 scroll_handle: ScrollHandle,
62 dev_server_store: Model<dev_server_projects::Store>,
63 workspace: WeakView<Workspace>,
64 project_path_input: View<Editor>,
65 dev_server_name_input: View<TextField>,
66 markdown: View<Markdown>,
67 _dev_server_subscription: Subscription,
68}
69
70#[derive(Default)]
71struct CreateDevServer {
72 creating: Option<Task<Option<()>>>,
73 dev_server_id: Option<DevServerId>,
74 access_token: Option<String>,
75 ssh_prompt: Option<View<SshPrompt>>,
76 kind: NewServerKind,
77}
78
79struct CreateDevServerProject {
80 dev_server_id: DevServerId,
81 creating: bool,
82 _opening: Option<Subscription>,
83}
84
85enum Mode {
86 Default(Option<CreateDevServerProject>),
87 CreateDevServer(CreateDevServer),
88}
89
90#[derive(Default, PartialEq, Eq, Clone, Copy)]
91enum NewServerKind {
92 DirectSSH,
93 #[default]
94 LegacySSH,
95 Manual,
96}
97
98impl DevServerProjects {
99 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
100 workspace.register_action(|workspace, _: &OpenRemote, cx| {
101 let handle = cx.view().downgrade();
102 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
103 });
104 }
105
106 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
107 workspace.update(cx, |workspace, cx| {
108 let handle = cx.view().downgrade();
109 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
110 })
111 }
112
113 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
114 let project_path_input = cx.new_view(|cx| {
115 let mut editor = Editor::single_line(cx);
116 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
117 editor
118 });
119 let dev_server_name_input = cx.new_view(|cx| {
120 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
121 });
122
123 let focus_handle = cx.focus_handle();
124 let dev_server_store = dev_server_projects::Store::global(cx);
125
126 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
127 cx.notify();
128 });
129
130 let mut base_style = cx.text_style();
131 base_style.refine(&gpui::TextStyleRefinement {
132 color: Some(cx.theme().colors().editor_foreground),
133 ..Default::default()
134 });
135
136 let markdown_style = MarkdownStyle {
137 base_text_style: base_style,
138 code_block: gpui::StyleRefinement {
139 text: Some(gpui::TextStyleRefinement {
140 font_family: Some("Zed Plex Mono".into()),
141 ..Default::default()
142 }),
143 ..Default::default()
144 },
145 link: gpui::TextStyleRefinement {
146 color: Some(Color::Accent.color(cx)),
147 ..Default::default()
148 },
149 syntax: cx.theme().syntax().clone(),
150 selection_background_color: cx.theme().players().local().selection,
151 ..Default::default()
152 };
153 let markdown =
154 cx.new_view(|cx| Markdown::new("".to_string(), markdown_style, None, cx, None));
155
156 Self {
157 mode: Mode::Default(None),
158 focus_handle,
159 scroll_handle: ScrollHandle::new(),
160 dev_server_store,
161 project_path_input,
162 dev_server_name_input,
163 markdown,
164 workspace,
165 _dev_server_subscription: subscription,
166 }
167 }
168
169 pub fn create_dev_server_project(
170 &mut self,
171 dev_server_id: DevServerId,
172 cx: &mut ViewContext<Self>,
173 ) {
174 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
175
176 if path.is_empty() {
177 return;
178 }
179
180 if !path.starts_with('/') && !path.starts_with('~') {
181 path = format!("~/{}", path);
182 }
183
184 if self
185 .dev_server_store
186 .read(cx)
187 .projects_for_server(dev_server_id)
188 .iter()
189 .any(|p| p.paths.iter().any(|p| p == &path))
190 {
191 cx.spawn(|_, mut cx| async move {
192 cx.prompt(
193 gpui::PromptLevel::Critical,
194 "Failed to create project",
195 Some(&format!("{} is already open on this dev server.", path)),
196 &["Ok"],
197 )
198 .await
199 })
200 .detach_and_log_err(cx);
201 return;
202 }
203
204 let create = {
205 let path = path.clone();
206 self.dev_server_store.update(cx, |store, cx| {
207 store.create_dev_server_project(dev_server_id, path, cx)
208 })
209 };
210
211 cx.spawn(|this, mut cx| async move {
212 let result = create.await;
213 this.update(&mut cx, |this, cx| {
214 if let Ok(result) = &result {
215 if let Some(dev_server_project_id) =
216 result.dev_server_project.as_ref().map(|p| p.id)
217 {
218 let subscription =
219 cx.observe(&this.dev_server_store, move |this, store, cx| {
220 if let Some(project_id) = store
221 .read(cx)
222 .dev_server_project(DevServerProjectId(dev_server_project_id))
223 .and_then(|p| p.project_id)
224 {
225 this.project_path_input.update(cx, |editor, cx| {
226 editor.set_text("", cx);
227 });
228 this.mode = Mode::Default(None);
229 if let Some(app_state) = AppState::global(cx).upgrade() {
230 workspace::join_dev_server_project(
231 DevServerProjectId(dev_server_project_id),
232 project_id,
233 app_state,
234 None,
235 cx,
236 )
237 .detach_and_prompt_err(
238 "Could not join project",
239 cx,
240 |_, _| None,
241 )
242 }
243 }
244 });
245
246 this.mode = Mode::Default(Some(CreateDevServerProject {
247 dev_server_id,
248 creating: true,
249 _opening: Some(subscription),
250 }));
251 }
252 } else {
253 this.mode = Mode::Default(Some(CreateDevServerProject {
254 dev_server_id,
255 creating: false,
256 _opening: None,
257 }));
258 }
259 })
260 .log_err();
261 result
262 })
263 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
264 match e.error_code() {
265 ErrorCode::DevServerOffline => Some(
266 "The dev server is offline. Please log in and check it is connected."
267 .to_string(),
268 ),
269 ErrorCode::DevServerProjectPathDoesNotExist => {
270 Some(format!("The path `{}` does not exist on the server.", path))
271 }
272 _ => None,
273 }
274 });
275
276 self.mode = Mode::Default(Some(CreateDevServerProject {
277 dev_server_id,
278 creating: true,
279 _opening: None,
280 }));
281 }
282
283 fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
284 let host = get_text(&self.dev_server_name_input, cx);
285 if host.is_empty() {
286 return;
287 }
288
289 let mut host = host.trim_start_matches("ssh ");
290 let mut username: Option<String> = None;
291 let mut port: Option<u16> = None;
292
293 if let Some((u, rest)) = host.split_once('@') {
294 host = rest;
295 username = Some(u.to_string());
296 }
297 if let Some((rest, p)) = host.split_once(':') {
298 host = rest;
299 port = p.parse().ok()
300 }
301
302 if let Some((rest, p)) = host.split_once(" -p") {
303 host = rest;
304 port = p.trim().parse().ok()
305 }
306
307 let connection_options = remote::SshConnectionOptions {
308 host: host.to_string(),
309 username,
310 port,
311 password: None,
312 };
313 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
314 let connection = connect_over_ssh(connection_options.clone(), ssh_prompt.clone(), cx)
315 .prompt_err("Failed to connect", cx, |_, _| None);
316
317 let creating = cx.spawn(move |this, mut cx| async move {
318 match connection.await {
319 Some(_) => this
320 .update(&mut cx, |this, cx| {
321 this.add_ssh_server(connection_options, cx);
322 this.mode = Mode::Default(None);
323 cx.notify()
324 })
325 .log_err(),
326 None => this
327 .update(&mut cx, |this, cx| {
328 this.mode = Mode::CreateDevServer(CreateDevServer {
329 kind: NewServerKind::DirectSSH,
330 ..Default::default()
331 });
332 cx.notify()
333 })
334 .log_err(),
335 };
336 None
337 });
338 self.mode = Mode::CreateDevServer(CreateDevServer {
339 kind: NewServerKind::DirectSSH,
340 ssh_prompt: Some(ssh_prompt.clone()),
341 creating: Some(creating),
342 ..Default::default()
343 });
344 }
345
346 fn create_ssh_project(
347 &mut self,
348 ix: usize,
349 ssh_connection: SshConnection,
350 cx: &mut ViewContext<Self>,
351 ) {
352 let Some(workspace) = self.workspace.upgrade() else {
353 return;
354 };
355
356 let connection_options = ssh_connection.into();
357 workspace.update(cx, |_, cx| {
358 cx.defer(move |workspace, cx| {
359 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
360 let prompt = workspace
361 .active_modal::<SshConnectionModal>(cx)
362 .unwrap()
363 .read(cx)
364 .prompt
365 .clone();
366
367 let connect = connect_over_ssh(connection_options, prompt, cx).prompt_err(
368 "Failed to connect",
369 cx,
370 |_, _| None,
371 );
372 cx.spawn(|workspace, mut cx| async move {
373 let Some(session) = connect.await else {
374 workspace
375 .update(&mut cx, |workspace, cx| {
376 let weak = cx.view().downgrade();
377 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
378 })
379 .log_err();
380 return;
381 };
382 let Ok((app_state, project, paths)) =
383 workspace.update(&mut cx, |workspace, cx| {
384 let app_state = workspace.app_state().clone();
385 let project = project::Project::ssh(
386 session,
387 app_state.client.clone(),
388 app_state.node_runtime.clone(),
389 app_state.user_store.clone(),
390 app_state.languages.clone(),
391 app_state.fs.clone(),
392 cx,
393 );
394 let paths = workspace.prompt_for_open_path(
395 PathPromptOptions {
396 files: true,
397 directories: true,
398 multiple: true,
399 },
400 project::DirectoryLister::Project(project.clone()),
401 cx,
402 );
403 (app_state, project, paths)
404 })
405 else {
406 return;
407 };
408
409 let Ok(Some(paths)) = paths.await else {
410 workspace
411 .update(&mut cx, |workspace, cx| {
412 let weak = cx.view().downgrade();
413 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
414 })
415 .log_err();
416 return;
417 };
418
419 let Some(options) = cx
420 .update(|cx| (app_state.build_window_options)(None, cx))
421 .log_err()
422 else {
423 return;
424 };
425
426 cx.open_window(options, |cx| {
427 cx.activate_window();
428
429 let fs = app_state.fs.clone();
430 update_settings_file::<SshSettings>(fs, cx, {
431 let paths = paths
432 .iter()
433 .map(|path| path.to_string_lossy().to_string())
434 .collect();
435 move |setting, _| {
436 if let Some(server) = setting
437 .ssh_connections
438 .as_mut()
439 .and_then(|connections| connections.get_mut(ix))
440 {
441 server.projects.push(SshProject { paths })
442 }
443 }
444 });
445
446 let tasks = paths
447 .into_iter()
448 .map(|path| {
449 project.update(cx, |project, cx| {
450 project.find_or_create_worktree(&path, true, cx)
451 })
452 })
453 .collect::<Vec<_>>();
454 cx.spawn(|_| async move {
455 for task in tasks {
456 task.await?;
457 }
458 Ok(())
459 })
460 .detach_and_prompt_err(
461 "Failed to open path",
462 cx,
463 |_, _| None,
464 );
465
466 cx.new_view(|cx| {
467 Workspace::new(None, project.clone(), app_state.clone(), cx)
468 })
469 })
470 .log_err();
471 })
472 .detach()
473 })
474 })
475 }
476
477 fn create_or_update_dev_server(
478 &mut self,
479 kind: NewServerKind,
480 existing_id: Option<DevServerId>,
481 access_token: Option<String>,
482 cx: &mut ViewContext<Self>,
483 ) {
484 let name = get_text(&self.dev_server_name_input, cx);
485 if name.is_empty() {
486 return;
487 }
488
489 let manual_setup = match kind {
490 NewServerKind::DirectSSH => unreachable!(),
491 NewServerKind::LegacySSH => false,
492 NewServerKind::Manual => true,
493 };
494
495 let ssh_connection_string = if manual_setup {
496 None
497 } else if name.contains(' ') {
498 Some(name.clone())
499 } else {
500 Some(format!("ssh {}", name))
501 };
502
503 let dev_server = self.dev_server_store.update(cx, {
504 let access_token = access_token.clone();
505 |store, cx| {
506 let ssh_connection_string = ssh_connection_string.clone();
507 if let Some(dev_server_id) = existing_id {
508 let rename = store.rename_dev_server(
509 dev_server_id,
510 name.clone(),
511 ssh_connection_string,
512 cx,
513 );
514 let token = if let Some(access_token) = access_token {
515 Task::ready(Ok(RegenerateDevServerTokenResponse {
516 dev_server_id: dev_server_id.0,
517 access_token,
518 }))
519 } else {
520 store.regenerate_dev_server_token(dev_server_id, cx)
521 };
522 cx.spawn(|_, _| async move {
523 rename.await?;
524 let response = token.await?;
525 Ok(CreateDevServerResponse {
526 dev_server_id: dev_server_id.0,
527 name,
528 access_token: response.access_token,
529 })
530 })
531 } else {
532 store.create_dev_server(name, ssh_connection_string.clone(), cx)
533 }
534 }
535 });
536
537 let workspace = self.workspace.clone();
538 let store = dev_server_projects::Store::global(cx);
539
540 let task = cx
541 .spawn({
542 |this, mut cx| async move {
543 let result = dev_server.await;
544
545 match result {
546 Ok(dev_server) => {
547 if let Some(ssh_connection_string) = ssh_connection_string {
548 this.update(&mut cx, |this, cx| {
549 if let Mode::CreateDevServer(CreateDevServer {
550 access_token,
551 dev_server_id,
552 ..
553 }) = &mut this.mode
554 {
555 access_token.replace(dev_server.access_token.clone());
556 dev_server_id
557 .replace(DevServerId(dev_server.dev_server_id));
558 }
559 cx.notify();
560 })?;
561
562 spawn_ssh_task(
563 workspace
564 .upgrade()
565 .ok_or_else(|| anyhow!("workspace dropped"))?,
566 store,
567 DevServerId(dev_server.dev_server_id),
568 ssh_connection_string,
569 dev_server.access_token.clone(),
570 &mut cx,
571 )
572 .await
573 .log_err();
574 }
575
576 this.update(&mut cx, |this, cx| {
577 this.focus_handle.focus(cx);
578 this.mode = Mode::CreateDevServer(CreateDevServer {
579 dev_server_id: Some(DevServerId(dev_server.dev_server_id)),
580 access_token: Some(dev_server.access_token),
581 kind,
582 ..Default::default()
583 });
584 cx.notify();
585 })?;
586 Ok(())
587 }
588 Err(e) => {
589 this.update(&mut cx, |this, cx| {
590 this.mode = Mode::CreateDevServer(CreateDevServer {
591 dev_server_id: existing_id,
592 access_token: None,
593 kind,
594 ..Default::default()
595 });
596 cx.notify()
597 })
598 .log_err();
599
600 Err(e)
601 }
602 }
603 }
604 })
605 .prompt_err("Failed to create server", cx, |_, _| None);
606
607 self.mode = Mode::CreateDevServer(CreateDevServer {
608 creating: Some(task),
609 dev_server_id: existing_id,
610 access_token,
611 kind,
612 ..Default::default()
613 });
614 cx.notify()
615 }
616
617 fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext<Self>) {
618 let store = self.dev_server_store.read(cx);
619 let prompt = if store.projects_for_server(id).is_empty()
620 && store
621 .dev_server(id)
622 .is_some_and(|server| server.status == DevServerStatus::Offline)
623 {
624 None
625 } else {
626 Some(cx.prompt(
627 gpui::PromptLevel::Warning,
628 "Are you sure?",
629 Some("This will delete the dev server and all of its remote projects."),
630 &["Delete", "Cancel"],
631 ))
632 };
633
634 cx.spawn(|this, mut cx| async move {
635 if let Some(prompt) = prompt {
636 if prompt.await? != 0 {
637 return Ok(());
638 }
639 }
640
641 let project_ids: Vec<DevServerProjectId> = this.update(&mut cx, |this, cx| {
642 this.dev_server_store.update(cx, |store, _| {
643 store
644 .projects_for_server(id)
645 .into_iter()
646 .map(|project| project.id)
647 .collect()
648 })
649 })?;
650
651 this.update(&mut cx, |this, cx| {
652 this.dev_server_store
653 .update(cx, |store, cx| store.delete_dev_server(id, cx))
654 })?
655 .await?;
656
657 for id in project_ids {
658 WORKSPACE_DB
659 .delete_workspace_by_dev_server_project_id(id)
660 .await
661 .log_err();
662 }
663 Ok(())
664 })
665 .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None);
666 }
667
668 fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext<Self>) {
669 let answer = cx.prompt(
670 gpui::PromptLevel::Warning,
671 "Delete this project?",
672 Some("This will delete the remote project. You can always re-add it later."),
673 &["Delete", "Cancel"],
674 );
675
676 cx.spawn(|this, mut cx| async move {
677 let answer = answer.await?;
678
679 if answer != 0 {
680 return Ok(());
681 }
682
683 this.update(&mut cx, |this, cx| {
684 this.dev_server_store
685 .update(cx, |store, cx| store.delete_dev_server_project(id, cx))
686 })?
687 .await?;
688
689 WORKSPACE_DB
690 .delete_workspace_by_dev_server_project_id(id)
691 .await
692 .log_err();
693
694 Ok(())
695 })
696 .detach_and_prompt_err("Failed to delete dev server project", cx, |_, _| None);
697 }
698
699 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
700 match &self.mode {
701 Mode::Default(None) => {}
702 Mode::Default(Some(create_project)) => {
703 self.create_dev_server_project(create_project.dev_server_id, cx);
704 }
705 Mode::CreateDevServer(state) => {
706 if let Some(prompt) = state.ssh_prompt.as_ref() {
707 prompt.update(cx, |prompt, cx| {
708 prompt.confirm(cx);
709 });
710 return;
711 }
712 if state.kind == NewServerKind::DirectSSH {
713 self.create_ssh_server(cx);
714 return;
715 }
716 if state.creating.is_none() || state.dev_server_id.is_some() {
717 self.create_or_update_dev_server(
718 state.kind,
719 state.dev_server_id,
720 state.access_token.clone(),
721 cx,
722 );
723 }
724 }
725 }
726 }
727
728 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
729 match &self.mode {
730 Mode::Default(None) => cx.emit(DismissEvent),
731 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
732 self.mode = Mode::CreateDevServer(CreateDevServer {
733 kind: NewServerKind::DirectSSH,
734 ..Default::default()
735 });
736 cx.notify();
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| PathWithPosition::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 SshSettings) + 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 if dev_server_id.is_some() {
1310 "Reconnect"
1311 } else {
1312 "Connect"
1313 },
1314 )
1315 .style(ButtonStyle::Filled)
1316 .layer(ElevationIndex::ModalSurface)
1317 .disabled(creating && dev_server_id.is_none())
1318 .on_click(cx.listener({
1319 let access_token = access_token.clone();
1320 move |this, _, cx| {
1321 if kind == NewServerKind::DirectSSH {
1322 this.create_ssh_server(cx);
1323 return;
1324 }
1325 this.create_or_update_dev_server(
1326 kind,
1327 dev_server_id,
1328 access_token.clone(),
1329 cx,
1330 );
1331 }
1332 }))
1333 }),
1334 )
1335 }
1336
1337 fn render_dev_server_token_creating(
1338 &self,
1339 access_token: String,
1340 dev_server_name: String,
1341 kind: NewServerKind,
1342 status: DevServerStatus,
1343 creating: bool,
1344 cx: &mut ViewContext<Self>,
1345 ) -> Div {
1346 self.markdown.update(cx, |markdown, cx| {
1347 if kind == NewServerKind::Manual {
1348 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);
1349 } else {
1350 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);
1351 }
1352 });
1353
1354 v_flex()
1355 .pl_2()
1356 .pt_2()
1357 .gap_2()
1358 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1359 .map(|el| {
1360 if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1361 {
1362 el.child(
1363 h_flex()
1364 .gap_2()
1365 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1366 .child(Label::new("Not connected")),
1367 )
1368 } else if status == DevServerStatus::Offline {
1369 el.child(Self::render_loading_spinner("Waiting for connection…"))
1370 } else {
1371 el.child(Label::new("🎊 Connection established!"))
1372 }
1373 })
1374 }
1375
1376 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1377 h_flex()
1378 .gap_2()
1379 .child(
1380 Icon::new(IconName::ArrowCircle)
1381 .size(IconSize::Medium)
1382 .with_animation(
1383 "arrow-circle",
1384 Animation::new(Duration::from_secs(2)).repeat(),
1385 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1386 ),
1387 )
1388 .child(Label::new(label))
1389 }
1390
1391 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1392 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1393 let ssh_connections = SshSettings::get_global(cx)
1394 .ssh_connections()
1395 .collect::<Vec<_>>();
1396
1397 let Mode::Default(create_dev_server_project) = &self.mode else {
1398 unreachable!()
1399 };
1400
1401 let mut is_creating = None;
1402 let mut creating_dev_server = None;
1403 if let Some(CreateDevServerProject {
1404 creating,
1405 dev_server_id,
1406 ..
1407 }) = create_dev_server_project
1408 {
1409 is_creating = Some(*creating);
1410 creating_dev_server = Some(*dev_server_id);
1411 };
1412 let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
1413
1414 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1415 .header(
1416 ModalHeader::new()
1417 .show_dismiss_button(true)
1418 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1419 )
1420 .when(is_signed_out, |modal| {
1421 modal
1422 .section(Section::new().child(v_flex().mb_4().child(Label::new(
1423 "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.",
1424 ))))
1425 .footer(
1426 ModalFooter::new().end_slot(
1427 Button::new("sign_in", "Sign in")
1428 .icon(IconName::Github)
1429 .icon_position(IconPosition::Start)
1430 .style(ButtonStyle::Filled)
1431 .full_width()
1432 .on_click(cx.listener(|_, _, cx| {
1433 let client = Client::global(cx).clone();
1434 cx.spawn(|_, mut cx| async move {
1435 client
1436 .authenticate_and_connect(true, &cx)
1437 .await
1438 .notify_async_err(&mut cx);
1439 })
1440 .detach();
1441 cx.emit(gpui::DismissEvent);
1442 })),
1443 ),
1444 )
1445 })
1446 .when(!is_signed_out, |modal| {
1447 modal.section(
1448 Section::new().child(
1449 div().mb_4().child(
1450 List::new()
1451 .empty_message("No dev servers registered.")
1452 .header(Some(
1453 ListHeader::new("Connections").end_slot(
1454 Button::new("register-dev-server-button", "Connect")
1455 .icon(IconName::Plus)
1456 .icon_position(IconPosition::Start)
1457 .tooltip(|cx| {
1458 Tooltip::text("Connect to a new server", cx)
1459 })
1460 .on_click(cx.listener(|this, _, cx| {
1461 this.mode = Mode::CreateDevServer(
1462 CreateDevServer {
1463 kind: if SshSettings::get_global(cx).use_direct_ssh() { NewServerKind::DirectSSH } else { NewServerKind::LegacySSH },
1464 ..Default::default()
1465 }
1466 );
1467 this.dev_server_name_input.update(
1468 cx,
1469 |text_field, cx| {
1470 text_field.editor().update(
1471 cx,
1472 |editor, cx| {
1473 editor.set_text("", cx);
1474 },
1475 );
1476 },
1477 );
1478 cx.notify();
1479 })),
1480 ),
1481 ))
1482 .children(ssh_connections.iter().cloned().enumerate().map(|(ix, connection)| {
1483 self.render_ssh_connection(ix, connection, cx)
1484 .into_any_element()
1485 }))
1486 .children(dev_servers.iter().map(|dev_server| {
1487 let creating = if creating_dev_server == Some(dev_server.id) {
1488 is_creating
1489 } else {
1490 None
1491 };
1492 self.render_dev_server(dev_server, creating, cx)
1493 .into_any_element()
1494 })),
1495 ),
1496 ),
1497 )
1498 })
1499 }
1500}
1501
1502fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1503 element
1504 .read(cx)
1505 .editor()
1506 .read(cx)
1507 .text(cx)
1508 .trim()
1509 .to_string()
1510}
1511
1512impl ModalView for DevServerProjects {}
1513
1514impl FocusableView for DevServerProjects {
1515 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1516 self.focus_handle.clone()
1517 }
1518}
1519
1520impl EventEmitter<DismissEvent> for DevServerProjects {}
1521
1522impl Render for DevServerProjects {
1523 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1524 div()
1525 .track_focus(&self.focus_handle)
1526 .elevation_3(cx)
1527 .key_context("DevServerModal")
1528 .on_action(cx.listener(Self::cancel))
1529 .on_action(cx.listener(Self::confirm))
1530 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1531 this.focus_handle(cx).focus(cx);
1532 }))
1533 .on_mouse_down_out(cx.listener(|this, _, cx| {
1534 if matches!(this.mode, Mode::Default(None)) {
1535 cx.emit(DismissEvent)
1536 }
1537 }))
1538 .w(rems(34.))
1539 .max_h(rems(40.))
1540 .child(match &self.mode {
1541 Mode::Default(_) => self.render_default(cx).into_any_element(),
1542 Mode::CreateDevServer(state) => {
1543 self.render_create_dev_server(state, cx).into_any_element()
1544 }
1545 })
1546 }
1547}
1548
1549pub fn reconnect_to_dev_server_project(
1550 workspace: View<Workspace>,
1551 dev_server: DevServer,
1552 dev_server_project_id: DevServerProjectId,
1553 replace_current_window: bool,
1554 cx: &mut WindowContext,
1555) -> Task<Result<()>> {
1556 let store = dev_server_projects::Store::global(cx);
1557 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1558 cx.spawn(|mut cx| async move {
1559 reconnect.await?;
1560
1561 cx.background_executor()
1562 .timer(Duration::from_millis(1000))
1563 .await;
1564
1565 if let Some(project_id) = store.update(&mut cx, |store, _| {
1566 store
1567 .dev_server_project(dev_server_project_id)
1568 .and_then(|p| p.project_id)
1569 })? {
1570 workspace
1571 .update(&mut cx, move |_, cx| {
1572 open_dev_server_project(
1573 replace_current_window,
1574 dev_server_project_id,
1575 project_id,
1576 cx,
1577 )
1578 })?
1579 .await?;
1580 }
1581
1582 Ok(())
1583 })
1584}
1585
1586pub fn reconnect_to_dev_server(
1587 workspace: View<Workspace>,
1588 dev_server: DevServer,
1589 cx: &mut WindowContext,
1590) -> Task<Result<()>> {
1591 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1592 return Task::ready(Err(anyhow!("can't reconnect, no ssh_connection_string")));
1593 };
1594 let dev_server_store = dev_server_projects::Store::global(cx);
1595 let get_access_token = dev_server_store.update(cx, |store, cx| {
1596 store.regenerate_dev_server_token(dev_server.id, cx)
1597 });
1598
1599 cx.spawn(|mut cx| async move {
1600 let access_token = get_access_token.await?.access_token;
1601
1602 spawn_ssh_task(
1603 workspace,
1604 dev_server_store,
1605 dev_server.id,
1606 ssh_connection_string.to_string(),
1607 access_token,
1608 &mut cx,
1609 )
1610 .await
1611 })
1612}
1613
1614pub async fn spawn_ssh_task(
1615 workspace: View<Workspace>,
1616 dev_server_store: Model<dev_server_projects::Store>,
1617 dev_server_id: DevServerId,
1618 ssh_connection_string: String,
1619 access_token: String,
1620 cx: &mut AsyncWindowContext,
1621) -> Result<()> {
1622 let terminal_panel = workspace
1623 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1624 .ok()
1625 .flatten()
1626 .with_context(|| anyhow!("No terminal panel"))?;
1627
1628 let command = "sh".to_string();
1629 let args = vec![
1630 "-x".to_string(),
1631 "-c".to_string(),
1632 format!(
1633 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 {}"#,
1634 access_token
1635 ),
1636 ];
1637
1638 let ssh_connection_string = ssh_connection_string.to_string();
1639 let (command, args) = wrap_for_ssh(
1640 &SshCommand::DevServer(ssh_connection_string.clone()),
1641 Some((&command, &args)),
1642 None,
1643 HashMap::default(),
1644 None,
1645 );
1646
1647 let terminal = terminal_panel
1648 .update(cx, |terminal_panel, cx| {
1649 terminal_panel.spawn_in_new_terminal(
1650 SpawnInTerminal {
1651 id: task::TaskId("ssh-remote".into()),
1652 full_label: "Install zed over ssh".into(),
1653 label: "Install zed over ssh".into(),
1654 command,
1655 args,
1656 command_label: ssh_connection_string.clone(),
1657 cwd: None,
1658 use_new_terminal: true,
1659 allow_concurrent_runs: false,
1660 reveal: RevealStrategy::Always,
1661 hide: HideStrategy::Never,
1662 env: Default::default(),
1663 shell: Default::default(),
1664 },
1665 cx,
1666 )
1667 })?
1668 .await?;
1669
1670 terminal
1671 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1672 .await;
1673
1674 // 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.
1675 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1676 == DevServerStatus::Offline
1677 {
1678 cx.background_executor()
1679 .timer(Duration::from_millis(200))
1680 .await
1681 }
1682
1683 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1684 == DevServerStatus::Offline
1685 {
1686 return Err(anyhow!("couldn't reconnect"))?;
1687 }
1688
1689 Ok(())
1690}