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 let _ = this.workspace.update(cx, |workspace, _| {
283 workspace
284 .client()
285 .telemetry()
286 .report_app_event("create ssh server".to_string())
287 });
288
289 this.add_ssh_server(connection_options, cx);
290 this.mode = Mode::Default(None);
291 cx.notify()
292 })
293 .log_err(),
294 None => this
295 .update(&mut cx, |this, cx| {
296 this.mode = Mode::CreateDevServer(CreateDevServer::default());
297 cx.notify()
298 })
299 .log_err(),
300 };
301 None
302 });
303 self.mode = Mode::CreateDevServer(CreateDevServer {
304 ssh_prompt: Some(ssh_prompt.clone()),
305 creating: Some(creating),
306 });
307 }
308
309 fn create_ssh_project(
310 &mut self,
311 ix: usize,
312 ssh_connection: SshConnection,
313 cx: &mut ViewContext<Self>,
314 ) {
315 let Some(workspace) = self.workspace.upgrade() else {
316 return;
317 };
318
319 let connection_options = ssh_connection.into();
320 workspace.update(cx, |_, cx| {
321 cx.defer(move |workspace, cx| {
322 workspace.toggle_modal(cx, |cx| SshConnectionModal::new(&connection_options, cx));
323 let prompt = workspace
324 .active_modal::<SshConnectionModal>(cx)
325 .unwrap()
326 .read(cx)
327 .prompt
328 .clone();
329
330 let connect = connect_over_ssh(
331 connection_options.dev_server_identifier(),
332 connection_options,
333 prompt,
334 cx,
335 )
336 .prompt_err("Failed to connect", cx, |_, _| None);
337 cx.spawn(|workspace, mut cx| async move {
338 let Some(session) = connect.await else {
339 workspace
340 .update(&mut cx, |workspace, cx| {
341 let weak = cx.view().downgrade();
342 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
343 })
344 .log_err();
345 return;
346 };
347 let Ok((app_state, project, paths)) =
348 workspace.update(&mut cx, |workspace, cx| {
349 let app_state = workspace.app_state().clone();
350 let project = project::Project::ssh(
351 session,
352 app_state.client.clone(),
353 app_state.node_runtime.clone(),
354 app_state.user_store.clone(),
355 app_state.languages.clone(),
356 app_state.fs.clone(),
357 cx,
358 );
359 let paths = workspace.prompt_for_open_path(
360 PathPromptOptions {
361 files: true,
362 directories: true,
363 multiple: true,
364 },
365 project::DirectoryLister::Project(project.clone()),
366 cx,
367 );
368 (app_state, project, paths)
369 })
370 else {
371 return;
372 };
373
374 let Ok(Some(paths)) = paths.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
384 let Some(options) = cx
385 .update(|cx| (app_state.build_window_options)(None, cx))
386 .log_err()
387 else {
388 return;
389 };
390
391 cx.open_window(options, |cx| {
392 cx.activate_window();
393
394 let fs = app_state.fs.clone();
395 update_settings_file::<SshSettings>(fs, cx, {
396 let paths = paths
397 .iter()
398 .map(|path| path.to_string_lossy().to_string())
399 .collect();
400 move |setting, _| {
401 if let Some(server) = setting
402 .ssh_connections
403 .as_mut()
404 .and_then(|connections| connections.get_mut(ix))
405 {
406 server.projects.push(SshProject { paths })
407 }
408 }
409 });
410
411 let tasks = paths
412 .into_iter()
413 .map(|path| {
414 project.update(cx, |project, cx| {
415 project.find_or_create_worktree(&path, true, cx)
416 })
417 })
418 .collect::<Vec<_>>();
419 cx.spawn(|_| async move {
420 for task in tasks {
421 task.await?;
422 }
423 Ok(())
424 })
425 .detach_and_prompt_err(
426 "Failed to open path",
427 cx,
428 |_, _| None,
429 );
430
431 cx.new_view(|cx| {
432 let workspace =
433 Workspace::new(None, project.clone(), app_state.clone(), cx);
434
435 workspace
436 .client()
437 .telemetry()
438 .report_app_event("create ssh project".to_string());
439
440 workspace
441 })
442 })
443 .log_err();
444 })
445 .detach()
446 })
447 })
448 }
449
450 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
451 match &self.mode {
452 Mode::Default(None) => {}
453 Mode::Default(Some(create_project)) => {
454 self.create_dev_server_project(create_project.dev_server_id, cx);
455 }
456 Mode::CreateDevServer(state) => {
457 if let Some(prompt) = state.ssh_prompt.as_ref() {
458 prompt.update(cx, |prompt, cx| {
459 prompt.confirm(cx);
460 });
461 return;
462 }
463
464 self.create_ssh_server(cx);
465 }
466 }
467 }
468
469 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
470 match &self.mode {
471 Mode::Default(None) => cx.emit(DismissEvent),
472 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
473 self.mode = Mode::CreateDevServer(CreateDevServer {
474 ..Default::default()
475 });
476 cx.notify();
477 }
478 _ => {
479 self.mode = Mode::Default(None);
480 self.focus_handle(cx).focus(cx);
481 cx.notify();
482 }
483 }
484 }
485
486 fn render_ssh_connection(
487 &mut self,
488 ix: usize,
489 ssh_connection: SshConnection,
490 cx: &mut ViewContext<Self>,
491 ) -> impl IntoElement {
492 v_flex()
493 .w_full()
494 .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx))
495 .child(
496 h_flex()
497 .w_full()
498 .group("ssh-server")
499 .justify_between()
500 .child(
501 h_flex()
502 .gap_2()
503 .w_full()
504 .child(
505 div()
506 .id(("status", ix))
507 .relative()
508 .child(Icon::new(IconName::Server).size(IconSize::Small)),
509 )
510 .child(
511 h_flex()
512 .max_w(rems(26.))
513 .overflow_hidden()
514 .whitespace_nowrap()
515 .child(Label::new(ssh_connection.host.clone())),
516 ),
517 )
518 .child(
519 h_flex()
520 .visible_on_hover("ssh-server")
521 .gap_1()
522 .child({
523 IconButton::new("copy-dev-server-address", IconName::Copy)
524 .icon_size(IconSize::Small)
525 .on_click(cx.listener(move |this, _, cx| {
526 this.update_settings_file(cx, move |servers, cx| {
527 if let Some(content) = servers
528 .ssh_connections
529 .as_ref()
530 .and_then(|connections| {
531 connections
532 .get(ix)
533 .map(|connection| connection.host.clone())
534 })
535 {
536 cx.write_to_clipboard(ClipboardItem::new_string(
537 content,
538 ));
539 }
540 });
541 }))
542 .tooltip(|cx| Tooltip::text("Copy Server Address", cx))
543 })
544 .child({
545 IconButton::new("remove-dev-server", IconName::TrashAlt)
546 .icon_size(IconSize::Small)
547 .on_click(cx.listener(move |this, _, cx| {
548 this.delete_ssh_server(ix, cx)
549 }))
550 .tooltip(|cx| Tooltip::text("Remove Dev Server", cx))
551 }),
552 ),
553 )
554 .child(
555 v_flex()
556 .w_full()
557 .border_l_1()
558 .border_color(cx.theme().colors().border_variant)
559 .my_1()
560 .mx_1p5()
561 .py_0p5()
562 .px_3()
563 .child(
564 List::new()
565 .empty_message("No projects.")
566 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
567 self.render_ssh_project(ix, &ssh_connection, pix, p, cx)
568 }))
569 .child(
570 h_flex().child(
571 Button::new("new-remote_project", "Open Folder…")
572 .icon(IconName::Plus)
573 .size(ButtonSize::Default)
574 .style(ButtonStyle::Filled)
575 .layer(ElevationIndex::ModalSurface)
576 .icon_position(IconPosition::Start)
577 .on_click(cx.listener(move |this, _, cx| {
578 this.create_ssh_project(ix, ssh_connection.clone(), cx);
579 })),
580 ),
581 ),
582 ),
583 )
584 }
585
586 fn render_ssh_project(
587 &self,
588 server_ix: usize,
589 server: &SshConnection,
590 ix: usize,
591 project: &SshProject,
592 cx: &ViewContext<Self>,
593 ) -> impl IntoElement {
594 let project = project.clone();
595 let server = server.clone();
596 ListItem::new(("remote-project", ix))
597 .spacing(ui::ListItemSpacing::Sparse)
598 .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
599 .child(Label::new(project.paths.join(", ")))
600 .on_click(cx.listener(move |this, _, cx| {
601 let Some(app_state) = this
602 .workspace
603 .update(cx, |workspace, _| workspace.app_state().clone())
604 .log_err()
605 else {
606 return;
607 };
608 let project = project.clone();
609 let server = server.clone();
610 cx.spawn(|_, mut cx| async move {
611 let result = open_ssh_project(
612 server.into(),
613 project.paths.into_iter().map(PathBuf::from).collect(),
614 app_state,
615 OpenOptions::default(),
616 &mut cx,
617 )
618 .await;
619 if let Err(e) = result {
620 log::error!("Failed to connect: {:?}", e);
621 cx.prompt(
622 gpui::PromptLevel::Critical,
623 "Failed to connect",
624 Some(&e.to_string()),
625 &["Ok"],
626 )
627 .await
628 .ok();
629 }
630 })
631 .detach();
632 }))
633 .end_hover_slot::<AnyElement>(Some(
634 IconButton::new("remove-remote-project", IconName::TrashAlt)
635 .on_click(
636 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
637 )
638 .tooltip(|cx| Tooltip::text("Delete remote project", cx))
639 .into_any_element(),
640 ))
641 }
642
643 fn update_settings_file(
644 &mut self,
645 cx: &mut ViewContext<Self>,
646 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
647 ) {
648 let Some(fs) = self
649 .workspace
650 .update(cx, |workspace, _| workspace.app_state().fs.clone())
651 .log_err()
652 else {
653 return;
654 };
655 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
656 }
657
658 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
659 self.update_settings_file(cx, move |setting, _| {
660 if let Some(connections) = setting.ssh_connections.as_mut() {
661 connections.remove(server);
662 }
663 });
664 }
665
666 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
667 self.update_settings_file(cx, move |setting, _| {
668 if let Some(server) = setting
669 .ssh_connections
670 .as_mut()
671 .and_then(|connections| connections.get_mut(server))
672 {
673 server.projects.remove(project);
674 }
675 });
676 }
677
678 fn add_ssh_server(
679 &mut self,
680 connection_options: remote::SshConnectionOptions,
681 cx: &mut ViewContext<Self>,
682 ) {
683 self.update_settings_file(cx, move |setting, _| {
684 setting
685 .ssh_connections
686 .get_or_insert(Default::default())
687 .push(SshConnection {
688 host: connection_options.host,
689 username: connection_options.username,
690 port: connection_options.port,
691 projects: vec![],
692 })
693 });
694 }
695
696 fn render_create_dev_server(
697 &self,
698 state: &CreateDevServer,
699 cx: &mut ViewContext<Self>,
700 ) -> impl IntoElement {
701 let creating = state.creating.is_some();
702 let ssh_prompt = state.ssh_prompt.clone();
703
704 self.dev_server_name_input.update(cx, |input, cx| {
705 input.editor().update(cx, |editor, cx| {
706 if editor.text(cx).is_empty() {
707 editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
708 }
709 })
710 });
711 let theme = cx.theme();
712 v_flex()
713 .id("create-dev-server")
714 .overflow_hidden()
715 .size_full()
716 .flex_1()
717 .child(
718 h_flex()
719 .p_2()
720 .gap_2()
721 .items_center()
722 .border_b_1()
723 .border_color(theme.colors().border_variant)
724 .child(
725 IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
726 .shape(IconButtonShape::Square)
727 .on_click(|_, cx| {
728 cx.dispatch_action(menu::Cancel.boxed_clone());
729 }),
730 )
731 .child(Label::new("Connect New Dev Server")),
732 )
733 .child(
734 v_flex()
735 .p_3()
736 .border_b_1()
737 .border_color(theme.colors().border_variant)
738 .child(Label::new("SSH Arguments"))
739 .child(
740 Label::new("Enter the command you use to SSH into this server.")
741 .size(LabelSize::Small)
742 .color(Color::Muted),
743 )
744 .child(
745 h_flex()
746 .mt_2()
747 .w_full()
748 .gap_2()
749 .child(self.dev_server_name_input.clone())
750 .child(
751 Button::new("create-dev-server", "Connect Server")
752 .style(ButtonStyle::Filled)
753 .layer(ElevationIndex::ModalSurface)
754 .disabled(creating)
755 .on_click(cx.listener({
756 move |this, _, cx| {
757 this.create_ssh_server(cx);
758 }
759 })),
760 ),
761 ),
762 )
763 .child(
764 h_flex()
765 .bg(theme.colors().editor_background)
766 .w_full()
767 .map(|this| {
768 if let Some(ssh_prompt) = ssh_prompt {
769 this.child(h_flex().w_full().child(ssh_prompt))
770 } else {
771 let color = Color::Muted.color(cx);
772 this.child(
773 h_flex()
774 .p_2()
775 .w_full()
776 .content_center()
777 .gap_2()
778 .child(h_flex().w_full())
779 .child(
780 div().p_1().rounded_lg().bg(color).with_animation(
781 "pulse-ssh-waiting-for-connection",
782 Animation::new(Duration::from_secs(2))
783 .repeat()
784 .with_easing(pulsating_between(0.2, 0.5)),
785 move |this, progress| this.bg(color.opacity(progress)),
786 ),
787 )
788 .child(
789 Label::new("Waiting for connection…")
790 .size(LabelSize::Small),
791 )
792 .child(h_flex().w_full()),
793 )
794 }
795 }),
796 )
797 }
798
799 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
800 let dev_servers = self.dev_server_store.read(cx).dev_servers();
801 let ssh_connections = SshSettings::get_global(cx)
802 .ssh_connections()
803 .collect::<Vec<_>>();
804
805 let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
806 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
807 .header(
808 ModalHeader::new().child(
809 h_flex()
810 .justify_between()
811 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
812 .child(
813 Button::new("register-dev-server-button", "Connect New Server")
814 .style(ButtonStyle::Filled)
815 .layer(ElevationIndex::ModalSurface)
816 .icon(IconName::Plus)
817 .icon_position(IconPosition::Start)
818 .icon_color(Color::Muted)
819 .on_click(cx.listener(|this, _, cx| {
820 this.mode = Mode::CreateDevServer(CreateDevServer {
821 ..Default::default()
822 });
823 this.dev_server_name_input.update(cx, |text_field, cx| {
824 text_field.editor().update(cx, |editor, cx| {
825 editor.set_text("", cx);
826 });
827 });
828 cx.notify();
829 })),
830 ),
831 ),
832 )
833 .section(
834 Section::new().padded(false).child(
835 div()
836 .border_y_1()
837 .border_color(cx.theme().colors().border_variant)
838 .w_full()
839 .child(
840 div().p_2().child(
841 List::new()
842 .empty_message("No dev servers registered yet.")
843 .children(ssh_connections.iter().cloned().enumerate().map(
844 |(ix, connection)| {
845 self.render_ssh_connection(ix, connection, cx)
846 .into_any_element()
847 },
848 )),
849 ),
850 ),
851 ),
852 )
853 .footer(
854 ModalFooter::new()
855 .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
856 )
857 }
858}
859
860fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
861 element
862 .read(cx)
863 .editor()
864 .read(cx)
865 .text(cx)
866 .trim()
867 .to_string()
868}
869
870impl ModalView for DevServerProjects {}
871
872impl FocusableView for DevServerProjects {
873 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
874 self.focus_handle.clone()
875 }
876}
877
878impl EventEmitter<DismissEvent> for DevServerProjects {}
879
880impl Render for DevServerProjects {
881 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
882 div()
883 .track_focus(&self.focus_handle)
884 .elevation_3(cx)
885 .key_context("DevServerModal")
886 .on_action(cx.listener(Self::cancel))
887 .on_action(cx.listener(Self::confirm))
888 .capture_any_mouse_down(cx.listener(|this, _, cx| {
889 this.focus_handle(cx).focus(cx);
890 }))
891 .on_mouse_down_out(cx.listener(|this, _, cx| {
892 if matches!(this.mode, Mode::Default(None)) {
893 cx.emit(DismissEvent)
894 }
895 }))
896 .w(rems(34.))
897 .max_h(rems(40.))
898 .child(match &self.mode {
899 Mode::Default(_) => self.render_default(cx).into_any_element(),
900 Mode::CreateDevServer(state) => {
901 self.render_create_dev_server(state, cx).into_any_element()
902 }
903 })
904 }
905}
906
907pub fn reconnect_to_dev_server_project(
908 workspace: View<Workspace>,
909 dev_server: DevServer,
910 dev_server_project_id: DevServerProjectId,
911 replace_current_window: bool,
912 cx: &mut WindowContext,
913) -> Task<Result<()>> {
914 let store = dev_server_projects::Store::global(cx);
915 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
916 cx.spawn(|mut cx| async move {
917 reconnect.await?;
918
919 cx.background_executor()
920 .timer(Duration::from_millis(1000))
921 .await;
922
923 if let Some(project_id) = store.update(&mut cx, |store, _| {
924 store
925 .dev_server_project(dev_server_project_id)
926 .and_then(|p| p.project_id)
927 })? {
928 workspace
929 .update(&mut cx, move |_, cx| {
930 open_dev_server_project(
931 replace_current_window,
932 dev_server_project_id,
933 project_id,
934 cx,
935 )
936 })?
937 .await?;
938 }
939
940 Ok(())
941 })
942}
943
944pub fn reconnect_to_dev_server(
945 workspace: View<Workspace>,
946 dev_server: DevServer,
947 cx: &mut WindowContext,
948) -> Task<Result<()>> {
949 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
950 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
951 };
952 let dev_server_store = dev_server_projects::Store::global(cx);
953 let get_access_token = dev_server_store.update(cx, |store, cx| {
954 store.regenerate_dev_server_token(dev_server.id, cx)
955 });
956
957 cx.spawn(|mut cx| async move {
958 let access_token = get_access_token.await?.access_token;
959
960 spawn_ssh_task(
961 workspace,
962 dev_server_store,
963 dev_server.id,
964 ssh_connection_string.to_string(),
965 access_token,
966 &mut cx,
967 )
968 .await
969 })
970}
971
972pub async fn spawn_ssh_task(
973 workspace: View<Workspace>,
974 dev_server_store: Model<dev_server_projects::Store>,
975 dev_server_id: DevServerId,
976 ssh_connection_string: String,
977 access_token: String,
978 cx: &mut AsyncWindowContext,
979) -> Result<()> {
980 let terminal_panel = workspace
981 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
982 .ok()
983 .flatten()
984 .with_context(|| anyhow!("No terminal panel"))?;
985
986 let command = "sh".to_string();
987 let args = vec![
988 "-x".to_string(),
989 "-c".to_string(),
990 format!(
991 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 {}"#,
992 access_token
993 ),
994 ];
995
996 let ssh_connection_string = ssh_connection_string.to_string();
997 let (command, args) = wrap_for_ssh(
998 &SshCommand::DevServer(ssh_connection_string.clone()),
999 Some((&command, &args)),
1000 None,
1001 HashMap::default(),
1002 None,
1003 );
1004
1005 let terminal = terminal_panel
1006 .update(cx, |terminal_panel, cx| {
1007 terminal_panel.spawn_in_new_terminal(
1008 SpawnInTerminal {
1009 id: task::TaskId("ssh-remote".into()),
1010 full_label: "Install zed over ssh".into(),
1011 label: "Install zed over ssh".into(),
1012 command,
1013 args,
1014 command_label: ssh_connection_string.clone(),
1015 cwd: None,
1016 use_new_terminal: true,
1017 allow_concurrent_runs: false,
1018 reveal: RevealStrategy::Always,
1019 hide: HideStrategy::Never,
1020 env: Default::default(),
1021 shell: Default::default(),
1022 },
1023 cx,
1024 )
1025 })?
1026 .await?;
1027
1028 terminal
1029 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1030 .await;
1031
1032 // 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.
1033 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1034 == DevServerStatus::Offline
1035 {
1036 cx.background_executor()
1037 .timer(Duration::from_millis(200))
1038 .await
1039 }
1040
1041 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1042 == DevServerStatus::Offline
1043 {
1044 return Err(anyhow!("couldn't reconnect"))?;
1045 }
1046
1047 Ok(())
1048}