1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::time::Duration;
4
5use anyhow::anyhow;
6use anyhow::Context;
7use anyhow::Result;
8use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
9use editor::Editor;
10use gpui::pulsating_between;
11use gpui::AsyncWindowContext;
12use gpui::ClipboardItem;
13use gpui::PathPromptOptions;
14use gpui::Subscription;
15use gpui::Task;
16use gpui::WeakView;
17use gpui::{
18 Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter,
19 FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext,
20};
21use project::terminals::wrap_for_ssh;
22use project::terminals::SshCommand;
23use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt};
24use settings::update_settings_file;
25use settings::Settings;
26use task::HideStrategy;
27use task::RevealStrategy;
28use task::SpawnInTerminal;
29use terminal_view::terminal_panel::TerminalPanel;
30use ui::ElevationIndex;
31use ui::Section;
32use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip};
33use ui_input::{FieldLabelLayout, TextField};
34use util::ResultExt;
35use workspace::OpenOptions;
36use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace};
37
38use crate::open_dev_server_project;
39use crate::ssh_connections::connect_over_ssh;
40use crate::ssh_connections::open_ssh_project;
41use crate::ssh_connections::RemoteSettingsContent;
42use crate::ssh_connections::SshConnection;
43use crate::ssh_connections::SshConnectionModal;
44use crate::ssh_connections::SshProject;
45use crate::ssh_connections::SshPrompt;
46use crate::ssh_connections::SshSettings;
47use crate::OpenRemote;
48
49pub struct DevServerProjects {
50 mode: Mode,
51 focus_handle: FocusHandle,
52 scroll_handle: ScrollHandle,
53 dev_server_store: Model<dev_server_projects::Store>,
54 workspace: WeakView<Workspace>,
55 project_path_input: View<Editor>,
56 dev_server_name_input: View<TextField>,
57 _dev_server_subscription: Subscription,
58}
59
60#[derive(Default)]
61struct CreateDevServer {
62 creating: Option<Task<Option<()>>>,
63 ssh_prompt: Option<View<SshPrompt>>,
64}
65
66struct CreateDevServerProject {
67 dev_server_id: DevServerId,
68 _opening: Option<Subscription>,
69}
70
71enum Mode {
72 Default(Option<CreateDevServerProject>),
73 CreateDevServer(CreateDevServer),
74}
75
76impl DevServerProjects {
77 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
78 workspace.register_action(|workspace, _: &OpenRemote, cx| {
79 let handle = cx.view().downgrade();
80 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
81 });
82 }
83
84 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
85 workspace.update(cx, |workspace, cx| {
86 let handle = cx.view().downgrade();
87 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
88 })
89 }
90
91 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
92 let project_path_input = cx.new_view(|cx| {
93 let mut editor = Editor::single_line(cx);
94 editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx);
95 editor
96 });
97 let dev_server_name_input = cx.new_view(|cx| {
98 TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden)
99 });
100
101 let focus_handle = cx.focus_handle();
102 let dev_server_store = dev_server_projects::Store::global(cx);
103
104 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
105 cx.notify();
106 });
107
108 let mut base_style = cx.text_style();
109 base_style.refine(&gpui::TextStyleRefinement {
110 color: Some(cx.theme().colors().editor_foreground),
111 ..Default::default()
112 });
113
114 Self {
115 mode: Mode::Default(None),
116 focus_handle,
117 scroll_handle: ScrollHandle::new(),
118 dev_server_store,
119 project_path_input,
120 dev_server_name_input,
121 workspace,
122 _dev_server_subscription: subscription,
123 }
124 }
125
126 pub fn create_dev_server_project(
127 &mut self,
128 dev_server_id: DevServerId,
129 cx: &mut ViewContext<Self>,
130 ) {
131 let mut path = self.project_path_input.read(cx).text(cx).trim().to_string();
132
133 if path.is_empty() {
134 return;
135 }
136
137 if !path.starts_with('/') && !path.starts_with('~') {
138 path = format!("~/{}", path);
139 }
140
141 if self
142 .dev_server_store
143 .read(cx)
144 .projects_for_server(dev_server_id)
145 .iter()
146 .any(|p| p.paths.iter().any(|p| p == &path))
147 {
148 cx.spawn(|_, mut cx| async move {
149 cx.prompt(
150 gpui::PromptLevel::Critical,
151 "Failed to create project",
152 Some(&format!("{} is already open on this dev server.", path)),
153 &["Ok"],
154 )
155 .await
156 })
157 .detach_and_log_err(cx);
158 return;
159 }
160
161 let create = {
162 let path = path.clone();
163 self.dev_server_store.update(cx, |store, cx| {
164 store.create_dev_server_project(dev_server_id, path, cx)
165 })
166 };
167
168 cx.spawn(|this, mut cx| async move {
169 let result = create.await;
170 this.update(&mut cx, |this, cx| {
171 if let Ok(result) = &result {
172 if let Some(dev_server_project_id) =
173 result.dev_server_project.as_ref().map(|p| p.id)
174 {
175 let subscription =
176 cx.observe(&this.dev_server_store, move |this, store, cx| {
177 if let Some(project_id) = store
178 .read(cx)
179 .dev_server_project(DevServerProjectId(dev_server_project_id))
180 .and_then(|p| p.project_id)
181 {
182 this.project_path_input.update(cx, |editor, cx| {
183 editor.set_text("", cx);
184 });
185 this.mode = Mode::Default(None);
186 if let Some(app_state) = AppState::global(cx).upgrade() {
187 workspace::join_dev_server_project(
188 DevServerProjectId(dev_server_project_id),
189 project_id,
190 app_state,
191 None,
192 cx,
193 )
194 .detach_and_prompt_err(
195 "Could not join project",
196 cx,
197 |_, _| None,
198 )
199 }
200 }
201 });
202
203 this.mode = Mode::Default(Some(CreateDevServerProject {
204 dev_server_id,
205 _opening: Some(subscription),
206 }));
207 }
208 } else {
209 this.mode = Mode::Default(Some(CreateDevServerProject {
210 dev_server_id,
211 _opening: None,
212 }));
213 }
214 })
215 .log_err();
216 result
217 })
218 .detach_and_prompt_err("Failed to create project", cx, move |e, _| {
219 match e.error_code() {
220 ErrorCode::DevServerOffline => Some(
221 "The dev server is offline. Please log in and check it is connected."
222 .to_string(),
223 ),
224 ErrorCode::DevServerProjectPathDoesNotExist => {
225 Some(format!("The path `{}` does not exist on the server.", path))
226 }
227 _ => None,
228 }
229 });
230
231 self.mode = Mode::Default(Some(CreateDevServerProject {
232 dev_server_id,
233
234 _opening: None,
235 }));
236 }
237
238 fn create_ssh_server(&mut self, cx: &mut ViewContext<Self>) {
239 let host = get_text(&self.dev_server_name_input, cx);
240 if host.is_empty() {
241 return;
242 }
243
244 let mut host = host.trim_start_matches("ssh ");
245 let mut username: Option<String> = None;
246 let mut port: Option<u16> = None;
247
248 if let Some((u, rest)) = host.split_once('@') {
249 host = rest;
250 username = Some(u.to_string());
251 }
252 if let Some((rest, p)) = host.split_once(':') {
253 host = rest;
254 port = p.parse().ok()
255 }
256
257 if let Some((rest, p)) = host.split_once(" -p") {
258 host = rest;
259 port = p.trim().parse().ok()
260 }
261
262 let connection_options = remote::SshConnectionOptions {
263 host: host.to_string(),
264 username: username.clone(),
265 port,
266 password: None,
267 };
268 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
269
270 let connection = connect_over_ssh(
271 connection_options.dev_server_identifier(),
272 connection_options.clone(),
273 ssh_prompt.clone(),
274 cx,
275 )
276 .prompt_err("Failed to connect", cx, |_, _| None);
277
278 let creating = cx.spawn(move |this, mut cx| async move {
279 match connection.await {
280 Some(_) => this
281 .update(&mut cx, |this, cx| {
282 this.add_ssh_server(connection_options, cx);
283 this.mode = Mode::Default(None);
284 cx.notify()
285 })
286 .log_err(),
287 None => this
288 .update(&mut cx, |this, cx| {
289 this.mode = Mode::CreateDevServer(CreateDevServer::default());
290 cx.notify()
291 })
292 .log_err(),
293 };
294 None
295 });
296 self.mode = Mode::CreateDevServer(CreateDevServer {
297 ssh_prompt: Some(ssh_prompt.clone()),
298 creating: Some(creating),
299 });
300 }
301
302 fn create_ssh_project(
303 &mut self,
304 ix: usize,
305 ssh_connection: SshConnection,
306 cx: &mut ViewContext<Self>,
307 ) {
308 let Some(workspace) = self.workspace.upgrade() else {
309 return;
310 };
311
312 let connection_options = ssh_connection.into();
313 workspace.update(cx, |_, cx| {
314 cx.defer(move |workspace, cx| {
315 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
316 let prompt = workspace
317 .active_modal::<SshConnectionModal>(cx)
318 .unwrap()
319 .read(cx)
320 .prompt
321 .clone();
322
323 let connect = connect_over_ssh(
324 connection_options.dev_server_identifier(),
325 connection_options,
326 prompt,
327 cx,
328 )
329 .prompt_err("Failed to connect", cx, |_, _| None);
330 cx.spawn(|workspace, mut cx| async move {
331 let Some(session) = connect.await else {
332 workspace
333 .update(&mut cx, |workspace, cx| {
334 let weak = cx.view().downgrade();
335 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
336 })
337 .log_err();
338 return;
339 };
340 let Ok((app_state, project, paths)) =
341 workspace.update(&mut cx, |workspace, cx| {
342 let app_state = workspace.app_state().clone();
343 let project = project::Project::ssh(
344 session,
345 app_state.client.clone(),
346 app_state.node_runtime.clone(),
347 app_state.user_store.clone(),
348 app_state.languages.clone(),
349 app_state.fs.clone(),
350 cx,
351 );
352 let paths = workspace.prompt_for_open_path(
353 PathPromptOptions {
354 files: true,
355 directories: true,
356 multiple: true,
357 },
358 project::DirectoryLister::Project(project.clone()),
359 cx,
360 );
361 (app_state, project, paths)
362 })
363 else {
364 return;
365 };
366
367 let Ok(Some(paths)) = paths.await else {
368 workspace
369 .update(&mut cx, |workspace, cx| {
370 let weak = cx.view().downgrade();
371 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
372 })
373 .log_err();
374 return;
375 };
376
377 let Some(options) = cx
378 .update(|cx| (app_state.build_window_options)(None, cx))
379 .log_err()
380 else {
381 return;
382 };
383
384 cx.open_window(options, |cx| {
385 cx.activate_window();
386
387 let fs = app_state.fs.clone();
388 update_settings_file::<SshSettings>(fs, cx, {
389 let paths = paths
390 .iter()
391 .map(|path| path.to_string_lossy().to_string())
392 .collect();
393 move |setting, _| {
394 if let Some(server) = setting
395 .ssh_connections
396 .as_mut()
397 .and_then(|connections| connections.get_mut(ix))
398 {
399 server.projects.push(SshProject { paths })
400 }
401 }
402 });
403
404 let tasks = paths
405 .into_iter()
406 .map(|path| {
407 project.update(cx, |project, cx| {
408 project.find_or_create_worktree(&path, true, cx)
409 })
410 })
411 .collect::<Vec<_>>();
412 cx.spawn(|_| async move {
413 for task in tasks {
414 task.await?;
415 }
416 Ok(())
417 })
418 .detach_and_prompt_err(
419 "Failed to open path",
420 cx,
421 |_, _| None,
422 );
423
424 cx.new_view(|cx| {
425 Workspace::new(None, project.clone(), app_state.clone(), cx)
426 })
427 })
428 .log_err();
429 })
430 .detach()
431 })
432 })
433 }
434
435 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
436 match &self.mode {
437 Mode::Default(None) => {}
438 Mode::Default(Some(create_project)) => {
439 self.create_dev_server_project(create_project.dev_server_id, cx);
440 }
441 Mode::CreateDevServer(state) => {
442 if let Some(prompt) = state.ssh_prompt.as_ref() {
443 prompt.update(cx, |prompt, cx| {
444 prompt.confirm(cx);
445 });
446 return;
447 }
448
449 self.create_ssh_server(cx);
450 }
451 }
452 }
453
454 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
455 match &self.mode {
456 Mode::Default(None) => cx.emit(DismissEvent),
457 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
458 self.mode = Mode::CreateDevServer(CreateDevServer {
459 ..Default::default()
460 });
461 cx.notify();
462 }
463 _ => {
464 self.mode = Mode::Default(None);
465 self.focus_handle(cx).focus(cx);
466 cx.notify();
467 }
468 }
469 }
470
471 fn render_ssh_connection(
472 &mut self,
473 ix: usize,
474 ssh_connection: SshConnection,
475 cx: &mut ViewContext<Self>,
476 ) -> impl IntoElement {
477 v_flex()
478 .w_full()
479 .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
480 .child(
481 h_flex()
482 .w_full()
483 .group("ssh-server")
484 .justify_between()
485 .child(
486 h_flex()
487 .gap_2()
488 .w_full()
489 .child(
490 div()
491 .id(("status", ix))
492 .relative()
493 .child(Icon::new(IconName::Server).size(IconSize::Small)),
494 )
495 .child(
496 h_flex()
497 .max_w(rems(26.))
498 .overflow_hidden()
499 .whitespace_nowrap()
500 .child(Label::new(ssh_connection.host.clone())),
501 ),
502 )
503 .child(
504 h_flex()
505 .visible_on_hover("ssh-server")
506 .gap_1()
507 .child({
508 IconButton::new("copy-dev-server-address", IconName::Copy)
509 .icon_size(IconSize::Small)
510 .on_click(cx.listener(move |this, _, cx| {
511 this.update_settings_file(cx, move |servers, cx| {
512 if let Some(content) = servers
513 .ssh_connections
514 .as_ref()
515 .and_then(|connections| {
516 connections
517 .get(ix)
518 .map(|connection| connection.host.clone())
519 })
520 {
521 cx.write_to_clipboard(ClipboardItem::new_string(
522 content,
523 ));
524 }
525 });
526 }))
527 .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
528 })
529 .child({
530 IconButton::new("remove-dev-server", IconName::TrashAlt)
531 .icon_size(IconSize::Small)
532 .on_click(cx.listener(move |this, _, cx| {
533 this.delete_ssh_server(ix, cx)
534 }))
535 .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
536 }),
537 ),
538 )
539 .child(
540 v_flex()
541 .w_full()
542 .border_l_1()
543 .border_color(cx.theme().colors().border_variant)
544 .my_1()
545 .mx_1p5()
546 .py_0p5()
547 .px_3()
548 .child(
549 List::new()
550 .empty_message("No projects.")
551 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
552 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
553 }))
554 .child(
555 h_flex().child(
556 Button::new("new-remote_project", "Open Folder…")
557 .icon(IconName::Plus)
558 .size(ButtonSize::Default)
559 .style(ButtonStyle::Filled)
560 .layer(ElevationIndex::ModalSurface)
561 .icon_position(IconPosition::Start)
562 .on_click(cx.listener(move |this, _, cx| {
563 this.create_ssh_project(ix, ssh_connection.clone(), cx);
564 })),
565 ),
566 ),
567 ),
568 )
569 }
570
571 fn render_ssh_project(
572 &self,
573 server_ix: usize,
574 server: &SshConnection,
575 ix: usize,
576 project: &SshProject,
577 cx: &ViewContext<Self>,
578 ) -> impl IntoElement {
579 let project = project.clone();
580 let server = server.clone();
581 ListItem::new(("remote-project", ix))
582 .spacing(ui::ListItemSpacing::Sparse)
583 .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
584 .child(Label::new(project.paths.join(", ")))
585 .on_click(cx.listener(move |this, _, cx| {
586 let Some(app_state) = this
587 .workspace
588 .update(cx, |workspace, _| workspace.app_state().clone())
589 .log_err()
590 else {
591 return;
592 };
593 let project = project.clone();
594 let server = server.clone();
595 cx.spawn(|_, mut cx| async move {
596 let result = open_ssh_project(
597 server.into(),
598 project.paths.into_iter().map(PathBuf::from).collect(),
599 app_state,
600 OpenOptions::default(),
601 &mut cx,
602 )
603 .await;
604 if let Err(e) = result {
605 log::error!("Failed to connect: {:?}", e);
606 cx.prompt(
607 gpui::PromptLevel::Critical,
608 "Failed to connect",
609 Some(&e.to_string()),
610 &["Ok"],
611 )
612 .await
613 .ok();
614 }
615 })
616 .detach();
617 }))
618 .end_hover_slot::<AnyElement>(Some(
619 IconButton::new("remove-remote-project", IconName::TrashAlt)
620 .on_click(
621 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
622 )
623 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
624 .into_any_element(),
625 ))
626 }
627
628 fn update_settings_file(
629 &mut self,
630 cx: &mut ViewContext<Self>,
631 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
632 ) {
633 let Some(fs) = self
634 .workspace
635 .update(cx, |workspace, _| workspace.app_state().fs.clone())
636 .log_err()
637 else {
638 return;
639 };
640 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
641 }
642
643 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
644 self.update_settings_file(cx, move |setting, _| {
645 if let Some(connections) = setting.ssh_connections.as_mut() {
646 connections.remove(server);
647 }
648 });
649 }
650
651 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
652 self.update_settings_file(cx, move |setting, _| {
653 if let Some(server) = setting
654 .ssh_connections
655 .as_mut()
656 .and_then(|connections| connections.get_mut(server))
657 {
658 server.projects.remove(project);
659 }
660 });
661 }
662
663 fn add_ssh_server(
664 &mut self,
665 connection_options: remote::SshConnectionOptions,
666 cx: &mut ViewContext<Self>,
667 ) {
668 self.update_settings_file(cx, move |setting, _| {
669 setting
670 .ssh_connections
671 .get_or_insert(Default::default())
672 .push(SshConnection {
673 host: connection_options.host,
674 username: connection_options.username,
675 port: connection_options.port,
676 projects: vec![],
677 })
678 });
679 }
680
681 fn render_create_dev_server(
682 &self,
683 state: &CreateDevServer,
684 cx: &mut ViewContext<Self>,
685 ) -> impl IntoElement {
686 let creating = state.creating.is_some();
687 let ssh_prompt = state.ssh_prompt.clone();
688
689 self.dev_server_name_input.update(cx, |input, cx| {
690 input.editor().update(cx, |editor, cx| {
691 if editor.text(cx).is_empty() {
692 editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
693 }
694 })
695 });
696 let theme = cx.theme();
697 v_flex()
698 .id("create-dev-server")
699 .overflow_hidden()
700 .size_full()
701 .flex_1()
702 .child(
703 h_flex()
704 .p_2()
705 .gap_2()
706 .items_center()
707 .border_b_1()
708 .border_color(theme.colors().border_variant)
709 .child(
710 IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
711 .shape(IconButtonShape::Square)
712 .on_click(|_, cx| {
713 cx.dispatch_action(menu::Cancel.boxed_clone());
714 }),
715 )
716 .child(Label::new("Connect New Dev Server")),
717 )
718 .child(
719 v_flex()
720 .p_3()
721 .border_b_1()
722 .border_color(theme.colors().border_variant)
723 .child(Label::new("SSH Arguments"))
724 .child(
725 Label::new("Enter the command you use to SSH into this server.")
726 .size(LabelSize::Small)
727 .color(Color::Muted),
728 )
729 .child(
730 h_flex()
731 .mt_2()
732 .w_full()
733 .gap_2()
734 .child(self.dev_server_name_input.clone())
735 .child(
736 Button::new("create-dev-server", "Connect Server")
737 .style(ButtonStyle::Filled)
738 .layer(ElevationIndex::ModalSurface)
739 .disabled(creating)
740 .on_click(cx.listener({
741 move |this, _, cx| {
742 this.create_ssh_server(cx);
743 }
744 })),
745 ),
746 ),
747 )
748 .child(
749 h_flex()
750 .bg(theme.colors().editor_background)
751 .w_full()
752 .map(|this| {
753 if let Some(ssh_prompt) = ssh_prompt {
754 this.child(h_flex().w_full().child(ssh_prompt))
755 } else {
756 let color = Color::Muted.color(cx);
757 this.child(
758 h_flex()
759 .p_2()
760 .w_full()
761 .content_center()
762 .gap_2()
763 .child(h_flex().w_full())
764 .child(
765 div().p_1().rounded_lg().bg(color).with_animation(
766 "pulse-ssh-waiting-for-connection",
767 Animation::new(Duration::from_secs(2))
768 .repeat()
769 .with_easing(pulsating_between(0.2, 0.5)),
770 move |this, progress| this.bg(color.opacity(progress)),
771 ),
772 )
773 .child(
774 Label::new("Waiting for connection…")
775 .size(LabelSize::Small),
776 )
777 .child(h_flex().w_full()),
778 )
779 }
780 }),
781 )
782 }
783
784 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
785 let dev_servers = self.dev_server_store.read(cx).dev_servers();
786 let ssh_connections = SshSettings::get_global(cx)
787 .ssh_connections()
788 .collect::<Vec<_>>();
789
790 let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
791 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
792 .header(
793 ModalHeader::new().child(
794 h_flex()
795 .justify_between()
796 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
797 .child(
798 Button::new("register-dev-server-button", "Connect New Server")
799 .style(ButtonStyle::Filled)
800 .layer(ElevationIndex::ModalSurface)
801 .icon(IconName::Plus)
802 .icon_position(IconPosition::Start)
803 .icon_color(Color::Muted)
804 .on_click(cx.listener(|this, _, cx| {
805 this.mode = Mode::CreateDevServer(CreateDevServer {
806 ..Default::default()
807 });
808 this.dev_server_name_input.update(cx, |text_field, cx| {
809 text_field.editor().update(cx, |editor, cx| {
810 editor.set_text("", cx);
811 });
812 });
813 cx.notify();
814 })),
815 ),
816 ),
817 )
818 .section(
819 Section::new().padded(false).child(
820 div()
821 .border_y_1()
822 .border_color(cx.theme().colors().border_variant)
823 .w_full()
824 .child(
825 div().p_2().child(
826 List::new()
827 .empty_message("No dev servers registered yet.")
828 .children(ssh_connections.iter().cloned().enumerate().map(
829 |(ix, connection)| {
830 self.render_ssh_connection(ix, connection, cx)
831 .into_any_element()
832 },
833 )),
834 ),
835 ),
836 ),
837 )
838 .footer(
839 ModalFooter::new()
840 .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
841 )
842 }
843}
844
845fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
846 element
847 .read(cx)
848 .editor()
849 .read(cx)
850 .text(cx)
851 .trim()
852 .to_string()
853}
854
855impl ModalView for DevServerProjects {}
856
857impl FocusableView for DevServerProjects {
858 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
859 self.focus_handle.clone()
860 }
861}
862
863impl EventEmitter<DismissEvent> for DevServerProjects {}
864
865impl Render for DevServerProjects {
866 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
867 div()
868 .track_focus(&self.focus_handle)
869 .elevation_3(cx)
870 .key_context("DevServerModal")
871 .on_action(cx.listener(Self::cancel))
872 .on_action(cx.listener(Self::confirm))
873 .capture_any_mouse_down(cx.listener(|this, _, cx| {
874 this.focus_handle(cx).focus(cx);
875 }))
876 .on_mouse_down_out(cx.listener(|this, _, cx| {
877 if matches!(this.mode, Mode::Default(None)) {
878 cx.emit(DismissEvent)
879 }
880 }))
881 .w(rems(34.))
882 .max_h(rems(40.))
883 .child(match &self.mode {
884 Mode::Default(_) => self.render_default(cx).into_any_element(),
885 Mode::CreateDevServer(state) => {
886 self.render_create_dev_server(state, cx).into_any_element()
887 }
888 })
889 }
890}
891
892pub fn reconnect_to_dev_server_project(
893 workspace: View<Workspace>,
894 dev_server: DevServer,
895 dev_server_project_id: DevServerProjectId,
896 replace_current_window: bool,
897 cx: &mut WindowContext,
898) -> Task<Result<()>> {
899 let store = dev_server_projects::Store::global(cx);
900 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
901 cx.spawn(|mut cx| async move {
902 reconnect.await?;
903
904 cx.background_executor()
905 .timer(Duration::from_millis(1000))
906 .await;
907
908 if let Some(project_id) = store.update(&mut cx, |store, _| {
909 store
910 .dev_server_project(dev_server_project_id)
911 .and_then(|p| p.project_id)
912 })? {
913 workspace
914 .update(&mut cx, move |_, cx| {
915 open_dev_server_project(
916 replace_current_window,
917 dev_server_project_id,
918 project_id,
919 cx,
920 )
921 })?
922 .await?;
923 }
924
925 Ok(())
926 })
927}
928
929pub fn reconnect_to_dev_server(
930 workspace: View<Workspace>,
931 dev_server: DevServer,
932 cx: &mut WindowContext,
933) -> Task<Result<()>> {
934 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
935 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
936 };
937 let dev_server_store = dev_server_projects::Store::global(cx);
938 let get_access_token = dev_server_store.update(cx, |store, cx| {
939 store.regenerate_dev_server_token(dev_server.id, cx)
940 });
941
942 cx.spawn(|mut cx| async move {
943 let access_token = get_access_token.await?.access_token;
944
945 spawn_ssh_task(
946 workspace,
947 dev_server_store,
948 dev_server.id,
949 ssh_connection_string.to_string(),
950 access_token,
951 &mut cx,
952 )
953 .await
954 })
955}
956
957pub async fn spawn_ssh_task(
958 workspace: View<Workspace>,
959 dev_server_store: Model<dev_server_projects::Store>,
960 dev_server_id: DevServerId,
961 ssh_connection_string: String,
962 access_token: String,
963 cx: &mut AsyncWindowContext,
964) -> Result<()> {
965 let terminal_panel = workspace
966 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
967 .ok()
968 .flatten()
969 .with_context(|| anyhow!("No terminal panel"))?;
970
971 let command = "sh".to_string();
972 let args = vec![
973 "-x".to_string(),
974 "-c".to_string(),
975 format!(
976 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 {}"#,
977 access_token
978 ),
979 ];
980
981 let ssh_connection_string = ssh_connection_string.to_string();
982 let (command, args) = wrap_for_ssh(
983 &SshCommand::DevServer(ssh_connection_string.clone()),
984 Some((&command, &args)),
985 None,
986 HashMap::default(),
987 None,
988 );
989
990 let terminal = terminal_panel
991 .update(cx, |terminal_panel, cx| {
992 terminal_panel.spawn_in_new_terminal(
993 SpawnInTerminal {
994 id: task::TaskId("ssh-remote".into()),
995 full_label: "Install zed over ssh".into(),
996 label: "Install zed over ssh".into(),
997 command,
998 args,
999 command_label: ssh_connection_string.clone(),
1000 cwd: None,
1001 use_new_terminal: true,
1002 allow_concurrent_runs: false,
1003 reveal: RevealStrategy::Always,
1004 hide: HideStrategy::Never,
1005 env: Default::default(),
1006 shell: Default::default(),
1007 },
1008 cx,
1009 )
1010 })?
1011 .await?;
1012
1013 terminal
1014 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1015 .await;
1016
1017 // 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.
1018 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1019 == DevServerStatus::Offline
1020 {
1021 cx.background_executor()
1022 .timer(Duration::from_millis(200))
1023 .await
1024 }
1025
1026 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1027 == DevServerStatus::Offline
1028 {
1029 return Err(anyhow!("couldn't reconnect"))?;
1030 }
1031
1032 Ok(())
1033}