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