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 =
1166 "Generate a token for this server and follow the steps to set Zed up on that machine.";
1167 const SSH_SETUP_MESSAGE: &str =
1168 "Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
1169
1170 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
1171 .header(
1172 ModalHeader::new()
1173 .headline("Create Dev Server")
1174 .show_back_button(true),
1175 )
1176 .section(
1177 Section::new()
1178 .header(if kind == NewServerKind::Manual {
1179 "Server Name".into()
1180 } else {
1181 "SSH arguments".into()
1182 })
1183 .child(
1184 div()
1185 .max_w(rems(16.))
1186 .child(self.dev_server_name_input.clone()),
1187 ),
1188 )
1189 .section(
1190 Section::new_contained()
1191 .header("Connection Method".into())
1192 .child(
1193 v_flex()
1194 .w_full()
1195 .px_2()
1196 .gap_y(Spacing::Large.rems(cx))
1197 .when(ssh_prompt.is_none(), |el| {
1198 el.child(
1199 v_flex()
1200 .when(use_direct_ssh, |el| {
1201 el.child(RadioWithLabel::new(
1202 "use-server-name-in-ssh",
1203 Label::new("Connect via SSH (default)"),
1204 NewServerKind::DirectSSH == kind,
1205 cx.listener({
1206 move |this, _, cx| {
1207 if let Mode::CreateDevServer(
1208 CreateDevServer { kind, .. },
1209 ) = &mut this.mode
1210 {
1211 *kind = NewServerKind::DirectSSH;
1212 }
1213 cx.notify()
1214 }
1215 }),
1216 ))
1217 })
1218 .when(!use_direct_ssh, |el| {
1219 el.child(RadioWithLabel::new(
1220 "use-server-name-in-ssh",
1221 Label::new("Configure over SSH (default)"),
1222 kind == NewServerKind::LegacySSH,
1223 cx.listener({
1224 move |this, _, cx| {
1225 if let Mode::CreateDevServer(
1226 CreateDevServer { kind, .. },
1227 ) = &mut this.mode
1228 {
1229 *kind = NewServerKind::LegacySSH;
1230 }
1231 cx.notify()
1232 }
1233 }),
1234 ))
1235 })
1236 .child(RadioWithLabel::new(
1237 "use-server-name-in-ssh",
1238 Label::new("Configure manually"),
1239 kind == NewServerKind::Manual,
1240 cx.listener({
1241 move |this, _, cx| {
1242 if let Mode::CreateDevServer(
1243 CreateDevServer { kind, .. },
1244 ) = &mut this.mode
1245 {
1246 *kind = NewServerKind::Manual;
1247 }
1248 cx.notify()
1249 }
1250 }),
1251 )),
1252 )
1253 })
1254 .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
1255 el.child(
1256 if kind == NewServerKind::Manual {
1257 Label::new(MANUAL_SETUP_MESSAGE)
1258 } else {
1259 Label::new(SSH_SETUP_MESSAGE)
1260 }
1261 .size(LabelSize::Small)
1262 .color(Color::Muted),
1263 )
1264 })
1265 .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
1266 .when(dev_server_id.is_some() && access_token.is_none(), |el| {
1267 el.child(
1268 if kind == NewServerKind::Manual {
1269 Label::new(
1270 "Note: updating the dev server generate a new token",
1271 )
1272 } else {
1273 Label::new(SSH_SETUP_MESSAGE)
1274 }
1275 .size(LabelSize::Small)
1276 .color(Color::Muted),
1277 )
1278 })
1279 .when_some(access_token.clone(), {
1280 |el, access_token| {
1281 el.child(self.render_dev_server_token_creating(
1282 access_token,
1283 name,
1284 kind,
1285 status,
1286 creating,
1287 cx,
1288 ))
1289 }
1290 }),
1291 ),
1292 )
1293 .footer(
1294 ModalFooter::new().end_slot(if status == DevServerStatus::Online {
1295 Button::new("create-dev-server", "Done")
1296 .style(ButtonStyle::Filled)
1297 .layer(ElevationIndex::ModalSurface)
1298 .on_click(cx.listener(move |this, _, cx| {
1299 cx.focus(&this.focus_handle);
1300 this.mode = Mode::Default(None);
1301 cx.notify();
1302 }))
1303 } else {
1304 Button::new(
1305 "create-dev-server",
1306 if kind == NewServerKind::Manual {
1307 if dev_server_id.is_some() {
1308 "Update"
1309 } else {
1310 "Create"
1311 }
1312 } else if dev_server_id.is_some() {
1313 "Reconnect"
1314 } else {
1315 "Connect"
1316 },
1317 )
1318 .style(ButtonStyle::Filled)
1319 .layer(ElevationIndex::ModalSurface)
1320 .disabled(creating && dev_server_id.is_none())
1321 .on_click(cx.listener({
1322 let access_token = access_token.clone();
1323 move |this, _, cx| {
1324 if kind == NewServerKind::DirectSSH {
1325 this.create_ssh_server(cx);
1326 return;
1327 }
1328 this.create_or_update_dev_server(
1329 kind,
1330 dev_server_id,
1331 access_token.clone(),
1332 cx,
1333 );
1334 }
1335 }))
1336 }),
1337 )
1338 }
1339
1340 fn render_dev_server_token_creating(
1341 &self,
1342 access_token: String,
1343 dev_server_name: String,
1344 kind: NewServerKind,
1345 status: DevServerStatus,
1346 creating: bool,
1347 cx: &mut ViewContext<Self>,
1348 ) -> Div {
1349 self.markdown.update(cx, |markdown, cx| {
1350 if kind == NewServerKind::Manual {
1351 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);
1352 } else {
1353 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);
1354 }
1355 });
1356
1357 v_flex()
1358 .pl_2()
1359 .pt_2()
1360 .gap_2()
1361 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1362 .map(|el| {
1363 if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1364 {
1365 el.child(
1366 h_flex()
1367 .gap_2()
1368 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1369 .child(Label::new("Not connected")),
1370 )
1371 } else if status == DevServerStatus::Offline {
1372 el.child(Self::render_loading_spinner("Waiting for connection…"))
1373 } else {
1374 el.child(Label::new("🎊 Connection established!"))
1375 }
1376 })
1377 }
1378
1379 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1380 h_flex()
1381 .gap_2()
1382 .child(
1383 Icon::new(IconName::ArrowCircle)
1384 .size(IconSize::Medium)
1385 .with_animation(
1386 "arrow-circle",
1387 Animation::new(Duration::from_secs(2)).repeat(),
1388 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1389 ),
1390 )
1391 .child(Label::new(label))
1392 }
1393
1394 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1395 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1396 let ssh_connections = SshSettings::get_global(cx)
1397 .ssh_connections()
1398 .collect::<Vec<_>>();
1399
1400 let Mode::Default(create_dev_server_project) = &self.mode else {
1401 unreachable!()
1402 };
1403
1404 let mut is_creating = None;
1405 let mut creating_dev_server = None;
1406 if let Some(CreateDevServerProject {
1407 creating,
1408 dev_server_id,
1409 ..
1410 }) = create_dev_server_project
1411 {
1412 is_creating = Some(*creating);
1413 creating_dev_server = Some(*dev_server_id);
1414 };
1415 let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
1416
1417 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1418 .header(
1419 ModalHeader::new()
1420 .show_dismiss_button(true)
1421 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1422 )
1423 .when(is_signed_out, |modal| {
1424 modal
1425 .section(Section::new().child(div().child(Label::new(
1426 "To continue with the remote development features, you need to sign in to Zed.",
1427 ))))
1428 .footer(
1429 ModalFooter::new().end_slot(
1430 Button::new("sign_in", "Sign in with GitHub")
1431 .icon(IconName::Github)
1432 .icon_position(IconPosition::Start)
1433 .full_width()
1434 .on_click(cx.listener(|_, _, cx| {
1435 let client = Client::global(cx).clone();
1436 cx.spawn(|_, mut cx| async move {
1437 client
1438 .authenticate_and_connect(true, &cx)
1439 .await
1440 .notify_async_err(&mut cx);
1441 })
1442 .detach();
1443 cx.emit(gpui::DismissEvent);
1444 })),
1445 ),
1446 )
1447 })
1448 .when(!is_signed_out, |modal| {
1449 modal.section(
1450 Section::new().child(
1451 div().child(
1452 List::new()
1453 .empty_message("No dev servers registered yet.")
1454 .header(Some(
1455 ListHeader::new("Connections").end_slot(
1456 Button::new("register-dev-server-button", "Connect New Server")
1457 .icon(IconName::Plus)
1458 .icon_position(IconPosition::Start)
1459 .icon_color(Color::Muted)
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 .p_2()
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}