1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use anyhow::Context;
7use anyhow::Result;
8use client::Client;
9use dev_server_projects::{DevServer, DevServerId, DevServerProject, DevServerProjectId};
10use editor::Editor;
11use gpui::AsyncWindowContext;
12use gpui::PathPromptOptions;
13use gpui::Subscription;
14use gpui::Task;
15use gpui::WeakView;
16use gpui::{
17 percentage, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
18 FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, ViewContext,
19};
20use markdown::Markdown;
21use markdown::MarkdownStyle;
22use project::terminals::wrap_for_ssh;
23use project::terminals::SshCommand;
24use rpc::proto::RegenerateDevServerTokenResponse;
25use rpc::{
26 proto::{CreateDevServerResponse, DevServerStatus},
27 ErrorCode, ErrorExt,
28};
29use settings::update_settings_file;
30use settings::Settings;
31use task::HideStrategy;
32use task::RevealStrategy;
33use task::SpawnInTerminal;
34use terminal_view::terminal_panel::TerminalPanel;
35use ui::ElevationIndex;
36use ui::Section;
37use ui::{
38 prelude::*, Indicator, List, ListHeader, ListItem, Modal, ModalFooter, ModalHeader,
39 RadioWithLabel, Tooltip,
40};
41use ui_input::{FieldLabelLayout, TextField};
42use util::ResultExt;
43use workspace::notifications::NotifyResultExt;
44use workspace::OpenOptions;
45use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace, WORKSPACE_DB};
46
47use crate::open_dev_server_project;
48use crate::ssh_connections::connect_over_ssh;
49use crate::ssh_connections::open_ssh_project;
50use crate::ssh_connections::RemoteSettingsContent;
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.paths.into_iter().map(PathBuf::from).collect(),
990 app_state,
991 OpenOptions::default(),
992 &mut cx,
993 )
994 .await;
995 if let Err(e) = result {
996 log::error!("Failed to connect: {:?}", e);
997 cx.prompt(
998 gpui::PromptLevel::Critical,
999 "Failed to connect",
1000 Some(&e.to_string()),
1001 &["Ok"],
1002 )
1003 .await
1004 .ok();
1005 }
1006 })
1007 .detach();
1008 }))
1009 .end_hover_slot::<AnyElement>(Some(
1010 IconButton::new("remove-remote-project", IconName::Trash)
1011 .on_click(
1012 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
1013 )
1014 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
1015 .into_any_element(),
1016 ))
1017 }
1018
1019 fn update_settings_file(
1020 &mut self,
1021 cx: &mut ViewContext<Self>,
1022 f: impl FnOnce(&mut RemoteSettingsContent) + Send + Sync + 'static,
1023 ) {
1024 let Some(fs) = self
1025 .workspace
1026 .update(cx, |workspace, _| workspace.app_state().fs.clone())
1027 .log_err()
1028 else {
1029 return;
1030 };
1031 update_settings_file::<SshSettings>(fs, cx, move |setting, _| f(setting));
1032 }
1033
1034 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
1035 self.update_settings_file(cx, move |setting| {
1036 if let Some(connections) = setting.ssh_connections.as_mut() {
1037 connections.remove(server);
1038 }
1039 });
1040 }
1041
1042 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
1043 self.update_settings_file(cx, move |setting| {
1044 if let Some(server) = setting
1045 .ssh_connections
1046 .as_mut()
1047 .and_then(|connections| connections.get_mut(server))
1048 {
1049 server.projects.remove(project);
1050 }
1051 });
1052 }
1053
1054 fn add_ssh_server(
1055 &mut self,
1056 connection_options: remote::SshConnectionOptions,
1057 cx: &mut ViewContext<Self>,
1058 ) {
1059 self.update_settings_file(cx, move |setting| {
1060 setting
1061 .ssh_connections
1062 .get_or_insert(Default::default())
1063 .push(SshConnection {
1064 host: connection_options.host,
1065 username: connection_options.username,
1066 port: connection_options.port,
1067 projects: vec![],
1068 })
1069 });
1070 }
1071
1072 fn render_create_new_project(
1073 &mut self,
1074 creating: bool,
1075 _: &mut ViewContext<Self>,
1076 ) -> impl IntoElement {
1077 ListItem::new("create-remote-project")
1078 .disabled(true)
1079 .start_slot(Icon::new(IconName::FileTree).color(Color::Muted))
1080 .child(self.project_path_input.clone())
1081 .child(div().w(IconSize::Medium.rems()).when(creating, |el| {
1082 el.child(
1083 Icon::new(IconName::ArrowCircle)
1084 .size(IconSize::Medium)
1085 .with_animation(
1086 "arrow-circle",
1087 Animation::new(Duration::from_secs(2)).repeat(),
1088 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1089 ),
1090 )
1091 }))
1092 }
1093
1094 fn render_dev_server_project(
1095 &mut self,
1096 project: &DevServerProject,
1097 cx: &mut ViewContext<Self>,
1098 ) -> impl IntoElement {
1099 let dev_server_project_id = project.id;
1100 let project_id = project.project_id;
1101 let is_online = project_id.is_some();
1102
1103 ListItem::new(("remote-project", dev_server_project_id.0))
1104 .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted)))
1105 .child(
1106 Label::new(project.paths.join(", "))
1107 )
1108 .on_click(cx.listener(move |_, _, cx| {
1109 if let Some(project_id) = project_id {
1110 if let Some(app_state) = AppState::global(cx).upgrade() {
1111 workspace::join_dev_server_project(dev_server_project_id, project_id, app_state, None, cx)
1112 .detach_and_prompt_err("Could not join project", cx, |_, _| None)
1113 }
1114 } else {
1115 cx.spawn(|_, mut cx| async move {
1116 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();
1117 }).detach();
1118 }
1119 }))
1120 .end_hover_slot::<AnyElement>(Some(IconButton::new("remove-remote-project", IconName::Trash)
1121 .on_click(cx.listener(move |this, _, cx| {
1122 this.delete_dev_server_project(dev_server_project_id, cx)
1123 }))
1124 .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element()))
1125 }
1126
1127 fn render_create_dev_server(
1128 &self,
1129 state: &CreateDevServer,
1130 cx: &mut ViewContext<Self>,
1131 ) -> impl IntoElement {
1132 let creating = state.creating.is_some();
1133 let dev_server_id = state.dev_server_id;
1134 let access_token = state.access_token.clone();
1135 let ssh_prompt = state.ssh_prompt.clone();
1136 let use_direct_ssh = SshSettings::get_global(cx).use_direct_ssh();
1137
1138 let mut kind = state.kind;
1139 if use_direct_ssh && kind == NewServerKind::LegacySSH {
1140 kind = NewServerKind::DirectSSH;
1141 }
1142
1143 let status = dev_server_id
1144 .map(|id| self.dev_server_store.read(cx).dev_server_status(id))
1145 .unwrap_or_default();
1146
1147 let name = self.dev_server_name_input.update(cx, |input, cx| {
1148 input.editor().update(cx, |editor, cx| {
1149 if editor.text(cx).is_empty() {
1150 match kind {
1151 NewServerKind::DirectSSH => editor.set_placeholder_text("ssh host", cx),
1152 NewServerKind::LegacySSH => editor.set_placeholder_text("ssh host", cx),
1153 NewServerKind::Manual => editor.set_placeholder_text("example-host", cx),
1154 }
1155 }
1156 editor.text(cx)
1157 })
1158 });
1159
1160 const MANUAL_SETUP_MESSAGE: &str =
1161 "Generate a token for this server and follow the steps to set Zed up on that machine.";
1162 const SSH_SETUP_MESSAGE: &str =
1163 "Enter the command you use to SSH into this server.\nFor example: `ssh me@my.server` or `ssh me@secret-box:2222`.";
1164
1165 Modal::new("create-dev-server", Some(self.scroll_handle.clone()))
1166 .header(
1167 ModalHeader::new()
1168 .headline("Create Dev Server")
1169 .show_back_button(true),
1170 )
1171 .section(
1172 Section::new()
1173 .header(if kind == NewServerKind::Manual {
1174 "Server Name".into()
1175 } else {
1176 "SSH arguments".into()
1177 })
1178 .child(
1179 div()
1180 .max_w(rems(16.))
1181 .child(self.dev_server_name_input.clone()),
1182 ),
1183 )
1184 .section(
1185 Section::new_contained()
1186 .header("Connection Method".into())
1187 .child(
1188 v_flex()
1189 .w_full()
1190 .px_2()
1191 .gap_y(Spacing::Large.rems(cx))
1192 .when(ssh_prompt.is_none(), |el| {
1193 el.child(
1194 v_flex()
1195 .when(use_direct_ssh, |el| {
1196 el.child(RadioWithLabel::new(
1197 "use-server-name-in-ssh",
1198 Label::new("Connect via SSH (default)"),
1199 NewServerKind::DirectSSH == kind,
1200 cx.listener({
1201 move |this, _, cx| {
1202 if let Mode::CreateDevServer(
1203 CreateDevServer { kind, .. },
1204 ) = &mut this.mode
1205 {
1206 *kind = NewServerKind::DirectSSH;
1207 }
1208 cx.notify()
1209 }
1210 }),
1211 ))
1212 })
1213 .when(!use_direct_ssh, |el| {
1214 el.child(RadioWithLabel::new(
1215 "use-server-name-in-ssh",
1216 Label::new("Configure over SSH (default)"),
1217 kind == NewServerKind::LegacySSH,
1218 cx.listener({
1219 move |this, _, cx| {
1220 if let Mode::CreateDevServer(
1221 CreateDevServer { kind, .. },
1222 ) = &mut this.mode
1223 {
1224 *kind = NewServerKind::LegacySSH;
1225 }
1226 cx.notify()
1227 }
1228 }),
1229 ))
1230 })
1231 .child(RadioWithLabel::new(
1232 "use-server-name-in-ssh",
1233 Label::new("Configure manually"),
1234 kind == NewServerKind::Manual,
1235 cx.listener({
1236 move |this, _, cx| {
1237 if let Mode::CreateDevServer(
1238 CreateDevServer { kind, .. },
1239 ) = &mut this.mode
1240 {
1241 *kind = NewServerKind::Manual;
1242 }
1243 cx.notify()
1244 }
1245 }),
1246 )),
1247 )
1248 })
1249 .when(dev_server_id.is_none() && ssh_prompt.is_none(), |el| {
1250 el.child(
1251 if kind == NewServerKind::Manual {
1252 Label::new(MANUAL_SETUP_MESSAGE)
1253 } else {
1254 Label::new(SSH_SETUP_MESSAGE)
1255 }
1256 .size(LabelSize::Small)
1257 .color(Color::Muted),
1258 )
1259 })
1260 .when_some(ssh_prompt, |el, ssh_prompt| el.child(ssh_prompt))
1261 .when(dev_server_id.is_some() && access_token.is_none(), |el| {
1262 el.child(
1263 if kind == NewServerKind::Manual {
1264 Label::new(
1265 "Note: updating the dev server generate a new token",
1266 )
1267 } else {
1268 Label::new(SSH_SETUP_MESSAGE)
1269 }
1270 .size(LabelSize::Small)
1271 .color(Color::Muted),
1272 )
1273 })
1274 .when_some(access_token.clone(), {
1275 |el, access_token| {
1276 el.child(self.render_dev_server_token_creating(
1277 access_token,
1278 name,
1279 kind,
1280 status,
1281 creating,
1282 cx,
1283 ))
1284 }
1285 }),
1286 ),
1287 )
1288 .footer(
1289 ModalFooter::new().end_slot(if status == DevServerStatus::Online {
1290 Button::new("create-dev-server", "Done")
1291 .style(ButtonStyle::Filled)
1292 .layer(ElevationIndex::ModalSurface)
1293 .on_click(cx.listener(move |this, _, cx| {
1294 cx.focus(&this.focus_handle);
1295 this.mode = Mode::Default(None);
1296 cx.notify();
1297 }))
1298 } else {
1299 Button::new(
1300 "create-dev-server",
1301 if kind == NewServerKind::Manual {
1302 if dev_server_id.is_some() {
1303 "Update"
1304 } else {
1305 "Create"
1306 }
1307 } else if dev_server_id.is_some() {
1308 "Reconnect"
1309 } else {
1310 "Connect"
1311 },
1312 )
1313 .style(ButtonStyle::Filled)
1314 .layer(ElevationIndex::ModalSurface)
1315 .disabled(creating && dev_server_id.is_none())
1316 .on_click(cx.listener({
1317 let access_token = access_token.clone();
1318 move |this, _, cx| {
1319 if kind == NewServerKind::DirectSSH {
1320 this.create_ssh_server(cx);
1321 return;
1322 }
1323 this.create_or_update_dev_server(
1324 kind,
1325 dev_server_id,
1326 access_token.clone(),
1327 cx,
1328 );
1329 }
1330 }))
1331 }),
1332 )
1333 }
1334
1335 fn render_dev_server_token_creating(
1336 &self,
1337 access_token: String,
1338 dev_server_name: String,
1339 kind: NewServerKind,
1340 status: DevServerStatus,
1341 creating: bool,
1342 cx: &mut ViewContext<Self>,
1343 ) -> Div {
1344 self.markdown.update(cx, |markdown, cx| {
1345 if kind == NewServerKind::Manual {
1346 markdown.reset(format!("Please log into '{}'. If you don't yet have Zed installed, run:\n```\ncurl https://zed.dev/install.sh | bash\n```\nThen, to start Zed in headless mode:\n```\nzed --dev-server-token {}\n```", dev_server_name, access_token), cx);
1347 } else {
1348 markdown.reset("Please wait while we connect over SSH.\n\nIf you run into problems, please [file a bug](https://github.com/zed-industries/zed), and in the meantime try using the manual setup.".to_string(), cx);
1349 }
1350 });
1351
1352 v_flex()
1353 .pl_2()
1354 .pt_2()
1355 .gap_2()
1356 .child(v_flex().w_full().text_sm().child(self.markdown.clone()))
1357 .map(|el| {
1358 if status == DevServerStatus::Offline && kind != NewServerKind::Manual && !creating
1359 {
1360 el.child(
1361 h_flex()
1362 .gap_2()
1363 .child(Icon::new(IconName::Disconnected).size(IconSize::Medium))
1364 .child(Label::new("Not connected")),
1365 )
1366 } else if status == DevServerStatus::Offline {
1367 el.child(Self::render_loading_spinner("Waiting for connection…"))
1368 } else {
1369 el.child(Label::new("🎊 Connection established!"))
1370 }
1371 })
1372 }
1373
1374 fn render_loading_spinner(label: impl Into<SharedString>) -> Div {
1375 h_flex()
1376 .gap_2()
1377 .child(
1378 Icon::new(IconName::ArrowCircle)
1379 .size(IconSize::Medium)
1380 .with_animation(
1381 "arrow-circle",
1382 Animation::new(Duration::from_secs(2)).repeat(),
1383 |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
1384 ),
1385 )
1386 .child(Label::new(label))
1387 }
1388
1389 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1390 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1391 let ssh_connections = SshSettings::get_global(cx)
1392 .ssh_connections()
1393 .collect::<Vec<_>>();
1394
1395 let Mode::Default(create_dev_server_project) = &self.mode else {
1396 unreachable!()
1397 };
1398
1399 let mut is_creating = None;
1400 let mut creating_dev_server = None;
1401 if let Some(CreateDevServerProject {
1402 creating,
1403 dev_server_id,
1404 ..
1405 }) = create_dev_server_project
1406 {
1407 is_creating = Some(*creating);
1408 creating_dev_server = Some(*dev_server_id);
1409 };
1410 let is_signed_out = Client::global(cx).status().borrow().is_signed_out();
1411
1412 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1413 .header(
1414 ModalHeader::new()
1415 .show_dismiss_button(true)
1416 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::Small)),
1417 )
1418 .when(is_signed_out, |modal| {
1419 modal
1420 .section(Section::new().child(div().child(Label::new(
1421 "To continue with the remote development features, you need to sign in to Zed.",
1422 ))))
1423 .footer(
1424 ModalFooter::new().end_slot(
1425 Button::new("sign_in", "Sign in with GitHub")
1426 .icon(IconName::Github)
1427 .icon_position(IconPosition::Start)
1428 .full_width()
1429 .on_click(cx.listener(|_, _, cx| {
1430 let client = Client::global(cx).clone();
1431 cx.spawn(|_, mut cx| async move {
1432 client
1433 .authenticate_and_connect(true, &cx)
1434 .await
1435 .notify_async_err(&mut cx);
1436 })
1437 .detach();
1438 cx.emit(gpui::DismissEvent);
1439 })),
1440 ),
1441 )
1442 })
1443 .when(!is_signed_out, |modal| {
1444 modal.section(
1445 Section::new().child(
1446 div().child(
1447 List::new()
1448 .empty_message("No dev servers registered yet.")
1449 .header(Some(
1450 ListHeader::new("Connections").end_slot(
1451 Button::new("register-dev-server-button", "Connect New Server")
1452 .icon(IconName::Plus)
1453 .icon_position(IconPosition::Start)
1454 .icon_color(Color::Muted)
1455 .on_click(cx.listener(|this, _, cx| {
1456 this.mode = Mode::CreateDevServer(
1457 CreateDevServer {
1458 kind: if SshSettings::get_global(cx).use_direct_ssh() { NewServerKind::DirectSSH } else { NewServerKind::LegacySSH },
1459 ..Default::default()
1460 }
1461 );
1462 this.dev_server_name_input.update(
1463 cx,
1464 |text_field, cx| {
1465 text_field.editor().update(
1466 cx,
1467 |editor, cx| {
1468 editor.set_text("", cx);
1469 },
1470 );
1471 },
1472 );
1473 cx.notify();
1474 })),
1475 ),
1476 ))
1477 .children(ssh_connections.iter().cloned().enumerate().map(|(ix, connection)| {
1478 self.render_ssh_connection(ix, connection, cx)
1479 .into_any_element()
1480 }))
1481 .children(dev_servers.iter().map(|dev_server| {
1482 let creating = if creating_dev_server == Some(dev_server.id) {
1483 is_creating
1484 } else {
1485 None
1486 };
1487 self.render_dev_server(dev_server, creating, cx)
1488 .into_any_element()
1489 })),
1490 ),
1491 ),
1492 )
1493 })
1494 }
1495}
1496
1497fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
1498 element
1499 .read(cx)
1500 .editor()
1501 .read(cx)
1502 .text(cx)
1503 .trim()
1504 .to_string()
1505}
1506
1507impl ModalView for DevServerProjects {}
1508
1509impl FocusableView for DevServerProjects {
1510 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1511 self.focus_handle.clone()
1512 }
1513}
1514
1515impl EventEmitter<DismissEvent> for DevServerProjects {}
1516
1517impl Render for DevServerProjects {
1518 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1519 div()
1520 .track_focus(&self.focus_handle)
1521 .p_2()
1522 .elevation_3(cx)
1523 .key_context("DevServerModal")
1524 .on_action(cx.listener(Self::cancel))
1525 .on_action(cx.listener(Self::confirm))
1526 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1527 this.focus_handle(cx).focus(cx);
1528 }))
1529 .on_mouse_down_out(cx.listener(|this, _, cx| {
1530 if matches!(this.mode, Mode::Default(None)) {
1531 cx.emit(DismissEvent)
1532 }
1533 }))
1534 .w(rems(34.))
1535 .max_h(rems(40.))
1536 .child(match &self.mode {
1537 Mode::Default(_) => self.render_default(cx).into_any_element(),
1538 Mode::CreateDevServer(state) => {
1539 self.render_create_dev_server(state, cx).into_any_element()
1540 }
1541 })
1542 }
1543}
1544
1545pub fn reconnect_to_dev_server_project(
1546 workspace: View<Workspace>,
1547 dev_server: DevServer,
1548 dev_server_project_id: DevServerProjectId,
1549 replace_current_window: bool,
1550 cx: &mut WindowContext,
1551) -> Task<Result<()>> {
1552 let store = dev_server_projects::Store::global(cx);
1553 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1554 cx.spawn(|mut cx| async move {
1555 reconnect.await?;
1556
1557 cx.background_executor()
1558 .timer(Duration::from_millis(1000))
1559 .await;
1560
1561 if let Some(project_id) = store.update(&mut cx, |store, _| {
1562 store
1563 .dev_server_project(dev_server_project_id)
1564 .and_then(|p| p.project_id)
1565 })? {
1566 workspace
1567 .update(&mut cx, move |_, cx| {
1568 open_dev_server_project(
1569 replace_current_window,
1570 dev_server_project_id,
1571 project_id,
1572 cx,
1573 )
1574 })?
1575 .await?;
1576 }
1577
1578 Ok(())
1579 })
1580}
1581
1582pub fn reconnect_to_dev_server(
1583 workspace: View<Workspace>,
1584 dev_server: DevServer,
1585 cx: &mut WindowContext,
1586) -> Task<Result<()>> {
1587 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1588 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1589 };
1590 let dev_server_store = dev_server_projects::Store::global(cx);
1591 let get_access_token = dev_server_store.update(cx, |store, cx| {
1592 store.regenerate_dev_server_token(dev_server.id, cx)
1593 });
1594
1595 cx.spawn(|mut cx| async move {
1596 let access_token = get_access_token.await?.access_token;
1597
1598 spawn_ssh_task(
1599 workspace,
1600 dev_server_store,
1601 dev_server.id,
1602 ssh_connection_string.to_string(),
1603 access_token,
1604 &mut cx,
1605 )
1606 .await
1607 })
1608}
1609
1610pub async fn spawn_ssh_task(
1611 workspace: View<Workspace>,
1612 dev_server_store: Model<dev_server_projects::Store>,
1613 dev_server_id: DevServerId,
1614 ssh_connection_string: String,
1615 access_token: String,
1616 cx: &mut AsyncWindowContext,
1617) -> Result<()> {
1618 let terminal_panel = workspace
1619 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1620 .ok()
1621 .flatten()
1622 .with_context(|| anyhow!("No terminal panel"))?;
1623
1624 let command = "sh".to_string();
1625 let args = vec![
1626 "-x".to_string(),
1627 "-c".to_string(),
1628 format!(
1629 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 {}"#,
1630 access_token
1631 ),
1632 ];
1633
1634 let ssh_connection_string = ssh_connection_string.to_string();
1635 let (command, args) = wrap_for_ssh(
1636 &SshCommand::DevServer(ssh_connection_string.clone()),
1637 Some((&command, &args)),
1638 None,
1639 HashMap::default(),
1640 None,
1641 );
1642
1643 let terminal = terminal_panel
1644 .update(cx, |terminal_panel, cx| {
1645 terminal_panel.spawn_in_new_terminal(
1646 SpawnInTerminal {
1647 id: task::TaskId("ssh-remote".into()),
1648 full_label: "Install zed over ssh".into(),
1649 label: "Install zed over ssh".into(),
1650 command,
1651 args,
1652 command_label: ssh_connection_string.clone(),
1653 cwd: None,
1654 use_new_terminal: true,
1655 allow_concurrent_runs: false,
1656 reveal: RevealStrategy::Always,
1657 hide: HideStrategy::Never,
1658 env: Default::default(),
1659 shell: Default::default(),
1660 },
1661 cx,
1662 )
1663 })?
1664 .await?;
1665
1666 terminal
1667 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1668 .await;
1669
1670 // 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.
1671 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1672 == DevServerStatus::Offline
1673 {
1674 cx.background_executor()
1675 .timer(Duration::from_millis(200))
1676 .await
1677 }
1678
1679 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1680 == DevServerStatus::Offline
1681 {
1682 return Err(anyhow!("couldn't reconnect"))?;
1683 }
1684
1685 Ok(())
1686}