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