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 .mb_1()
560 .mx_1p5()
561 .pl_2()
562 .child(
563 List::new()
564 .empty_message("No projects.")
565 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
566 v_flex().gap_0p5().child(self.render_ssh_project(
567 ix,
568 &ssh_connection,
569 pix,
570 p,
571 cx,
572 ))
573 }))
574 .child(
575 h_flex().mt_1().pl_1().child(
576 Button::new("new-remote_project", "Open Folder…")
577 .size(ButtonSize::Default)
578 .layer(ElevationIndex::ModalSurface)
579 .icon(IconName::Plus)
580 .icon_color(Color::Muted)
581 .icon_position(IconPosition::Start)
582 .on_click(cx.listener(move |this, _, cx| {
583 this.create_ssh_project(ix, ssh_connection.clone(), cx);
584 })),
585 ),
586 ),
587 ),
588 )
589 }
590
591 fn render_ssh_project(
592 &self,
593 server_ix: usize,
594 server: &SshConnection,
595 ix: usize,
596 project: &SshProject,
597 cx: &ViewContext<Self>,
598 ) -> impl IntoElement {
599 let project = project.clone();
600 let server = server.clone();
601
602 ListItem::new(("remote-project", ix))
603 .inset(true)
604 .spacing(ui::ListItemSpacing::Sparse)
605 .start_slot(
606 Icon::new(IconName::Folder)
607 .color(Color::Muted)
608 .size(IconSize::Small),
609 )
610 .child(Label::new(project.paths.join(", ")))
611 .on_click(cx.listener(move |this, _, cx| {
612 let Some(app_state) = this
613 .workspace
614 .update(cx, |workspace, _| workspace.app_state().clone())
615 .log_err()
616 else {
617 return;
618 };
619 let project = project.clone();
620 let server = server.clone();
621 cx.spawn(|_, mut cx| async move {
622 let result = open_ssh_project(
623 server.into(),
624 project.paths.into_iter().map(PathBuf::from).collect(),
625 app_state,
626 OpenOptions::default(),
627 &mut cx,
628 )
629 .await;
630 if let Err(e) = result {
631 log::error!("Failed to connect: {:?}", e);
632 cx.prompt(
633 gpui::PromptLevel::Critical,
634 "Failed to connect",
635 Some(&e.to_string()),
636 &["Ok"],
637 )
638 .await
639 .ok();
640 }
641 })
642 .detach();
643 }))
644 .end_hover_slot::<AnyElement>(Some(
645 IconButton::new("remove-remote-project", IconName::TrashAlt)
646 .on_click(
647 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
648 )
649 .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
650 .into_any_element(),
651 ))
652 }
653
654 fn update_settings_file(
655 &mut self,
656 cx: &mut ViewContext<Self>,
657 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
658 ) {
659 let Some(fs) = self
660 .workspace
661 .update(cx, |workspace, _| workspace.app_state().fs.clone())
662 .log_err()
663 else {
664 return;
665 };
666 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
667 }
668
669 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
670 self.update_settings_file(cx, move |setting, _| {
671 if let Some(connections) = setting.ssh_connections.as_mut() {
672 connections.remove(server);
673 }
674 });
675 }
676
677 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
678 self.update_settings_file(cx, move |setting, _| {
679 if let Some(server) = setting
680 .ssh_connections
681 .as_mut()
682 .and_then(|connections| connections.get_mut(server))
683 {
684 server.projects.remove(project);
685 }
686 });
687 }
688
689 fn add_ssh_server(
690 &mut self,
691 connection_options: remote::SshConnectionOptions,
692 cx: &mut ViewContext<Self>,
693 ) {
694 self.update_settings_file(cx, move |setting, _| {
695 setting
696 .ssh_connections
697 .get_or_insert(Default::default())
698 .push(SshConnection {
699 host: connection_options.host,
700 username: connection_options.username,
701 port: connection_options.port,
702 projects: vec![],
703 })
704 });
705 }
706
707 fn render_create_dev_server(
708 &self,
709 state: &CreateDevServer,
710 cx: &mut ViewContext<Self>,
711 ) -> impl IntoElement {
712 let creating = state.creating.is_some();
713 let ssh_prompt = state.ssh_prompt.clone();
714
715 self.dev_server_name_input.update(cx, |input, cx| {
716 input.editor().update(cx, |editor, cx| {
717 if editor.text(cx).is_empty() {
718 editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx);
719 }
720 })
721 });
722 let theme = cx.theme();
723
724 v_flex()
725 .id("create-dev-server")
726 .overflow_hidden()
727 .size_full()
728 .flex_1()
729 .child(
730 h_flex()
731 .p_2()
732 .gap_2()
733 .items_center()
734 .border_b_1()
735 .border_color(theme.colors().border_variant)
736 .child(
737 IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft)
738 .shape(IconButtonShape::Square)
739 .on_click(|_, cx| {
740 cx.dispatch_action(menu::Cancel.boxed_clone());
741 }),
742 )
743 .child(Label::new("Connect New Dev Server")),
744 )
745 .child(
746 v_flex()
747 .p_3()
748 .border_b_1()
749 .border_color(theme.colors().border_variant)
750 .child(Label::new("SSH Arguments"))
751 .child(
752 Label::new("Enter the command you use to SSH into this server.")
753 .size(LabelSize::Small)
754 .color(Color::Muted),
755 )
756 .child(
757 h_flex()
758 .mt_2()
759 .w_full()
760 .gap_2()
761 .child(self.dev_server_name_input.clone())
762 .child(
763 Button::new("create-dev-server", "Connect Server")
764 .style(ButtonStyle::Filled)
765 .layer(ElevationIndex::ModalSurface)
766 .disabled(creating)
767 .on_click(cx.listener({
768 move |this, _, cx| {
769 this.create_ssh_server(cx);
770 }
771 })),
772 ),
773 ),
774 )
775 .child(
776 h_flex()
777 .bg(theme.colors().editor_background)
778 .rounded_b_md()
779 .w_full()
780 .map(|this| {
781 if let Some(ssh_prompt) = ssh_prompt {
782 this.child(h_flex().w_full().child(ssh_prompt))
783 } else {
784 let color = Color::Muted.color(cx);
785 this.child(
786 h_flex()
787 .p_2()
788 .w_full()
789 .justify_center()
790 .gap_1p5()
791 .child(
792 div().p_1().rounded_lg().bg(color).with_animation(
793 "pulse-ssh-waiting-for-connection",
794 Animation::new(Duration::from_secs(2))
795 .repeat()
796 .with_easing(pulsating_between(0.2, 0.5)),
797 move |this, progress| this.bg(color.opacity(progress)),
798 ),
799 )
800 .child(
801 Label::new("Waiting for connection…")
802 .size(LabelSize::Small),
803 ),
804 )
805 }
806 }),
807 )
808 }
809
810 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
811 let dev_servers = self.dev_server_store.read(cx).dev_servers();
812 let ssh_connections = SshSettings::get_global(cx)
813 .ssh_connections()
814 .collect::<Vec<_>>();
815
816 let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len());
817 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
818 .header(
819 ModalHeader::new().child(
820 h_flex()
821 .justify_between()
822 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
823 .child(
824 Button::new("register-dev-server-button", "Connect New Server")
825 .style(ButtonStyle::Filled)
826 .layer(ElevationIndex::ModalSurface)
827 .icon(IconName::Plus)
828 .icon_position(IconPosition::Start)
829 .icon_color(Color::Muted)
830 .on_click(cx.listener(|this, _, cx| {
831 this.mode = Mode::CreateDevServer(CreateDevServer {
832 ..Default::default()
833 });
834 this.dev_server_name_input.update(cx, |text_field, cx| {
835 text_field.editor().update(cx, |editor, cx| {
836 editor.set_text("", cx);
837 });
838 });
839 cx.notify();
840 })),
841 ),
842 ),
843 )
844 .section(
845 Section::new().padded(false).child(
846 div()
847 .border_y_1()
848 .border_color(cx.theme().colors().border_variant)
849 .w_full()
850 .child(
851 div().p_2().child(
852 List::new()
853 .empty_message("No dev servers registered yet.")
854 .children(ssh_connections.iter().cloned().enumerate().map(
855 |(ix, connection)| {
856 self.render_ssh_connection(ix, connection, cx)
857 .into_any_element()
858 },
859 )),
860 ),
861 ),
862 ),
863 )
864 .footer(
865 ModalFooter::new()
866 .start_slot(div().child(Label::new(footer).size(LabelSize::Small))),
867 )
868 }
869}
870
871fn get_text(element: &View<TextField>, cx: &mut WindowContext) -> String {
872 element
873 .read(cx)
874 .editor()
875 .read(cx)
876 .text(cx)
877 .trim()
878 .to_string()
879}
880
881impl ModalView for DevServerProjects {}
882
883impl FocusableView for DevServerProjects {
884 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
885 self.focus_handle.clone()
886 }
887}
888
889impl EventEmitter<DismissEvent> for DevServerProjects {}
890
891impl Render for DevServerProjects {
892 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
893 div()
894 .track_focus(&self.focus_handle)
895 .elevation_3(cx)
896 .key_context("DevServerModal")
897 .on_action(cx.listener(Self::cancel))
898 .on_action(cx.listener(Self::confirm))
899 .capture_any_mouse_down(cx.listener(|this, _, cx| {
900 this.focus_handle(cx).focus(cx);
901 }))
902 .on_mouse_down_out(cx.listener(|this, _, cx| {
903 if matches!(this.mode, Mode::Default(None)) {
904 cx.emit(DismissEvent)
905 }
906 }))
907 .w(rems(34.))
908 .max_h(rems(40.))
909 .child(match &self.mode {
910 Mode::Default(_) => self.render_default(cx).into_any_element(),
911 Mode::CreateDevServer(state) => {
912 self.render_create_dev_server(state, cx).into_any_element()
913 }
914 })
915 }
916}
917
918pub fn reconnect_to_dev_server_project(
919 workspace: View<Workspace>,
920 dev_server: DevServer,
921 dev_server_project_id: DevServerProjectId,
922 replace_current_window: bool,
923 cx: &mut WindowContext,
924) -> Task<Result<()>> {
925 let store = dev_server_projects::Store::global(cx);
926 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
927 cx.spawn(|mut cx| async move {
928 reconnect.await?;
929
930 cx.background_executor()
931 .timer(Duration::from_millis(1000))
932 .await;
933
934 if let Some(project_id) = store.update(&mut cx, |store, _| {
935 store
936 .dev_server_project(dev_server_project_id)
937 .and_then(|p| p.project_id)
938 })? {
939 workspace
940 .update(&mut cx, move |_, cx| {
941 open_dev_server_project(
942 replace_current_window,
943 dev_server_project_id,
944 project_id,
945 cx,
946 )
947 })?
948 .await?;
949 }
950
951 Ok(())
952 })
953}
954
955pub fn reconnect_to_dev_server(
956 workspace: View<Workspace>,
957 dev_server: DevServer,
958 cx: &mut WindowContext,
959) -> Task<Result<()>> {
960 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
961 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
962 };
963 let dev_server_store = dev_server_projects::Store::global(cx);
964 let get_access_token = dev_server_store.update(cx, |store, cx| {
965 store.regenerate_dev_server_token(dev_server.id, cx)
966 });
967
968 cx.spawn(|mut cx| async move {
969 let access_token = get_access_token.await?.access_token;
970
971 spawn_ssh_task(
972 workspace,
973 dev_server_store,
974 dev_server.id,
975 ssh_connection_string.to_string(),
976 access_token,
977 &mut cx,
978 )
979 .await
980 })
981}
982
983pub async fn spawn_ssh_task(
984 workspace: View<Workspace>,
985 dev_server_store: Model<dev_server_projects::Store>,
986 dev_server_id: DevServerId,
987 ssh_connection_string: String,
988 access_token: String,
989 cx: &mut AsyncWindowContext,
990) -> Result<()> {
991 let terminal_panel = workspace
992 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
993 .ok()
994 .flatten()
995 .with_context(|| anyhow!("No terminal panel"))?;
996
997 let command = "sh".to_string();
998 let args = vec![
999 "-x".to_string(),
1000 "-c".to_string(),
1001 format!(
1002 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 {}"#,
1003 access_token
1004 ),
1005 ];
1006
1007 let ssh_connection_string = ssh_connection_string.to_string();
1008 let (command, args) = wrap_for_ssh(
1009 &SshCommand::DevServer(ssh_connection_string.clone()),
1010 Some((&command, &args)),
1011 None,
1012 HashMap::default(),
1013 None,
1014 );
1015
1016 let terminal = terminal_panel
1017 .update(cx, |terminal_panel, cx| {
1018 terminal_panel.spawn_in_new_terminal(
1019 SpawnInTerminal {
1020 id: task::TaskId("ssh-remote".into()),
1021 full_label: "Install zed over ssh".into(),
1022 label: "Install zed over ssh".into(),
1023 command,
1024 args,
1025 command_label: ssh_connection_string.clone(),
1026 cwd: None,
1027 use_new_terminal: true,
1028 allow_concurrent_runs: false,
1029 reveal: RevealStrategy::Always,
1030 hide: HideStrategy::Never,
1031 env: Default::default(),
1032 shell: Default::default(),
1033 },
1034 cx,
1035 )
1036 })?
1037 .await?;
1038
1039 terminal
1040 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1041 .await;
1042
1043 // 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.
1044 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1045 == DevServerStatus::Offline
1046 {
1047 cx.background_executor()
1048 .timer(Duration::from_millis(200))
1049 .await
1050 }
1051
1052 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1053 == DevServerStatus::Offline
1054 {
1055 return Err(anyhow!("couldn't reconnect"))?;
1056 }
1057
1058 Ok(())
1059}