1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::anyhow;
7use anyhow::Context;
8use anyhow::Result;
9use dev_server_projects::{DevServer, DevServerId, DevServerProjectId};
10use editor::Editor;
11use file_finder::OpenPathDelegate;
12use futures::channel::oneshot;
13use futures::future::Shared;
14use futures::FutureExt;
15use gpui::canvas;
16use gpui::pulsating_between;
17use gpui::AsyncWindowContext;
18use gpui::ClipboardItem;
19use gpui::Subscription;
20use gpui::Task;
21use gpui::WeakView;
22use gpui::{
23 Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle,
24 FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext,
25};
26use picker::Picker;
27use project::terminals::wrap_for_ssh;
28use project::terminals::SshCommand;
29use project::Project;
30use rpc::proto::DevServerStatus;
31use settings::update_settings_file;
32use settings::Settings;
33use task::HideStrategy;
34use task::RevealStrategy;
35use task::SpawnInTerminal;
36use terminal_view::terminal_panel::TerminalPanel;
37use ui::Section;
38use ui::{prelude::*, IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip};
39use util::ResultExt;
40use workspace::notifications::NotificationId;
41use workspace::OpenOptions;
42use workspace::Toast;
43use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace};
44
45use crate::open_dev_server_project;
46use crate::ssh_connections::connect_over_ssh;
47use crate::ssh_connections::open_ssh_project;
48use crate::ssh_connections::RemoteSettingsContent;
49use crate::ssh_connections::SshConnection;
50use crate::ssh_connections::SshConnectionHeader;
51use crate::ssh_connections::SshConnectionModal;
52use crate::ssh_connections::SshProject;
53use crate::ssh_connections::SshPrompt;
54use crate::ssh_connections::SshSettings;
55use crate::OpenRemote;
56
57pub struct DevServerProjects {
58 mode: Mode,
59 focus_handle: FocusHandle,
60 scroll_handle: ScrollHandle,
61 dev_server_store: Model<dev_server_projects::Store>,
62 workspace: WeakView<Workspace>,
63 _dev_server_subscription: Subscription,
64 selectable_items: SelectableItemList,
65}
66
67struct CreateDevServer {
68 address_editor: View<Editor>,
69 creating: Option<Task<Option<()>>>,
70 ssh_prompt: Option<View<SshPrompt>>,
71}
72
73impl CreateDevServer {
74 fn new(cx: &mut WindowContext<'_>) -> Self {
75 let address_editor = cx.new_view(Editor::single_line);
76 address_editor.update(cx, |this, cx| {
77 this.focus_handle(cx).focus(cx);
78 });
79 Self {
80 address_editor,
81 creating: None,
82 ssh_prompt: None,
83 }
84 }
85}
86
87struct ProjectPicker {
88 connection_string: SharedString,
89 picker: View<Picker<OpenPathDelegate>>,
90 _path_task: Shared<Task<Option<()>>>,
91}
92
93type SelectedItemCallback =
94 Box<dyn Fn(&mut DevServerProjects, &mut ViewContext<DevServerProjects>) + 'static>;
95
96/// Used to implement keyboard navigation for SSH modal.
97#[derive(Default)]
98struct SelectableItemList {
99 items: Vec<SelectedItemCallback>,
100 active_item: Option<usize>,
101}
102
103struct EditNicknameState {
104 index: usize,
105 editor: View<Editor>,
106}
107
108impl EditNicknameState {
109 fn new(index: usize, cx: &mut WindowContext<'_>) -> Self {
110 let this = Self {
111 index,
112 editor: cx.new_view(Editor::single_line),
113 };
114 let starting_text = SshSettings::get_global(cx)
115 .ssh_connections()
116 .nth(index)
117 .and_then(|state| state.nickname.clone())
118 .filter(|text| !text.is_empty());
119 this.editor.update(cx, |this, cx| {
120 this.set_placeholder_text("Add a nickname for this server", cx);
121 if let Some(starting_text) = starting_text {
122 this.set_text(starting_text, cx);
123 }
124 });
125 this.editor.focus_handle(cx).focus(cx);
126 this
127 }
128}
129
130impl SelectableItemList {
131 fn reset(&mut self) {
132 self.items.clear();
133 }
134
135 fn reset_selection(&mut self) {
136 self.active_item.take();
137 }
138
139 fn prev(&mut self, _: &mut WindowContext<'_>) {
140 match self.active_item.as_mut() {
141 Some(active_index) => {
142 *active_index = active_index.checked_sub(1).unwrap_or(self.items.len() - 1)
143 }
144 None => {
145 self.active_item = Some(self.items.len() - 1);
146 }
147 }
148 }
149
150 fn next(&mut self, _: &mut WindowContext<'_>) {
151 match self.active_item.as_mut() {
152 Some(active_index) => {
153 if *active_index + 1 < self.items.len() {
154 *active_index += 1;
155 } else {
156 *active_index = 0;
157 }
158 }
159 None => {
160 self.active_item = Some(0);
161 }
162 }
163 }
164
165 fn add_item(&mut self, callback: SelectedItemCallback) {
166 self.items.push(callback)
167 }
168
169 fn is_selected(&self) -> bool {
170 self.active_item == self.items.len().checked_sub(1)
171 }
172
173 fn confirm(&self, dev_modal: &mut DevServerProjects, cx: &mut ViewContext<DevServerProjects>) {
174 if let Some(active_item) = self.active_item.and_then(|ix| self.items.get(ix)) {
175 active_item(dev_modal, cx);
176 }
177 }
178}
179
180impl ProjectPicker {
181 fn new(
182 ix: usize,
183 connection_string: SharedString,
184 project: Model<Project>,
185 workspace: WeakView<Workspace>,
186 cx: &mut ViewContext<DevServerProjects>,
187 ) -> View<Self> {
188 let (tx, rx) = oneshot::channel();
189 let lister = project::DirectoryLister::Project(project.clone());
190 let query = lister.default_query(cx);
191 let delegate = file_finder::OpenPathDelegate::new(tx, lister);
192
193 let picker = cx.new_view(|cx| {
194 let picker = Picker::uniform_list(delegate, cx)
195 .width(rems(34.))
196 .modal(false);
197 picker.set_query(query, cx);
198 picker
199 });
200 cx.new_view(|cx| {
201 let _path_task = cx
202 .spawn({
203 let workspace = workspace.clone();
204 move |_, mut cx| async move {
205 let Ok(Some(paths)) = rx.await else {
206 workspace
207 .update(&mut cx, |workspace, cx| {
208 let weak = cx.view().downgrade();
209 workspace
210 .toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
211 })
212 .log_err()?;
213 return None;
214 };
215
216 let app_state = workspace
217 .update(&mut cx, |workspace, _| workspace.app_state().clone())
218 .ok()?;
219 let options = cx
220 .update(|cx| (app_state.build_window_options)(None, cx))
221 .log_err()?;
222
223 cx.open_window(options, |cx| {
224 cx.activate_window();
225
226 let fs = app_state.fs.clone();
227 update_settings_file::<SshSettings>(fs, cx, {
228 let paths = paths
229 .iter()
230 .map(|path| path.to_string_lossy().to_string())
231 .collect();
232 move |setting, _| {
233 if let Some(server) = setting
234 .ssh_connections
235 .as_mut()
236 .and_then(|connections| connections.get_mut(ix))
237 {
238 server.projects.push(SshProject { paths })
239 }
240 }
241 });
242
243 let tasks = paths
244 .into_iter()
245 .map(|path| {
246 project.update(cx, |project, cx| {
247 project.find_or_create_worktree(&path, true, cx)
248 })
249 })
250 .collect::<Vec<_>>();
251 cx.spawn(|_| async move {
252 for task in tasks {
253 task.await?;
254 }
255 Ok(())
256 })
257 .detach_and_prompt_err(
258 "Failed to open path",
259 cx,
260 |_, _| None,
261 );
262
263 cx.new_view(|cx| {
264 let workspace =
265 Workspace::new(None, project.clone(), app_state.clone(), cx);
266
267 workspace
268 .client()
269 .telemetry()
270 .report_app_event("create ssh project".to_string());
271
272 workspace
273 })
274 })
275 .log_err();
276 Some(())
277 }
278 })
279 .shared();
280
281 Self {
282 _path_task,
283 picker,
284 connection_string,
285 }
286 })
287 }
288}
289
290impl gpui::Render for ProjectPicker {
291 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
292 v_flex()
293 .child(
294 SshConnectionHeader {
295 connection_string: self.connection_string.clone(),
296 nickname: None,
297 }
298 .render(cx),
299 )
300 .child(self.picker.clone())
301 }
302}
303enum Mode {
304 Default,
305 ViewServerOptions(usize, SshConnection),
306 EditNickname(EditNicknameState),
307 ProjectPicker(View<ProjectPicker>),
308 CreateDevServer(CreateDevServer),
309}
310
311impl DevServerProjects {
312 pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
313 workspace.register_action(|workspace, _: &OpenRemote, cx| {
314 let handle = cx.view().downgrade();
315 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
316 });
317 }
318
319 pub fn open(workspace: View<Workspace>, cx: &mut WindowContext) {
320 workspace.update(cx, |workspace, cx| {
321 let handle = cx.view().downgrade();
322 workspace.toggle_modal(cx, |cx| Self::new(cx, handle))
323 })
324 }
325
326 pub fn new(cx: &mut ViewContext<Self>, workspace: WeakView<Workspace>) -> Self {
327 let focus_handle = cx.focus_handle();
328 let dev_server_store = dev_server_projects::Store::global(cx);
329
330 let subscription = cx.observe(&dev_server_store, |_, _, cx| {
331 cx.notify();
332 });
333
334 let mut base_style = cx.text_style();
335 base_style.refine(&gpui::TextStyleRefinement {
336 color: Some(cx.theme().colors().editor_foreground),
337 ..Default::default()
338 });
339
340 Self {
341 mode: Mode::Default,
342 focus_handle,
343 scroll_handle: ScrollHandle::new(),
344 dev_server_store,
345 workspace,
346 _dev_server_subscription: subscription,
347 selectable_items: Default::default(),
348 }
349 }
350
351 fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext<Self>) {
352 if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
353 return;
354 }
355 self.selectable_items.next(cx);
356 }
357 fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext<Self>) {
358 if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) {
359 return;
360 }
361 self.selectable_items.prev(cx);
362 }
363 pub fn project_picker(
364 ix: usize,
365 connection_options: remote::SshConnectionOptions,
366 project: Model<Project>,
367 cx: &mut ViewContext<Self>,
368 workspace: WeakView<Workspace>,
369 ) -> Self {
370 let mut this = Self::new(cx, workspace.clone());
371 this.mode = Mode::ProjectPicker(ProjectPicker::new(
372 ix,
373 connection_options.connection_string().into(),
374 project,
375 workspace,
376 cx,
377 ));
378
379 this
380 }
381
382 fn create_ssh_server(&mut self, editor: View<Editor>, cx: &mut ViewContext<Self>) {
383 let host = get_text(&editor, cx);
384 if host.is_empty() {
385 return;
386 }
387
388 let mut host = host.trim_start_matches("ssh ");
389 let mut username: Option<String> = None;
390 let mut port: Option<u16> = None;
391
392 if let Some((u, rest)) = host.split_once('@') {
393 host = rest;
394 username = Some(u.to_string());
395 }
396 if let Some((rest, p)) = host.split_once(':') {
397 host = rest;
398 port = p.parse().ok()
399 }
400
401 if let Some((rest, p)) = host.split_once(" -p") {
402 host = rest;
403 port = p.trim().parse().ok()
404 }
405
406 let connection_options = remote::SshConnectionOptions {
407 host: host.to_string(),
408 username: username.clone(),
409 port,
410 password: None,
411 };
412 let ssh_prompt = cx.new_view(|cx| SshPrompt::new(&connection_options, cx));
413
414 let connection = connect_over_ssh(
415 connection_options.dev_server_identifier(),
416 connection_options.clone(),
417 ssh_prompt.clone(),
418 cx,
419 )
420 .prompt_err("Failed to connect", cx, |_, _| None);
421
422 let creating = cx.spawn(move |this, mut cx| async move {
423 match connection.await {
424 Some(_) => this
425 .update(&mut cx, |this, cx| {
426 let _ = this.workspace.update(cx, |workspace, _| {
427 workspace
428 .client()
429 .telemetry()
430 .report_app_event("create ssh server".to_string())
431 });
432
433 this.add_ssh_server(connection_options, cx);
434 this.mode = Mode::Default;
435 this.selectable_items.reset_selection();
436 cx.notify()
437 })
438 .log_err(),
439 None => this
440 .update(&mut cx, |this, cx| {
441 this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
442 cx.notify()
443 })
444 .log_err(),
445 };
446 None
447 });
448 let mut state = CreateDevServer::new(cx);
449 state.address_editor = editor;
450 state.ssh_prompt = Some(ssh_prompt.clone());
451 state.creating = Some(creating);
452 self.mode = Mode::CreateDevServer(state);
453 }
454
455 fn view_server_options(
456 &mut self,
457 (index, connection): (usize, SshConnection),
458 cx: &mut ViewContext<Self>,
459 ) {
460 self.selectable_items.reset_selection();
461 self.mode = Mode::ViewServerOptions(index, connection);
462 cx.notify();
463 }
464
465 fn create_ssh_project(
466 &mut self,
467 ix: usize,
468 ssh_connection: SshConnection,
469 cx: &mut ViewContext<Self>,
470 ) {
471 let Some(workspace) = self.workspace.upgrade() else {
472 return;
473 };
474
475 let connection_options = ssh_connection.into();
476 workspace.update(cx, |_, cx| {
477 cx.defer(move |workspace, cx| {
478 workspace.toggle_modal(cx, |cx| {
479 SshConnectionModal::new(&connection_options, false, cx)
480 });
481 let prompt = workspace
482 .active_modal::<SshConnectionModal>(cx)
483 .unwrap()
484 .read(cx)
485 .prompt
486 .clone();
487
488 let connect = connect_over_ssh(
489 connection_options.dev_server_identifier(),
490 connection_options.clone(),
491 prompt,
492 cx,
493 )
494 .prompt_err("Failed to connect", cx, |_, _| None);
495 cx.spawn(move |workspace, mut cx| async move {
496 let Some(session) = connect.await else {
497 workspace
498 .update(&mut cx, |workspace, cx| {
499 let weak = cx.view().downgrade();
500 workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak));
501 })
502 .log_err();
503 return;
504 };
505
506 workspace
507 .update(&mut cx, |workspace, cx| {
508 let app_state = workspace.app_state().clone();
509 let weak = cx.view().downgrade();
510 let project = project::Project::ssh(
511 session,
512 app_state.client.clone(),
513 app_state.node_runtime.clone(),
514 app_state.user_store.clone(),
515 app_state.languages.clone(),
516 app_state.fs.clone(),
517 cx,
518 );
519 workspace.toggle_modal(cx, |cx| {
520 DevServerProjects::project_picker(
521 ix,
522 connection_options,
523 project,
524 cx,
525 weak,
526 )
527 });
528 })
529 .ok();
530 })
531 .detach()
532 })
533 })
534 }
535
536 fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
537 match &self.mode {
538 Mode::Default | Mode::ViewServerOptions(_, _) => {
539 let items = std::mem::take(&mut self.selectable_items);
540 items.confirm(self, cx);
541 self.selectable_items = items;
542 }
543 Mode::ProjectPicker(_) => {}
544 Mode::CreateDevServer(state) => {
545 if let Some(prompt) = state.ssh_prompt.as_ref() {
546 prompt.update(cx, |prompt, cx| {
547 prompt.confirm(cx);
548 });
549 return;
550 }
551
552 state.address_editor.update(cx, |this, _| {
553 this.set_read_only(true);
554 });
555 self.create_ssh_server(state.address_editor.clone(), cx);
556 }
557 Mode::EditNickname(state) => {
558 let text = Some(state.editor.read(cx).text(cx))
559 .filter(|text| !text.is_empty())
560 .map(SharedString::from);
561 let index = state.index;
562 self.update_settings_file(cx, move |setting, _| {
563 if let Some(connections) = setting.ssh_connections.as_mut() {
564 if let Some(connection) = connections.get_mut(index) {
565 connection.nickname = text;
566 }
567 }
568 });
569 self.mode = Mode::Default;
570 self.selectable_items.reset_selection();
571 self.focus_handle.focus(cx);
572 }
573 }
574 }
575
576 fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
577 match &self.mode {
578 Mode::Default => cx.emit(DismissEvent),
579 Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => {
580 self.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
581 self.selectable_items.reset_selection();
582 cx.notify();
583 }
584 _ => {
585 self.mode = Mode::Default;
586 self.selectable_items.reset_selection();
587 self.focus_handle(cx).focus(cx);
588 cx.notify();
589 }
590 }
591 }
592
593 fn render_ssh_connection(
594 &mut self,
595 ix: usize,
596 ssh_connection: SshConnection,
597 cx: &mut ViewContext<Self>,
598 ) -> impl IntoElement {
599 let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() {
600 let aux_label = SharedString::from(format!("({})", ssh_connection.host));
601 (nickname, Some(aux_label))
602 } else {
603 (ssh_connection.host.clone(), None)
604 };
605 v_flex()
606 .w_full()
607 .child(ListSeparator)
608 .child(
609 h_flex()
610 .group("ssh-server")
611 .w_full()
612 .pt_0p5()
613 .px_3()
614 .gap_1()
615 .overflow_hidden()
616 .whitespace_nowrap()
617 .child(
618 Label::new(main_label)
619 .size(LabelSize::Small)
620 .weight(FontWeight::SEMIBOLD)
621 .color(Color::Muted),
622 )
623 .children(
624 aux_label.map(|label| {
625 Label::new(label).size(LabelSize::Small).color(Color::Muted)
626 }),
627 ),
628 )
629 .child(
630 List::new()
631 .empty_message("No projects.")
632 .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| {
633 v_flex().gap_0p5().child(self.render_ssh_project(
634 ix,
635 &ssh_connection,
636 pix,
637 p,
638 cx,
639 ))
640 }))
641 .child(h_flex().map(|this| {
642 self.selectable_items.add_item(Box::new({
643 let ssh_connection = ssh_connection.clone();
644 move |this, cx| {
645 this.create_ssh_project(ix, ssh_connection.clone(), cx);
646 }
647 }));
648 let is_selected = self.selectable_items.is_selected();
649 this.child(
650 ListItem::new(("new-remote-project", ix))
651 .selected(is_selected)
652 .inset(true)
653 .spacing(ui::ListItemSpacing::Sparse)
654 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
655 .child(Label::new("Open Folder"))
656 .on_click(cx.listener({
657 let ssh_connection = ssh_connection.clone();
658 move |this, _, cx| {
659 this.create_ssh_project(ix, ssh_connection.clone(), cx);
660 }
661 })),
662 )
663 }))
664 .child(h_flex().map(|this| {
665 self.selectable_items.add_item(Box::new({
666 let ssh_connection = ssh_connection.clone();
667 move |this, cx| {
668 this.view_server_options((ix, ssh_connection.clone()), cx);
669 }
670 }));
671 let is_selected = self.selectable_items.is_selected();
672 this.child(
673 ListItem::new(("server-options", ix))
674 .selected(is_selected)
675 .inset(true)
676 .spacing(ui::ListItemSpacing::Sparse)
677 .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
678 .child(Label::new("View Server Options"))
679 .on_click(cx.listener({
680 let ssh_connection = ssh_connection.clone();
681 move |this, _, cx| {
682 this.view_server_options((ix, ssh_connection.clone()), cx);
683 }
684 })),
685 )
686 })),
687 )
688 }
689
690 fn render_ssh_project(
691 &mut self,
692 server_ix: usize,
693 server: &SshConnection,
694 ix: usize,
695 project: &SshProject,
696 cx: &ViewContext<Self>,
697 ) -> impl IntoElement {
698 let server = server.clone();
699
700 let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
701 let callback = Arc::new({
702 let project = project.clone();
703 move |this: &mut Self, cx: &mut ViewContext<Self>| {
704 let Some(app_state) = this
705 .workspace
706 .update(cx, |workspace, _| workspace.app_state().clone())
707 .log_err()
708 else {
709 return;
710 };
711 let project = project.clone();
712 let server = server.clone();
713 cx.spawn(|_, mut cx| async move {
714 let result = open_ssh_project(
715 server.into(),
716 project.paths.into_iter().map(PathBuf::from).collect(),
717 app_state,
718 OpenOptions::default(),
719 &mut cx,
720 )
721 .await;
722 if let Err(e) = result {
723 log::error!("Failed to connect: {:?}", e);
724 cx.prompt(
725 gpui::PromptLevel::Critical,
726 "Failed to connect",
727 Some(&e.to_string()),
728 &["Ok"],
729 )
730 .await
731 .ok();
732 }
733 })
734 .detach();
735 }
736 });
737 self.selectable_items.add_item(Box::new({
738 let callback = callback.clone();
739 move |this, cx| callback(this, cx)
740 }));
741 let is_selected = self.selectable_items.is_selected();
742
743 ListItem::new((element_id_base, ix))
744 .inset(true)
745 .selected(is_selected)
746 .spacing(ui::ListItemSpacing::Sparse)
747 .start_slot(
748 Icon::new(IconName::Folder)
749 .color(Color::Muted)
750 .size(IconSize::Small),
751 )
752 .child(Label::new(project.paths.join(", ")))
753 .on_click(cx.listener(move |this, _, cx| callback(this, cx)))
754 .end_hover_slot::<AnyElement>(Some(
755 IconButton::new("remove-remote-project", IconName::TrashAlt)
756 .icon_size(IconSize::Small)
757 .shape(IconButtonShape::Square)
758 .on_click(
759 cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)),
760 )
761 .size(ButtonSize::Large)
762 .tooltip(|cx| Tooltip::text("Delete Remote Project", cx))
763 .into_any_element(),
764 ))
765 }
766
767 fn update_settings_file(
768 &mut self,
769 cx: &mut ViewContext<Self>,
770 f: impl FnOnce(&mut RemoteSettingsContent, &AppContext) + Send + Sync + 'static,
771 ) {
772 let Some(fs) = self
773 .workspace
774 .update(cx, |workspace, _| workspace.app_state().fs.clone())
775 .log_err()
776 else {
777 return;
778 };
779 update_settings_file::<SshSettings>(fs, cx, move |setting, cx| f(setting, cx));
780 }
781
782 fn delete_ssh_server(&mut self, server: usize, cx: &mut ViewContext<Self>) {
783 self.update_settings_file(cx, move |setting, _| {
784 if let Some(connections) = setting.ssh_connections.as_mut() {
785 connections.remove(server);
786 }
787 });
788 }
789
790 fn delete_ssh_project(&mut self, server: usize, project: usize, cx: &mut ViewContext<Self>) {
791 self.update_settings_file(cx, move |setting, _| {
792 if let Some(server) = setting
793 .ssh_connections
794 .as_mut()
795 .and_then(|connections| connections.get_mut(server))
796 {
797 server.projects.remove(project);
798 }
799 });
800 }
801
802 fn add_ssh_server(
803 &mut self,
804 connection_options: remote::SshConnectionOptions,
805 cx: &mut ViewContext<Self>,
806 ) {
807 self.update_settings_file(cx, move |setting, _| {
808 setting
809 .ssh_connections
810 .get_or_insert(Default::default())
811 .push(SshConnection {
812 host: SharedString::from(connection_options.host),
813 username: connection_options.username,
814 port: connection_options.port,
815 projects: vec![],
816 nickname: None,
817 })
818 });
819 }
820
821 fn render_create_dev_server(
822 &self,
823 state: &CreateDevServer,
824 cx: &mut ViewContext<Self>,
825 ) -> impl IntoElement {
826 let ssh_prompt = state.ssh_prompt.clone();
827
828 state.address_editor.update(cx, |editor, cx| {
829 if editor.text(cx).is_empty() {
830 editor.set_placeholder_text(
831 "Enter the command you use to SSH into this server: e.g., ssh me@my.server",
832 cx,
833 );
834 }
835 });
836
837 let theme = cx.theme();
838
839 v_flex()
840 .id("create-dev-server")
841 .overflow_hidden()
842 .size_full()
843 .flex_1()
844 .child(
845 div()
846 .p_2()
847 .border_b_1()
848 .border_color(theme.colors().border_variant)
849 .child(state.address_editor.clone()),
850 )
851 .child(
852 h_flex()
853 .bg(theme.colors().editor_background)
854 .rounded_b_md()
855 .w_full()
856 .map(|this| {
857 if let Some(ssh_prompt) = ssh_prompt {
858 this.child(h_flex().w_full().child(ssh_prompt))
859 } else {
860 let color = Color::Muted.color(cx);
861 this.child(
862 h_flex()
863 .p_2()
864 .w_full()
865 .items_center()
866 .justify_center()
867 .gap_2()
868 .child(
869 div().size_1p5().rounded_full().bg(color).with_animation(
870 "pulse-ssh-waiting-for-connection",
871 Animation::new(Duration::from_secs(2))
872 .repeat()
873 .with_easing(pulsating_between(0.2, 0.5)),
874 move |this, progress| this.bg(color.opacity(progress)),
875 ),
876 )
877 .child(
878 Label::new("Waiting for connection…")
879 .size(LabelSize::Small),
880 ),
881 )
882 }
883 }),
884 )
885 }
886
887 fn render_view_options(
888 &mut self,
889 index: usize,
890 connection: SshConnection,
891 cx: &mut ViewContext<Self>,
892 ) -> impl IntoElement {
893 let connection_string = connection.host.clone();
894
895 div()
896 .size_full()
897 .child(
898 SshConnectionHeader {
899 connection_string: connection_string.clone(),
900 nickname: connection.nickname.clone(),
901 }
902 .render(cx),
903 )
904 .child(
905 v_flex()
906 .py_1()
907 .child({
908 self.selectable_items.add_item(Box::new({
909 move |this, cx| {
910 this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
911 cx.notify();
912 }
913 }));
914 let is_selected = self.selectable_items.is_selected();
915 let label = if connection.nickname.is_some() {
916 "Edit Nickname"
917 } else {
918 "Add Nickname to Server"
919 };
920 ListItem::new("add-nickname")
921 .selected(is_selected)
922 .inset(true)
923 .spacing(ui::ListItemSpacing::Sparse)
924 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
925 .child(Label::new(label))
926 .on_click(cx.listener(move |this, _, cx| {
927 this.mode = Mode::EditNickname(EditNicknameState::new(index, cx));
928 cx.notify();
929 }))
930 })
931 .child({
932 let workspace = self.workspace.clone();
933 fn callback(
934 workspace: WeakView<Workspace>,
935 connection_string: SharedString,
936 cx: &mut WindowContext<'_>,
937 ) {
938 cx.write_to_clipboard(ClipboardItem::new_string(
939 connection_string.to_string(),
940 ));
941 workspace
942 .update(cx, |this, cx| {
943 struct SshServerAddressCopiedToClipboard;
944 let notification = format!(
945 "Copied server address ({}) to clipboard",
946 connection_string
947 );
948
949 this.show_toast(
950 Toast::new(
951 NotificationId::composite::<
952 SshServerAddressCopiedToClipboard,
953 >(
954 connection_string.clone()
955 ),
956 notification,
957 )
958 .autohide(),
959 cx,
960 );
961 })
962 .ok();
963 }
964 self.selectable_items.add_item(Box::new({
965 let workspace = workspace.clone();
966 let connection_string = connection_string.clone();
967 move |_, cx| {
968 callback(workspace.clone(), connection_string.clone(), cx);
969 }
970 }));
971 let is_selected = self.selectable_items.is_selected();
972 ListItem::new("copy-server-address")
973 .selected(is_selected)
974 .inset(true)
975 .spacing(ui::ListItemSpacing::Sparse)
976 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
977 .child(Label::new("Copy Server Address"))
978 .end_hover_slot(
979 Label::new(connection_string.clone()).color(Color::Muted),
980 )
981 .on_click({
982 let connection_string = connection_string.clone();
983 move |_, cx| {
984 callback(workspace.clone(), connection_string.clone(), cx);
985 }
986 })
987 })
988 .child({
989 fn remove_ssh_server(
990 dev_servers: View<DevServerProjects>,
991 workspace: WeakView<Workspace>,
992 index: usize,
993 connection_string: SharedString,
994 cx: &mut WindowContext<'_>,
995 ) {
996 workspace
997 .update(cx, |this, cx| {
998 struct SshServerRemoval;
999 let notification = format!(
1000 "Do you really want to remove server `{}`?",
1001 connection_string
1002 );
1003 this.show_toast(
1004 Toast::new(
1005 NotificationId::composite::<SshServerRemoval>(
1006 connection_string.clone(),
1007 ),
1008 notification,
1009 )
1010 .on_click(
1011 "Yes, delete it",
1012 move |cx| {
1013 dev_servers.update(cx, |this, cx| {
1014 this.delete_ssh_server(index, cx);
1015 this.mode = Mode::Default;
1016 cx.notify();
1017 })
1018 },
1019 ),
1020 cx,
1021 );
1022 })
1023 .ok();
1024 }
1025 self.selectable_items.add_item(Box::new({
1026 let connection_string = connection_string.clone();
1027 move |this, cx| {
1028 remove_ssh_server(
1029 cx.view().clone(),
1030 this.workspace.clone(),
1031 index,
1032 connection_string.clone(),
1033 cx,
1034 );
1035 }
1036 }));
1037 let is_selected = self.selectable_items.is_selected();
1038 ListItem::new("delete-server")
1039 .selected(is_selected)
1040 .inset(true)
1041 .spacing(ui::ListItemSpacing::Sparse)
1042 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1043 .child(Label::new("Delete Server").color(Color::Error))
1044 .on_click(cx.listener(move |this, _, cx| {
1045 remove_ssh_server(
1046 cx.view().clone(),
1047 this.workspace.clone(),
1048 index,
1049 connection_string.clone(),
1050 cx,
1051 );
1052 }))
1053 })
1054 .child(ListSeparator)
1055 .child({
1056 self.selectable_items.add_item(Box::new({
1057 move |this, cx| {
1058 this.mode = Mode::Default;
1059 cx.notify();
1060 }
1061 }));
1062 let is_selected = self.selectable_items.is_selected();
1063 ListItem::new("go-back")
1064 .selected(is_selected)
1065 .inset(true)
1066 .spacing(ui::ListItemSpacing::Sparse)
1067 .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted))
1068 .child(Label::new("Go Back"))
1069 .on_click(cx.listener(|this, _, cx| {
1070 this.mode = Mode::Default;
1071 cx.notify()
1072 }))
1073 }),
1074 )
1075 }
1076
1077 fn render_edit_nickname(
1078 &self,
1079 state: &EditNicknameState,
1080 cx: &mut ViewContext<Self>,
1081 ) -> impl IntoElement {
1082 let Some(connection) = SshSettings::get_global(cx)
1083 .ssh_connections()
1084 .nth(state.index)
1085 else {
1086 return v_flex();
1087 };
1088
1089 let connection_string = connection.host.clone();
1090
1091 v_flex()
1092 .child(
1093 SshConnectionHeader {
1094 connection_string,
1095 nickname: connection.nickname.clone(),
1096 }
1097 .render(cx),
1098 )
1099 .child(h_flex().p_2().child(state.editor.clone()))
1100 }
1101
1102 fn render_default(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1103 let dev_servers = self.dev_server_store.read(cx).dev_servers();
1104 let ssh_connections = SshSettings::get_global(cx)
1105 .ssh_connections()
1106 .collect::<Vec<_>>();
1107 self.selectable_items.add_item(Box::new(|this, cx| {
1108 this.mode = Mode::CreateDevServer(CreateDevServer::new(cx));
1109 cx.notify();
1110 }));
1111
1112 let is_selected = self.selectable_items.is_selected();
1113
1114 let connect_button = ListItem::new("register-dev-server-button")
1115 .selected(is_selected)
1116 .inset(true)
1117 .spacing(ui::ListItemSpacing::Sparse)
1118 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1119 .child(Label::new("Connect New Server"))
1120 .on_click(cx.listener(|this, _, cx| {
1121 let state = CreateDevServer::new(cx);
1122 this.mode = Mode::CreateDevServer(state);
1123
1124 cx.notify();
1125 }));
1126
1127 let mut modal_section = v_flex()
1128 .id("ssh-server-list")
1129 .overflow_y_scroll()
1130 .size_full()
1131 .child(connect_button)
1132 .child(
1133 List::new()
1134 .empty_message(
1135 v_flex()
1136 .child(ListSeparator)
1137 .child(div().px_3().child(
1138 Label::new("No dev servers registered yet.").color(Color::Muted),
1139 ))
1140 .into_any_element(),
1141 )
1142 .children(ssh_connections.iter().cloned().enumerate().map(
1143 |(ix, connection)| {
1144 self.render_ssh_connection(ix, connection, cx)
1145 .into_any_element()
1146 },
1147 )),
1148 )
1149 .into_any_element();
1150
1151 let server_count = format!("Servers: {}", ssh_connections.len() + dev_servers.len());
1152
1153 Modal::new("remote-projects", Some(self.scroll_handle.clone()))
1154 .header(
1155 ModalHeader::new().child(
1156 h_flex()
1157 .items_center()
1158 .justify_between()
1159 .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall))
1160 .child(Label::new(server_count).size(LabelSize::Small)),
1161 ),
1162 )
1163 .section(
1164 Section::new().padded(false).child(
1165 v_flex()
1166 .min_h(rems(20.))
1167 .flex_1()
1168 .size_full()
1169 .child(ListSeparator)
1170 .child(
1171 canvas(
1172 |bounds, cx| {
1173 modal_section.prepaint_as_root(
1174 bounds.origin,
1175 bounds.size.into(),
1176 cx,
1177 );
1178 modal_section
1179 },
1180 |_, mut modal_section, cx| {
1181 modal_section.paint(cx);
1182 },
1183 )
1184 .size_full(),
1185 ),
1186 ),
1187 )
1188 }
1189}
1190
1191fn get_text(element: &View<Editor>, cx: &mut WindowContext) -> String {
1192 element.read(cx).text(cx).trim().to_string()
1193}
1194
1195impl ModalView for DevServerProjects {}
1196
1197impl FocusableView for DevServerProjects {
1198 fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
1199 self.focus_handle.clone()
1200 }
1201}
1202
1203impl EventEmitter<DismissEvent> for DevServerProjects {}
1204
1205impl Render for DevServerProjects {
1206 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1207 self.selectable_items.reset();
1208 div()
1209 .track_focus(&self.focus_handle)
1210 .elevation_3(cx)
1211 .key_context("DevServerModal")
1212 .on_action(cx.listener(Self::cancel))
1213 .on_action(cx.listener(Self::confirm))
1214 .on_action(cx.listener(Self::prev_item))
1215 .on_action(cx.listener(Self::next_item))
1216 .capture_any_mouse_down(cx.listener(|this, _, cx| {
1217 this.focus_handle(cx).focus(cx);
1218 }))
1219 .on_mouse_down_out(cx.listener(|this, _, cx| {
1220 if matches!(this.mode, Mode::Default) {
1221 cx.emit(DismissEvent)
1222 }
1223 }))
1224 .w(rems(34.))
1225 .child(match &self.mode {
1226 Mode::Default => self.render_default(cx).into_any_element(),
1227 Mode::ViewServerOptions(index, connection) => self
1228 .render_view_options(*index, connection.clone(), cx)
1229 .into_any_element(),
1230 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1231 Mode::CreateDevServer(state) => {
1232 self.render_create_dev_server(state, cx).into_any_element()
1233 }
1234 Mode::EditNickname(state) => {
1235 self.render_edit_nickname(state, cx).into_any_element()
1236 }
1237 })
1238 }
1239}
1240
1241pub fn reconnect_to_dev_server_project(
1242 workspace: View<Workspace>,
1243 dev_server: DevServer,
1244 dev_server_project_id: DevServerProjectId,
1245 replace_current_window: bool,
1246 cx: &mut WindowContext,
1247) -> Task<Result<()>> {
1248 let store = dev_server_projects::Store::global(cx);
1249 let reconnect = reconnect_to_dev_server(workspace.clone(), dev_server, cx);
1250 cx.spawn(|mut cx| async move {
1251 reconnect.await?;
1252
1253 cx.background_executor()
1254 .timer(Duration::from_millis(1000))
1255 .await;
1256
1257 if let Some(project_id) = store.update(&mut cx, |store, _| {
1258 store
1259 .dev_server_project(dev_server_project_id)
1260 .and_then(|p| p.project_id)
1261 })? {
1262 workspace
1263 .update(&mut cx, move |_, cx| {
1264 open_dev_server_project(
1265 replace_current_window,
1266 dev_server_project_id,
1267 project_id,
1268 cx,
1269 )
1270 })?
1271 .await?;
1272 }
1273
1274 Ok(())
1275 })
1276}
1277
1278pub fn reconnect_to_dev_server(
1279 workspace: View<Workspace>,
1280 dev_server: DevServer,
1281 cx: &mut WindowContext,
1282) -> Task<Result<()>> {
1283 let Some(ssh_connection_string) = dev_server.ssh_connection_string else {
1284 return Task::ready(Err(anyhow!("Can't reconnect, no ssh_connection_string")));
1285 };
1286 let dev_server_store = dev_server_projects::Store::global(cx);
1287 let get_access_token = dev_server_store.update(cx, |store, cx| {
1288 store.regenerate_dev_server_token(dev_server.id, cx)
1289 });
1290
1291 cx.spawn(|mut cx| async move {
1292 let access_token = get_access_token.await?.access_token;
1293
1294 spawn_ssh_task(
1295 workspace,
1296 dev_server_store,
1297 dev_server.id,
1298 ssh_connection_string.to_string(),
1299 access_token,
1300 &mut cx,
1301 )
1302 .await
1303 })
1304}
1305
1306pub async fn spawn_ssh_task(
1307 workspace: View<Workspace>,
1308 dev_server_store: Model<dev_server_projects::Store>,
1309 dev_server_id: DevServerId,
1310 ssh_connection_string: String,
1311 access_token: String,
1312 cx: &mut AsyncWindowContext,
1313) -> Result<()> {
1314 let terminal_panel = workspace
1315 .update(cx, |workspace, cx| workspace.panel::<TerminalPanel>(cx))
1316 .ok()
1317 .flatten()
1318 .with_context(|| anyhow!("No terminal panel"))?;
1319
1320 let command = "sh".to_string();
1321 let args = vec![
1322 "-x".to_string(),
1323 "-c".to_string(),
1324 format!(
1325 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 {}"#,
1326 access_token
1327 ),
1328 ];
1329
1330 let ssh_connection_string = ssh_connection_string.to_string();
1331 let (command, args) = wrap_for_ssh(
1332 &SshCommand::DevServer(ssh_connection_string.clone()),
1333 Some((&command, &args)),
1334 None,
1335 HashMap::default(),
1336 None,
1337 );
1338
1339 let terminal = terminal_panel
1340 .update(cx, |terminal_panel, cx| {
1341 terminal_panel.spawn_in_new_terminal(
1342 SpawnInTerminal {
1343 id: task::TaskId("ssh-remote".into()),
1344 full_label: "Install zed over ssh".into(),
1345 label: "Install zed over ssh".into(),
1346 command,
1347 args,
1348 command_label: ssh_connection_string.clone(),
1349 cwd: None,
1350 use_new_terminal: true,
1351 allow_concurrent_runs: false,
1352 reveal: RevealStrategy::Always,
1353 hide: HideStrategy::Never,
1354 env: Default::default(),
1355 shell: Default::default(),
1356 },
1357 cx,
1358 )
1359 })?
1360 .await?;
1361
1362 terminal
1363 .update(cx, |terminal, cx| terminal.wait_for_completed_task(cx))?
1364 .await;
1365
1366 // 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.
1367 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1368 == DevServerStatus::Offline
1369 {
1370 cx.background_executor()
1371 .timer(Duration::from_millis(200))
1372 .await
1373 }
1374
1375 if dev_server_store.update(cx, |this, _| this.dev_server_status(dev_server_id))?
1376 == DevServerStatus::Offline
1377 {
1378 return Err(anyhow!("couldn't reconnect"))?;
1379 }
1380
1381 Ok(())
1382}