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