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