1use crate::{
2 remote_connections::{
3 RemoteConnectionModal, RemoteConnectionPrompt, SshConnection, SshConnectionHeader,
4 SshSettings, connect_over_ssh, open_remote_project,
5 },
6 ssh_config::parse_ssh_config_hosts,
7};
8use editor::Editor;
9use file_finder::OpenPathDelegate;
10use futures::{FutureExt, channel::oneshot, future::Shared, select};
11use gpui::{
12 AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter,
13 FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window,
14 canvas,
15};
16use paths::{global_ssh_config_file, user_ssh_config_file};
17use picker::Picker;
18use project::{Fs, Project};
19use remote::{
20 RemoteClient, RemoteConnectionOptions, SshConnectionOptions,
21 remote_client::ConnectionIdentifier,
22};
23use settings::{
24 RemoteSettingsContent, Settings, SettingsStore, SshProject, update_settings_file,
25 watch_config_file,
26};
27use smol::stream::StreamExt as _;
28use std::{
29 borrow::Cow,
30 collections::BTreeSet,
31 path::PathBuf,
32 rc::Rc,
33 sync::{
34 Arc,
35 atomic::{self, AtomicUsize},
36 },
37};
38use ui::{
39 IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
40 Section, Tooltip, WithScrollbar, prelude::*,
41};
42use util::{
43 ResultExt,
44 paths::{PathStyle, RemotePathBuf},
45};
46use workspace::{
47 ModalView, OpenOptions, Toast, Workspace,
48 notifications::{DetachAndPromptErr, NotificationId},
49 open_remote_project_with_existing_connection,
50};
51
52pub struct RemoteServerProjects {
53 mode: Mode,
54 focus_handle: FocusHandle,
55 workspace: WeakEntity<Workspace>,
56 retained_connections: Vec<Entity<RemoteClient>>,
57 ssh_config_updates: Task<()>,
58 ssh_config_servers: BTreeSet<SharedString>,
59 create_new_window: bool,
60 _subscription: Subscription,
61}
62
63struct CreateRemoteServer {
64 address_editor: Entity<Editor>,
65 address_error: Option<SharedString>,
66 ssh_prompt: Option<Entity<RemoteConnectionPrompt>>,
67 _creating: Option<Task<Option<()>>>,
68}
69
70impl CreateRemoteServer {
71 fn new(window: &mut Window, cx: &mut App) -> Self {
72 let address_editor = cx.new(|cx| Editor::single_line(window, cx));
73 address_editor.update(cx, |this, cx| {
74 this.focus_handle(cx).focus(window);
75 });
76 Self {
77 address_editor,
78 address_error: None,
79 ssh_prompt: None,
80 _creating: None,
81 }
82 }
83}
84
85struct ProjectPicker {
86 connection_string: SharedString,
87 nickname: Option<SharedString>,
88 picker: Entity<Picker<OpenPathDelegate>>,
89 _path_task: Shared<Task<Option<()>>>,
90}
91
92struct EditNicknameState {
93 index: usize,
94 editor: Entity<Editor>,
95}
96
97impl EditNicknameState {
98 fn new(index: usize, window: &mut Window, cx: &mut App) -> Self {
99 let this = Self {
100 index,
101 editor: cx.new(|cx| Editor::single_line(window, cx)),
102 };
103 let starting_text = SshSettings::get_global(cx)
104 .ssh_connections()
105 .nth(index)
106 .and_then(|state| state.nickname)
107 .filter(|text| !text.is_empty());
108 this.editor.update(cx, |this, cx| {
109 this.set_placeholder_text("Add a nickname for this server", window, cx);
110 if let Some(starting_text) = starting_text {
111 this.set_text(starting_text, window, cx);
112 }
113 });
114 this.editor.focus_handle(cx).focus(window);
115 this
116 }
117}
118
119impl Focusable for ProjectPicker {
120 fn focus_handle(&self, cx: &App) -> FocusHandle {
121 self.picker.focus_handle(cx)
122 }
123}
124
125impl ProjectPicker {
126 fn new(
127 create_new_window: bool,
128 ix: usize,
129 connection: SshConnectionOptions,
130 project: Entity<Project>,
131 home_dir: RemotePathBuf,
132 path_style: PathStyle,
133 workspace: WeakEntity<Workspace>,
134 window: &mut Window,
135 cx: &mut Context<RemoteServerProjects>,
136 ) -> Entity<Self> {
137 let (tx, rx) = oneshot::channel();
138 let lister = project::DirectoryLister::Project(project.clone());
139 let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style);
140
141 let picker = cx.new(|cx| {
142 let picker = Picker::uniform_list(delegate, window, cx)
143 .width(rems(34.))
144 .modal(false);
145 picker.set_query(home_dir.to_string(), window, cx);
146 picker
147 });
148 let connection_string = connection.connection_string().into();
149 let nickname = connection.nickname.clone().map(|nick| nick.into());
150 let _path_task = cx
151 .spawn_in(window, {
152 let workspace = workspace;
153 async move |this, cx| {
154 let Ok(Some(paths)) = rx.await else {
155 workspace
156 .update_in(cx, |workspace, window, cx| {
157 let fs = workspace.project().read(cx).fs().clone();
158 let weak = cx.entity().downgrade();
159 workspace.toggle_modal(window, cx, |window, cx| {
160 RemoteServerProjects::new(
161 create_new_window,
162 fs,
163 window,
164 weak,
165 cx,
166 )
167 });
168 })
169 .log_err()?;
170 return None;
171 };
172
173 let app_state = workspace
174 .read_with(cx, |workspace, _| workspace.app_state().clone())
175 .ok()?;
176
177 cx.update(|_, cx| {
178 let fs = app_state.fs.clone();
179 update_settings_file(fs, cx, {
180 let paths = paths
181 .iter()
182 .map(|path| path.to_string_lossy().to_string())
183 .collect();
184 move |setting, _| {
185 if let Some(server) = setting
186 .remote
187 .ssh_connections
188 .as_mut()
189 .and_then(|connections| connections.get_mut(ix))
190 {
191 server.projects.insert(SshProject { paths });
192 }
193 }
194 });
195 })
196 .log_err();
197
198 let options = cx
199 .update(|_, cx| (app_state.build_window_options)(None, cx))
200 .log_err()?;
201 let window = cx
202 .open_window(options, |window, cx| {
203 cx.new(|cx| {
204 telemetry::event!("SSH Project Created");
205 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
206 })
207 })
208 .log_err()?;
209
210 open_remote_project_with_existing_connection(
211 RemoteConnectionOptions::Ssh(connection),
212 project,
213 paths,
214 app_state,
215 window,
216 cx,
217 )
218 .await
219 .log_err();
220
221 this.update(cx, |_, cx| {
222 cx.emit(DismissEvent);
223 })
224 .ok();
225 Some(())
226 }
227 })
228 .shared();
229 cx.new(|_| Self {
230 _path_task,
231 picker,
232 connection_string,
233 nickname,
234 })
235 }
236}
237
238impl gpui::Render for ProjectPicker {
239 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
240 v_flex()
241 .child(
242 SshConnectionHeader {
243 connection_string: self.connection_string.clone(),
244 paths: Default::default(),
245 nickname: self.nickname.clone(),
246 }
247 .render(window, cx),
248 )
249 .child(
250 div()
251 .border_t_1()
252 .border_color(cx.theme().colors().border_variant)
253 .child(self.picker.clone()),
254 )
255 }
256}
257
258#[derive(Clone)]
259enum RemoteEntry {
260 Project {
261 open_folder: NavigableEntry,
262 projects: Vec<(NavigableEntry, SshProject)>,
263 configure: NavigableEntry,
264 connection: SshConnection,
265 },
266 SshConfig {
267 open_folder: NavigableEntry,
268 host: SharedString,
269 },
270}
271
272impl RemoteEntry {
273 fn is_from_zed(&self) -> bool {
274 matches!(self, Self::Project { .. })
275 }
276
277 fn connection(&self) -> Cow<'_, SshConnection> {
278 match self {
279 Self::Project { connection, .. } => Cow::Borrowed(connection),
280 Self::SshConfig { host, .. } => Cow::Owned(SshConnection {
281 host: host.clone(),
282 ..SshConnection::default()
283 }),
284 }
285 }
286}
287
288#[derive(Clone)]
289struct DefaultState {
290 scroll_handle: ScrollHandle,
291 add_new_server: NavigableEntry,
292 servers: Vec<RemoteEntry>,
293}
294
295impl DefaultState {
296 fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
297 let handle = ScrollHandle::new();
298 let add_new_server = NavigableEntry::new(&handle, cx);
299
300 let ssh_settings = SshSettings::get_global(cx);
301 let read_ssh_config = ssh_settings.read_ssh_config;
302
303 let mut servers: Vec<RemoteEntry> = ssh_settings
304 .ssh_connections()
305 .map(|connection| {
306 let open_folder = NavigableEntry::new(&handle, cx);
307 let configure = NavigableEntry::new(&handle, cx);
308 let projects = connection
309 .projects
310 .iter()
311 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
312 .collect();
313 RemoteEntry::Project {
314 open_folder,
315 configure,
316 projects,
317 connection,
318 }
319 })
320 .collect();
321
322 if read_ssh_config {
323 let mut extra_servers_from_config = ssh_config_servers.clone();
324 for server in &servers {
325 if let RemoteEntry::Project { connection, .. } = server {
326 extra_servers_from_config.remove(&connection.host);
327 }
328 }
329 servers.extend(extra_servers_from_config.into_iter().map(|host| {
330 RemoteEntry::SshConfig {
331 open_folder: NavigableEntry::new(&handle, cx),
332 host,
333 }
334 }));
335 }
336
337 Self {
338 scroll_handle: handle,
339 add_new_server,
340 servers,
341 }
342 }
343}
344
345#[derive(Clone)]
346struct ViewServerOptionsState {
347 server_index: usize,
348 connection: SshConnection,
349 entries: [NavigableEntry; 4],
350}
351enum Mode {
352 Default(DefaultState),
353 ViewServerOptions(ViewServerOptionsState),
354 EditNickname(EditNicknameState),
355 ProjectPicker(Entity<ProjectPicker>),
356 CreateRemoteServer(CreateRemoteServer),
357}
358
359impl Mode {
360 fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
361 Self::Default(DefaultState::new(ssh_config_servers, cx))
362 }
363}
364impl RemoteServerProjects {
365 pub fn new(
366 create_new_window: bool,
367 fs: Arc<dyn Fs>,
368 window: &mut Window,
369 workspace: WeakEntity<Workspace>,
370 cx: &mut Context<Self>,
371 ) -> Self {
372 let focus_handle = cx.focus_handle();
373 let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
374 let ssh_config_updates = if read_ssh_config {
375 spawn_ssh_config_watch(fs.clone(), cx)
376 } else {
377 Task::ready(())
378 };
379
380 let mut base_style = window.text_style();
381 base_style.refine(&gpui::TextStyleRefinement {
382 color: Some(cx.theme().colors().editor_foreground),
383 ..Default::default()
384 });
385
386 let _subscription =
387 cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
388 let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
389 if read_ssh_config != new_read_ssh_config {
390 read_ssh_config = new_read_ssh_config;
391 if read_ssh_config {
392 recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
393 } else {
394 recent_projects.ssh_config_servers.clear();
395 recent_projects.ssh_config_updates = Task::ready(());
396 }
397 }
398 });
399
400 Self {
401 mode: Mode::default_mode(&BTreeSet::new(), cx),
402 focus_handle,
403 workspace,
404 retained_connections: Vec::new(),
405 ssh_config_updates,
406 ssh_config_servers: BTreeSet::new(),
407 create_new_window,
408 _subscription,
409 }
410 }
411
412 pub fn project_picker(
413 create_new_window: bool,
414 ix: usize,
415 connection_options: remote::SshConnectionOptions,
416 project: Entity<Project>,
417 home_dir: RemotePathBuf,
418 path_style: PathStyle,
419 window: &mut Window,
420 cx: &mut Context<Self>,
421 workspace: WeakEntity<Workspace>,
422 ) -> Self {
423 let fs = project.read(cx).fs().clone();
424 let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
425 this.mode = Mode::ProjectPicker(ProjectPicker::new(
426 create_new_window,
427 ix,
428 connection_options,
429 project,
430 home_dir,
431 path_style,
432 workspace,
433 window,
434 cx,
435 ));
436 cx.notify();
437
438 this
439 }
440
441 fn create_ssh_server(
442 &mut self,
443 editor: Entity<Editor>,
444 window: &mut Window,
445 cx: &mut Context<Self>,
446 ) {
447 let input = get_text(&editor, cx);
448 if input.is_empty() {
449 return;
450 }
451
452 let connection_options = match SshConnectionOptions::parse_command_line(&input) {
453 Ok(c) => c,
454 Err(e) => {
455 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
456 address_editor: editor,
457 address_error: Some(format!("could not parse: {:?}", e).into()),
458 ssh_prompt: None,
459 _creating: None,
460 });
461 return;
462 }
463 };
464 let ssh_prompt = cx.new(|cx| {
465 RemoteConnectionPrompt::new(
466 connection_options.connection_string(),
467 connection_options.nickname.clone(),
468 window,
469 cx,
470 )
471 });
472
473 let connection = connect_over_ssh(
474 ConnectionIdentifier::setup(),
475 connection_options.clone(),
476 ssh_prompt.clone(),
477 window,
478 cx,
479 )
480 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
481
482 let address_editor = editor.clone();
483 let creating = cx.spawn_in(window, async move |this, cx| {
484 match connection.await {
485 Some(Some(client)) => this
486 .update_in(cx, |this, window, cx| {
487 telemetry::event!("SSH Server Created");
488 this.retained_connections.push(client);
489 this.add_ssh_server(connection_options, cx);
490 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
491 this.focus_handle(cx).focus(window);
492 cx.notify()
493 })
494 .log_err(),
495 _ => this
496 .update(cx, |this, cx| {
497 address_editor.update(cx, |this, _| {
498 this.set_read_only(false);
499 });
500 this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
501 address_editor,
502 address_error: None,
503 ssh_prompt: None,
504 _creating: None,
505 });
506 cx.notify()
507 })
508 .log_err(),
509 };
510 None
511 });
512
513 editor.update(cx, |this, _| {
514 this.set_read_only(true);
515 });
516 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
517 address_editor: editor,
518 address_error: None,
519 ssh_prompt: Some(ssh_prompt),
520 _creating: Some(creating),
521 });
522 }
523
524 fn view_server_options(
525 &mut self,
526 (server_index, connection): (usize, SshConnection),
527 window: &mut Window,
528 cx: &mut Context<Self>,
529 ) {
530 self.mode = Mode::ViewServerOptions(ViewServerOptionsState {
531 server_index,
532 connection,
533 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
534 });
535 self.focus_handle(cx).focus(window);
536 cx.notify();
537 }
538
539 fn create_ssh_project(
540 &mut self,
541 ix: usize,
542 ssh_connection: SshConnection,
543 window: &mut Window,
544 cx: &mut Context<Self>,
545 ) {
546 let Some(workspace) = self.workspace.upgrade() else {
547 return;
548 };
549
550 let create_new_window = self.create_new_window;
551 let connection_options: SshConnectionOptions = ssh_connection.into();
552 workspace.update(cx, |_, cx| {
553 cx.defer_in(window, move |workspace, window, cx| {
554 let app_state = workspace.app_state().clone();
555 workspace.toggle_modal(window, cx, |window, cx| {
556 RemoteConnectionModal::new(
557 &RemoteConnectionOptions::Ssh(connection_options.clone()),
558 Vec::new(),
559 window,
560 cx,
561 )
562 });
563 let prompt = workspace
564 .active_modal::<RemoteConnectionModal>(cx)
565 .unwrap()
566 .read(cx)
567 .prompt
568 .clone();
569
570 let connect = connect_over_ssh(
571 ConnectionIdentifier::setup(),
572 connection_options.clone(),
573 prompt,
574 window,
575 cx,
576 )
577 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
578
579 cx.spawn_in(window, async move |workspace, cx| {
580 let session = connect.await;
581
582 workspace.update(cx, |workspace, cx| {
583 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
584 prompt.update(cx, |prompt, cx| prompt.finished(cx))
585 }
586 })?;
587
588 let Some(Some(session)) = session else {
589 return workspace.update_in(cx, |workspace, window, cx| {
590 let weak = cx.entity().downgrade();
591 let fs = workspace.project().read(cx).fs().clone();
592 workspace.toggle_modal(window, cx, |window, cx| {
593 RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
594 });
595 });
596 };
597
598 let (path_style, project) = cx.update(|_, cx| {
599 (
600 session.read(cx).path_style(),
601 project::Project::remote(
602 session,
603 app_state.client.clone(),
604 app_state.node_runtime.clone(),
605 app_state.user_store.clone(),
606 app_state.languages.clone(),
607 app_state.fs.clone(),
608 cx,
609 ),
610 )
611 })?;
612
613 let home_dir = project
614 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
615 .await
616 .and_then(|path| path.into_abs_path())
617 .map(|path| RemotePathBuf::new(path, path_style))
618 .unwrap_or_else(|| match path_style {
619 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
620 PathStyle::Windows => {
621 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
622 }
623 });
624
625 workspace
626 .update_in(cx, |workspace, window, cx| {
627 let weak = cx.entity().downgrade();
628 workspace.toggle_modal(window, cx, |window, cx| {
629 RemoteServerProjects::project_picker(
630 create_new_window,
631 ix,
632 connection_options,
633 project,
634 home_dir,
635 path_style,
636 window,
637 cx,
638 weak,
639 )
640 });
641 })
642 .ok();
643 Ok(())
644 })
645 .detach();
646 })
647 })
648 }
649
650 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
651 match &self.mode {
652 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
653 Mode::ProjectPicker(_) => {}
654 Mode::CreateRemoteServer(state) => {
655 if let Some(prompt) = state.ssh_prompt.as_ref() {
656 prompt.update(cx, |prompt, cx| {
657 prompt.confirm(window, cx);
658 });
659 return;
660 }
661
662 self.create_ssh_server(state.address_editor.clone(), window, cx);
663 }
664 Mode::EditNickname(state) => {
665 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
666 let index = state.index;
667 self.update_settings_file(cx, move |setting, _| {
668 if let Some(connections) = setting.ssh_connections.as_mut()
669 && let Some(connection) = connections.get_mut(index)
670 {
671 connection.nickname = text;
672 }
673 });
674 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
675 self.focus_handle.focus(window);
676 }
677 }
678 }
679
680 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
681 match &self.mode {
682 Mode::Default(_) => cx.emit(DismissEvent),
683 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
684 let new_state = CreateRemoteServer::new(window, cx);
685 let old_prompt = state.address_editor.read(cx).text(cx);
686 new_state.address_editor.update(cx, |this, cx| {
687 this.set_text(old_prompt, window, cx);
688 });
689
690 self.mode = Mode::CreateRemoteServer(new_state);
691 cx.notify();
692 }
693 _ => {
694 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
695 self.focus_handle(cx).focus(window);
696 cx.notify();
697 }
698 }
699 }
700
701 fn render_ssh_connection(
702 &mut self,
703 ix: usize,
704 ssh_server: RemoteEntry,
705 window: &mut Window,
706 cx: &mut Context<Self>,
707 ) -> impl IntoElement {
708 let connection = ssh_server.connection().into_owned();
709 let (main_label, aux_label) = if let Some(nickname) = connection.nickname.clone() {
710 let aux_label = SharedString::from(format!("({})", connection.host));
711 (nickname.into(), Some(aux_label))
712 } else {
713 (connection.host.clone(), None)
714 };
715 v_flex()
716 .w_full()
717 .child(ListSeparator)
718 .child(
719 h_flex()
720 .group("ssh-server")
721 .w_full()
722 .pt_0p5()
723 .px_3()
724 .gap_1()
725 .overflow_hidden()
726 .child(
727 div().max_w_96().overflow_hidden().text_ellipsis().child(
728 Label::new(main_label)
729 .size(LabelSize::Small)
730 .color(Color::Muted),
731 ),
732 )
733 .children(
734 aux_label.map(|label| {
735 Label::new(label).size(LabelSize::Small).color(Color::Muted)
736 }),
737 ),
738 )
739 .child(match &ssh_server {
740 RemoteEntry::Project {
741 open_folder,
742 projects,
743 configure,
744 connection,
745 } => List::new()
746 .empty_message("No projects.")
747 .children(projects.iter().enumerate().map(|(pix, p)| {
748 v_flex().gap_0p5().child(self.render_ssh_project(
749 ix,
750 ssh_server.clone(),
751 pix,
752 p,
753 window,
754 cx,
755 ))
756 }))
757 .child(
758 h_flex()
759 .id(("new-remote-project-container", ix))
760 .track_focus(&open_folder.focus_handle)
761 .anchor_scroll(open_folder.scroll_anchor.clone())
762 .on_action(cx.listener({
763 let ssh_connection = connection.clone();
764 move |this, _: &menu::Confirm, window, cx| {
765 this.create_ssh_project(ix, ssh_connection.clone(), window, cx);
766 }
767 }))
768 .child(
769 ListItem::new(("new-remote-project", ix))
770 .toggle_state(
771 open_folder.focus_handle.contains_focused(window, cx),
772 )
773 .inset(true)
774 .spacing(ui::ListItemSpacing::Sparse)
775 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
776 .child(Label::new("Open Folder"))
777 .on_click(cx.listener({
778 let ssh_connection = connection.clone();
779 move |this, _, window, cx| {
780 this.create_ssh_project(
781 ix,
782 ssh_connection.clone(),
783 window,
784 cx,
785 );
786 }
787 })),
788 ),
789 )
790 .child(
791 h_flex()
792 .id(("server-options-container", ix))
793 .track_focus(&configure.focus_handle)
794 .anchor_scroll(configure.scroll_anchor.clone())
795 .on_action(cx.listener({
796 let ssh_connection = connection.clone();
797 move |this, _: &menu::Confirm, window, cx| {
798 this.view_server_options(
799 (ix, ssh_connection.clone()),
800 window,
801 cx,
802 );
803 }
804 }))
805 .child(
806 ListItem::new(("server-options", ix))
807 .toggle_state(
808 configure.focus_handle.contains_focused(window, cx),
809 )
810 .inset(true)
811 .spacing(ui::ListItemSpacing::Sparse)
812 .start_slot(Icon::new(IconName::Settings).color(Color::Muted))
813 .child(Label::new("View Server Options"))
814 .on_click(cx.listener({
815 let ssh_connection = connection.clone();
816 move |this, _, window, cx| {
817 this.view_server_options(
818 (ix, ssh_connection.clone()),
819 window,
820 cx,
821 );
822 }
823 })),
824 ),
825 ),
826 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
827 h_flex()
828 .id(("new-remote-project-container", ix))
829 .track_focus(&open_folder.focus_handle)
830 .anchor_scroll(open_folder.scroll_anchor.clone())
831 .on_action(cx.listener({
832 let ssh_connection = connection.clone();
833 let host = host.clone();
834 move |this, _: &menu::Confirm, window, cx| {
835 let new_ix = this.create_host_from_ssh_config(&host, cx);
836 this.create_ssh_project(new_ix, ssh_connection.clone(), window, cx);
837 }
838 }))
839 .child(
840 ListItem::new(("new-remote-project", ix))
841 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
842 .inset(true)
843 .spacing(ui::ListItemSpacing::Sparse)
844 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
845 .child(Label::new("Open Folder"))
846 .on_click(cx.listener({
847 let ssh_connection = connection;
848 let host = host.clone();
849 move |this, _, window, cx| {
850 let new_ix = this.create_host_from_ssh_config(&host, cx);
851 this.create_ssh_project(
852 new_ix,
853 ssh_connection.clone(),
854 window,
855 cx,
856 );
857 }
858 })),
859 ),
860 ),
861 })
862 }
863
864 fn render_ssh_project(
865 &mut self,
866 server_ix: usize,
867 server: RemoteEntry,
868 ix: usize,
869 (navigation, project): &(NavigableEntry, SshProject),
870 window: &mut Window,
871 cx: &mut Context<Self>,
872 ) -> impl IntoElement {
873 let create_new_window = self.create_new_window;
874 let is_from_zed = server.is_from_zed();
875 let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
876 let container_element_id_base =
877 SharedString::from(format!("remote-project-container-{element_id_base}"));
878
879 let callback = Rc::new({
880 let project = project.clone();
881 move |remote_server_projects: &mut Self,
882 secondary_confirm: bool,
883 window: &mut Window,
884 cx: &mut Context<Self>| {
885 let Some(app_state) = remote_server_projects
886 .workspace
887 .read_with(cx, |workspace, _| workspace.app_state().clone())
888 .log_err()
889 else {
890 return;
891 };
892 let project = project.clone();
893 let server = server.connection().into_owned();
894 cx.emit(DismissEvent);
895
896 let replace_window = match (create_new_window, secondary_confirm) {
897 (true, false) | (false, true) => None,
898 (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
899 };
900
901 cx.spawn_in(window, async move |_, cx| {
902 let result = open_remote_project(
903 RemoteConnectionOptions::Ssh(server.into()),
904 project.paths.into_iter().map(PathBuf::from).collect(),
905 app_state,
906 OpenOptions {
907 replace_window,
908 ..OpenOptions::default()
909 },
910 cx,
911 )
912 .await;
913 if let Err(e) = result {
914 log::error!("Failed to connect: {e:#}");
915 cx.prompt(
916 gpui::PromptLevel::Critical,
917 "Failed to connect",
918 Some(&e.to_string()),
919 &["Ok"],
920 )
921 .await
922 .ok();
923 }
924 })
925 .detach();
926 }
927 });
928
929 div()
930 .id((container_element_id_base, ix))
931 .track_focus(&navigation.focus_handle)
932 .anchor_scroll(navigation.scroll_anchor.clone())
933 .on_action(cx.listener({
934 let callback = callback.clone();
935 move |this, _: &menu::Confirm, window, cx| {
936 callback(this, false, window, cx);
937 }
938 }))
939 .on_action(cx.listener({
940 let callback = callback.clone();
941 move |this, _: &menu::SecondaryConfirm, window, cx| {
942 callback(this, true, window, cx);
943 }
944 }))
945 .child(
946 ListItem::new((element_id_base, ix))
947 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
948 .inset(true)
949 .spacing(ui::ListItemSpacing::Sparse)
950 .start_slot(
951 Icon::new(IconName::Folder)
952 .color(Color::Muted)
953 .size(IconSize::Small),
954 )
955 .child(Label::new(project.paths.join(", ")))
956 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
957 let secondary_confirm = e.modifiers().platform;
958 callback(this, secondary_confirm, window, cx)
959 }))
960 .when(is_from_zed, |server_list_item| {
961 server_list_item.end_hover_slot::<AnyElement>(Some(
962 div()
963 .mr_2()
964 .child({
965 let project = project.clone();
966 // Right-margin to offset it from the Scrollbar
967 IconButton::new("remove-remote-project", IconName::Trash)
968 .icon_size(IconSize::Small)
969 .shape(IconButtonShape::Square)
970 .size(ButtonSize::Large)
971 .tooltip(Tooltip::text("Delete Remote Project"))
972 .on_click(cx.listener(move |this, _, _, cx| {
973 this.delete_ssh_project(server_ix, &project, cx)
974 }))
975 })
976 .into_any_element(),
977 ))
978 }),
979 )
980 }
981
982 fn update_settings_file(
983 &mut self,
984 cx: &mut Context<Self>,
985 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
986 ) {
987 let Some(fs) = self
988 .workspace
989 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
990 .log_err()
991 else {
992 return;
993 };
994 update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
995 }
996
997 fn delete_ssh_server(&mut self, server: usize, cx: &mut Context<Self>) {
998 self.update_settings_file(cx, move |setting, _| {
999 if let Some(connections) = setting.ssh_connections.as_mut() {
1000 connections.remove(server);
1001 }
1002 });
1003 }
1004
1005 fn delete_ssh_project(&mut self, server: usize, project: &SshProject, cx: &mut Context<Self>) {
1006 let project = project.clone();
1007 self.update_settings_file(cx, move |setting, _| {
1008 if let Some(server) = setting
1009 .ssh_connections
1010 .as_mut()
1011 .and_then(|connections| connections.get_mut(server))
1012 {
1013 server.projects.remove(&project);
1014 }
1015 });
1016 }
1017
1018 fn add_ssh_server(
1019 &mut self,
1020 connection_options: remote::SshConnectionOptions,
1021 cx: &mut Context<Self>,
1022 ) {
1023 self.update_settings_file(cx, move |setting, _| {
1024 setting
1025 .ssh_connections
1026 .get_or_insert(Default::default())
1027 .push(SshConnection {
1028 host: SharedString::from(connection_options.host),
1029 username: connection_options.username,
1030 port: connection_options.port,
1031 projects: BTreeSet::new(),
1032 nickname: None,
1033 args: connection_options.args.unwrap_or_default(),
1034 upload_binary_over_ssh: None,
1035 port_forwards: connection_options.port_forwards,
1036 })
1037 });
1038 }
1039
1040 fn render_create_remote_server(
1041 &self,
1042 state: &CreateRemoteServer,
1043 window: &mut Window,
1044 cx: &mut Context<Self>,
1045 ) -> impl IntoElement {
1046 let ssh_prompt = state.ssh_prompt.clone();
1047
1048 state.address_editor.update(cx, |editor, cx| {
1049 if editor.text(cx).is_empty() {
1050 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1051 }
1052 });
1053
1054 let theme = cx.theme();
1055
1056 v_flex()
1057 .track_focus(&self.focus_handle(cx))
1058 .id("create-remote-server")
1059 .overflow_hidden()
1060 .size_full()
1061 .flex_1()
1062 .child(
1063 div()
1064 .p_2()
1065 .border_b_1()
1066 .border_color(theme.colors().border_variant)
1067 .child(state.address_editor.clone()),
1068 )
1069 .child(
1070 h_flex()
1071 .bg(theme.colors().editor_background)
1072 .rounded_b_sm()
1073 .w_full()
1074 .map(|this| {
1075 if let Some(ssh_prompt) = ssh_prompt {
1076 this.child(h_flex().w_full().child(ssh_prompt))
1077 } else if let Some(address_error) = &state.address_error {
1078 this.child(
1079 h_flex().p_2().w_full().gap_2().child(
1080 Label::new(address_error.clone())
1081 .size(LabelSize::Small)
1082 .color(Color::Error),
1083 ),
1084 )
1085 } else {
1086 this.child(
1087 h_flex()
1088 .p_2()
1089 .w_full()
1090 .gap_1()
1091 .child(
1092 Label::new(
1093 "Enter the command you use to SSH into this server.",
1094 )
1095 .color(Color::Muted)
1096 .size(LabelSize::Small),
1097 )
1098 .child(
1099 Button::new("learn-more", "Learn More")
1100 .label_size(LabelSize::Small)
1101 .icon(IconName::ArrowUpRight)
1102 .icon_size(IconSize::XSmall)
1103 .on_click(|_, _, cx| {
1104 cx.open_url(
1105 "https://zed.dev/docs/remote-development",
1106 );
1107 }),
1108 ),
1109 )
1110 }
1111 }),
1112 )
1113 }
1114
1115 fn render_view_options(
1116 &mut self,
1117 ViewServerOptionsState {
1118 server_index,
1119 connection,
1120 entries,
1121 }: ViewServerOptionsState,
1122 window: &mut Window,
1123 cx: &mut Context<Self>,
1124 ) -> impl IntoElement {
1125 let connection_string = connection.host.clone();
1126
1127 let mut view = Navigable::new(
1128 div()
1129 .track_focus(&self.focus_handle(cx))
1130 .size_full()
1131 .child(
1132 SshConnectionHeader {
1133 connection_string: connection_string.clone(),
1134 paths: Default::default(),
1135 nickname: connection.nickname.clone().map(|s| s.into()),
1136 }
1137 .render(window, cx),
1138 )
1139 .child(
1140 v_flex()
1141 .pb_1()
1142 .child(ListSeparator)
1143 .child({
1144 let label = if connection.nickname.is_some() {
1145 "Edit Nickname"
1146 } else {
1147 "Add Nickname to Server"
1148 };
1149 div()
1150 .id("ssh-options-add-nickname")
1151 .track_focus(&entries[0].focus_handle)
1152 .on_action(cx.listener(
1153 move |this, _: &menu::Confirm, window, cx| {
1154 this.mode = Mode::EditNickname(EditNicknameState::new(
1155 server_index,
1156 window,
1157 cx,
1158 ));
1159 cx.notify();
1160 },
1161 ))
1162 .child(
1163 ListItem::new("add-nickname")
1164 .toggle_state(
1165 entries[0].focus_handle.contains_focused(window, cx),
1166 )
1167 .inset(true)
1168 .spacing(ui::ListItemSpacing::Sparse)
1169 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1170 .child(Label::new(label))
1171 .on_click(cx.listener(move |this, _, window, cx| {
1172 this.mode = Mode::EditNickname(EditNicknameState::new(
1173 server_index,
1174 window,
1175 cx,
1176 ));
1177 cx.notify();
1178 })),
1179 )
1180 })
1181 .child({
1182 let workspace = self.workspace.clone();
1183 fn callback(
1184 workspace: WeakEntity<Workspace>,
1185 connection_string: SharedString,
1186 cx: &mut App,
1187 ) {
1188 cx.write_to_clipboard(ClipboardItem::new_string(
1189 connection_string.to_string(),
1190 ));
1191 workspace
1192 .update(cx, |this, cx| {
1193 struct SshServerAddressCopiedToClipboard;
1194 let notification = format!(
1195 "Copied server address ({}) to clipboard",
1196 connection_string
1197 );
1198
1199 this.show_toast(
1200 Toast::new(
1201 NotificationId::composite::<
1202 SshServerAddressCopiedToClipboard,
1203 >(
1204 connection_string.clone()
1205 ),
1206 notification,
1207 )
1208 .autohide(),
1209 cx,
1210 );
1211 })
1212 .ok();
1213 }
1214 div()
1215 .id("ssh-options-copy-server-address")
1216 .track_focus(&entries[1].focus_handle)
1217 .on_action({
1218 let connection_string = connection_string.clone();
1219 let workspace = self.workspace.clone();
1220 move |_: &menu::Confirm, _, cx| {
1221 callback(workspace.clone(), connection_string.clone(), cx);
1222 }
1223 })
1224 .child(
1225 ListItem::new("copy-server-address")
1226 .toggle_state(
1227 entries[1].focus_handle.contains_focused(window, cx),
1228 )
1229 .inset(true)
1230 .spacing(ui::ListItemSpacing::Sparse)
1231 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1232 .child(Label::new("Copy Server Address"))
1233 .end_hover_slot(
1234 Label::new(connection_string.clone())
1235 .color(Color::Muted),
1236 )
1237 .on_click({
1238 let connection_string = connection_string.clone();
1239 move |_, _, cx| {
1240 callback(
1241 workspace.clone(),
1242 connection_string.clone(),
1243 cx,
1244 );
1245 }
1246 }),
1247 )
1248 })
1249 .child({
1250 fn remove_ssh_server(
1251 remote_servers: Entity<RemoteServerProjects>,
1252 index: usize,
1253 connection_string: SharedString,
1254 window: &mut Window,
1255 cx: &mut App,
1256 ) {
1257 let prompt_message =
1258 format!("Remove server `{}`?", connection_string);
1259
1260 let confirmation = window.prompt(
1261 PromptLevel::Warning,
1262 &prompt_message,
1263 None,
1264 &["Yes, remove it", "No, keep it"],
1265 cx,
1266 );
1267
1268 cx.spawn(async move |cx| {
1269 if confirmation.await.ok() == Some(0) {
1270 remote_servers
1271 .update(cx, |this, cx| {
1272 this.delete_ssh_server(index, cx);
1273 })
1274 .ok();
1275 remote_servers
1276 .update(cx, |this, cx| {
1277 this.mode = Mode::default_mode(
1278 &this.ssh_config_servers,
1279 cx,
1280 );
1281 cx.notify();
1282 })
1283 .ok();
1284 }
1285 anyhow::Ok(())
1286 })
1287 .detach_and_log_err(cx);
1288 }
1289 div()
1290 .id("ssh-options-copy-server-address")
1291 .track_focus(&entries[2].focus_handle)
1292 .on_action(cx.listener({
1293 let connection_string = connection_string.clone();
1294 move |_, _: &menu::Confirm, window, cx| {
1295 remove_ssh_server(
1296 cx.entity(),
1297 server_index,
1298 connection_string.clone(),
1299 window,
1300 cx,
1301 );
1302 cx.focus_self(window);
1303 }
1304 }))
1305 .child(
1306 ListItem::new("remove-server")
1307 .toggle_state(
1308 entries[2].focus_handle.contains_focused(window, cx),
1309 )
1310 .inset(true)
1311 .spacing(ui::ListItemSpacing::Sparse)
1312 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1313 .child(Label::new("Remove Server").color(Color::Error))
1314 .on_click(cx.listener(move |_, _, window, cx| {
1315 remove_ssh_server(
1316 cx.entity(),
1317 server_index,
1318 connection_string.clone(),
1319 window,
1320 cx,
1321 );
1322 cx.focus_self(window);
1323 })),
1324 )
1325 })
1326 .child(ListSeparator)
1327 .child({
1328 div()
1329 .id("ssh-options-copy-server-address")
1330 .track_focus(&entries[3].focus_handle)
1331 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1332 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1333 cx.focus_self(window);
1334 cx.notify();
1335 }))
1336 .child(
1337 ListItem::new("go-back")
1338 .toggle_state(
1339 entries[3].focus_handle.contains_focused(window, cx),
1340 )
1341 .inset(true)
1342 .spacing(ui::ListItemSpacing::Sparse)
1343 .start_slot(
1344 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1345 )
1346 .child(Label::new("Go Back"))
1347 .on_click(cx.listener(|this, _, window, cx| {
1348 this.mode =
1349 Mode::default_mode(&this.ssh_config_servers, cx);
1350 cx.focus_self(window);
1351 cx.notify()
1352 })),
1353 )
1354 }),
1355 )
1356 .into_any_element(),
1357 );
1358 for entry in entries {
1359 view = view.entry(entry);
1360 }
1361
1362 view.render(window, cx).into_any_element()
1363 }
1364
1365 fn render_edit_nickname(
1366 &self,
1367 state: &EditNicknameState,
1368 window: &mut Window,
1369 cx: &mut Context<Self>,
1370 ) -> impl IntoElement {
1371 let Some(connection) = SshSettings::get_global(cx)
1372 .ssh_connections()
1373 .nth(state.index)
1374 else {
1375 return v_flex()
1376 .id("ssh-edit-nickname")
1377 .track_focus(&self.focus_handle(cx));
1378 };
1379
1380 let connection_string = connection.host.clone();
1381 let nickname = connection.nickname.map(|s| s.into());
1382
1383 v_flex()
1384 .id("ssh-edit-nickname")
1385 .track_focus(&self.focus_handle(cx))
1386 .child(
1387 SshConnectionHeader {
1388 connection_string,
1389 paths: Default::default(),
1390 nickname,
1391 }
1392 .render(window, cx),
1393 )
1394 .child(
1395 h_flex()
1396 .p_2()
1397 .border_t_1()
1398 .border_color(cx.theme().colors().border_variant)
1399 .child(state.editor.clone()),
1400 )
1401 }
1402
1403 fn render_default(
1404 &mut self,
1405 mut state: DefaultState,
1406 window: &mut Window,
1407 cx: &mut Context<Self>,
1408 ) -> impl IntoElement {
1409 let ssh_settings = SshSettings::get_global(cx);
1410
1411 let mut should_rebuild = state
1412 .servers
1413 .iter()
1414 .filter_map(|server| match server {
1415 RemoteEntry::Project { connection, .. } => Some(connection),
1416 RemoteEntry::SshConfig { .. } => None,
1417 })
1418 .ne(&ssh_settings.ssh_connections);
1419
1420 if !should_rebuild && ssh_settings.read_ssh_config {
1421 let current_ssh_hosts: BTreeSet<SharedString> = state
1422 .servers
1423 .iter()
1424 .filter_map(|server| match server {
1425 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1426 _ => None,
1427 })
1428 .collect();
1429 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1430 for server in &state.servers {
1431 if let RemoteEntry::Project { connection, .. } = server {
1432 expected_ssh_hosts.remove(&connection.host);
1433 }
1434 }
1435 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
1436 }
1437
1438 if should_rebuild {
1439 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1440 if let Mode::Default(new_state) = &self.mode {
1441 state = new_state.clone();
1442 }
1443 }
1444
1445 let connect_button = div()
1446 .id("ssh-connect-new-server-container")
1447 .track_focus(&state.add_new_server.focus_handle)
1448 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1449 .child(
1450 ListItem::new("register-remove-server-button")
1451 .toggle_state(
1452 state
1453 .add_new_server
1454 .focus_handle
1455 .contains_focused(window, cx),
1456 )
1457 .inset(true)
1458 .spacing(ui::ListItemSpacing::Sparse)
1459 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1460 .child(Label::new("Connect New Server"))
1461 .on_click(cx.listener(|this, _, window, cx| {
1462 let state = CreateRemoteServer::new(window, cx);
1463 this.mode = Mode::CreateRemoteServer(state);
1464
1465 cx.notify();
1466 })),
1467 )
1468 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1469 let state = CreateRemoteServer::new(window, cx);
1470 this.mode = Mode::CreateRemoteServer(state);
1471
1472 cx.notify();
1473 }));
1474
1475 let mut modal_section = Navigable::new(
1476 v_flex()
1477 .track_focus(&self.focus_handle(cx))
1478 .id("ssh-server-list")
1479 .overflow_y_scroll()
1480 .track_scroll(&state.scroll_handle)
1481 .size_full()
1482 .child(connect_button)
1483 .child(
1484 List::new()
1485 .empty_message(
1486 v_flex()
1487 .child(
1488 div().px_3().child(
1489 Label::new("No remote servers registered yet.")
1490 .color(Color::Muted),
1491 ),
1492 )
1493 .into_any_element(),
1494 )
1495 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
1496 self.render_ssh_connection(ix, connection.clone(), window, cx)
1497 .into_any_element()
1498 })),
1499 )
1500 .into_any_element(),
1501 )
1502 .entry(state.add_new_server.clone());
1503
1504 for server in &state.servers {
1505 match server {
1506 RemoteEntry::Project {
1507 open_folder,
1508 projects,
1509 configure,
1510 ..
1511 } => {
1512 for (navigation_state, _) in projects {
1513 modal_section = modal_section.entry(navigation_state.clone());
1514 }
1515 modal_section = modal_section
1516 .entry(open_folder.clone())
1517 .entry(configure.clone());
1518 }
1519 RemoteEntry::SshConfig { open_folder, .. } => {
1520 modal_section = modal_section.entry(open_folder.clone());
1521 }
1522 }
1523 }
1524 let mut modal_section = modal_section.render(window, cx).into_any_element();
1525
1526 let (create_window, reuse_window) = if self.create_new_window {
1527 (
1528 window.keystroke_text_for(&menu::Confirm),
1529 window.keystroke_text_for(&menu::SecondaryConfirm),
1530 )
1531 } else {
1532 (
1533 window.keystroke_text_for(&menu::SecondaryConfirm),
1534 window.keystroke_text_for(&menu::Confirm),
1535 )
1536 };
1537 let placeholder_text = Arc::from(format!(
1538 "{reuse_window} reuses this window, {create_window} opens a new one",
1539 ));
1540
1541 Modal::new("remote-projects", None)
1542 .header(
1543 ModalHeader::new()
1544 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
1545 .child(
1546 Label::new(placeholder_text)
1547 .color(Color::Muted)
1548 .size(LabelSize::XSmall),
1549 ),
1550 )
1551 .section(
1552 Section::new().padded(false).child(
1553 v_flex()
1554 .min_h(rems(20.))
1555 .size_full()
1556 .relative()
1557 .child(ListSeparator)
1558 .child(
1559 canvas(
1560 |bounds, window, cx| {
1561 modal_section.prepaint_as_root(
1562 bounds.origin,
1563 bounds.size.into(),
1564 window,
1565 cx,
1566 );
1567 modal_section
1568 },
1569 |_, mut modal_section, window, cx| {
1570 modal_section.paint(window, cx);
1571 },
1572 )
1573 .size_full(),
1574 )
1575 .vertical_scrollbar_for(state.scroll_handle, window, cx),
1576 ),
1577 )
1578 .into_any_element()
1579 }
1580
1581 fn create_host_from_ssh_config(
1582 &mut self,
1583 ssh_config_host: &SharedString,
1584 cx: &mut Context<'_, Self>,
1585 ) -> usize {
1586 let new_ix = Arc::new(AtomicUsize::new(0));
1587
1588 let update_new_ix = new_ix.clone();
1589 self.update_settings_file(cx, move |settings, _| {
1590 update_new_ix.store(
1591 settings
1592 .ssh_connections
1593 .as_ref()
1594 .map_or(0, |connections| connections.len()),
1595 atomic::Ordering::Release,
1596 );
1597 });
1598
1599 self.add_ssh_server(
1600 SshConnectionOptions {
1601 host: ssh_config_host.to_string(),
1602 ..SshConnectionOptions::default()
1603 },
1604 cx,
1605 );
1606 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1607 new_ix.load(atomic::Ordering::Acquire)
1608 }
1609}
1610
1611fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
1612 let mut user_ssh_config_watcher =
1613 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
1614 let mut global_ssh_config_watcher = watch_config_file(
1615 cx.background_executor(),
1616 fs,
1617 global_ssh_config_file().to_owned(),
1618 );
1619
1620 cx.spawn(async move |remote_server_projects, cx| {
1621 let mut global_hosts = BTreeSet::default();
1622 let mut user_hosts = BTreeSet::default();
1623 let mut running_receivers = 2;
1624
1625 loop {
1626 select! {
1627 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
1628 match new_global_file_contents {
1629 Some(new_global_file_contents) => {
1630 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
1631 if remote_server_projects.update(cx, |remote_server_projects, cx| {
1632 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1633 cx.notify();
1634 }).is_err() {
1635 return;
1636 }
1637 },
1638 None => {
1639 running_receivers -= 1;
1640 if running_receivers == 0 {
1641 return;
1642 }
1643 }
1644 }
1645 },
1646 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
1647 match new_user_file_contents {
1648 Some(new_user_file_contents) => {
1649 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
1650 if remote_server_projects.update(cx, |remote_server_projects, cx| {
1651 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1652 cx.notify();
1653 }).is_err() {
1654 return;
1655 }
1656 },
1657 None => {
1658 running_receivers -= 1;
1659 if running_receivers == 0 {
1660 return;
1661 }
1662 }
1663 }
1664 },
1665 }
1666 }
1667 })
1668}
1669
1670fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
1671 element.read(cx).text(cx).trim().to_string()
1672}
1673
1674impl ModalView for RemoteServerProjects {}
1675
1676impl Focusable for RemoteServerProjects {
1677 fn focus_handle(&self, cx: &App) -> FocusHandle {
1678 match &self.mode {
1679 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1680 _ => self.focus_handle.clone(),
1681 }
1682 }
1683}
1684
1685impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1686
1687impl Render for RemoteServerProjects {
1688 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1689 div()
1690 .elevation_3(cx)
1691 .w(rems(34.))
1692 .key_context("RemoteServerModal")
1693 .on_action(cx.listener(Self::cancel))
1694 .on_action(cx.listener(Self::confirm))
1695 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
1696 this.focus_handle(cx).focus(window);
1697 }))
1698 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
1699 if matches!(this.mode, Mode::Default(_)) {
1700 cx.emit(DismissEvent)
1701 }
1702 }))
1703 .child(match &self.mode {
1704 Mode::Default(state) => self
1705 .render_default(state.clone(), window, cx)
1706 .into_any_element(),
1707 Mode::ViewServerOptions(state) => self
1708 .render_view_options(state.clone(), window, cx)
1709 .into_any_element(),
1710 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1711 Mode::CreateRemoteServer(state) => self
1712 .render_create_remote_server(state, window, cx)
1713 .into_any_element(),
1714 Mode::EditNickname(state) => self
1715 .render_edit_nickname(state, window, cx)
1716 .into_any_element(),
1717 })
1718 }
1719}