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::ssh_config::parse_ssh_config_hosts;
54use crate::ssh_connections::RemoteSettingsContent;
55use crate::ssh_connections::SshConnection;
56use crate::ssh_connections::SshConnectionHeader;
57use crate::ssh_connections::SshConnectionModal;
58use crate::ssh_connections::SshProject;
59use crate::ssh_connections::SshPrompt;
60use crate::ssh_connections::SshSettings;
61use crate::ssh_connections::connect_over_ssh;
62use crate::ssh_connections::open_ssh_project;
63
64mod navigation_base {}
65pub struct RemoteServerProjects {
66 mode: Mode,
67 focus_handle: FocusHandle,
68 workspace: WeakEntity<Workspace>,
69 retained_connections: Vec<Entity<SshRemoteClient>>,
70 ssh_config_updates: Task<()>,
71 ssh_config_servers: BTreeSet<SharedString>,
72 _subscription: Subscription,
73}
74
75struct CreateRemoteServer {
76 address_editor: Entity<Editor>,
77 address_error: Option<SharedString>,
78 ssh_prompt: Option<Entity<SshPrompt>>,
79 _creating: Option<Task<Option<()>>>,
80}
81
82impl CreateRemoteServer {
83 fn new(window: &mut Window, cx: &mut App) -> Self {
84 let address_editor = cx.new(|cx| Editor::single_line(window, cx));
85 address_editor.update(cx, |this, cx| {
86 this.focus_handle(cx).focus(window);
87 });
88 Self {
89 address_editor,
90 address_error: None,
91 ssh_prompt: None,
92 _creating: None,
93 }
94 }
95}
96
97struct ProjectPicker {
98 connection_string: SharedString,
99 nickname: Option<SharedString>,
100 picker: Entity<Picker<OpenPathDelegate>>,
101 _path_task: Shared<Task<Option<()>>>,
102}
103
104struct EditNicknameState {
105 index: usize,
106 editor: Entity<Editor>,
107}
108
109impl EditNicknameState {
110 fn new(index: usize, window: &mut Window, cx: &mut App) -> Self {
111 let this = Self {
112 index,
113 editor: cx.new(|cx| Editor::single_line(window, cx)),
114 };
115 let starting_text = SshSettings::get_global(cx)
116 .ssh_connections()
117 .nth(index)
118 .and_then(|state| state.nickname.clone())
119 .filter(|text| !text.is_empty());
120 this.editor.update(cx, |this, cx| {
121 this.set_placeholder_text("Add a nickname for this server", cx);
122 if let Some(starting_text) = starting_text {
123 this.set_text(starting_text, window, cx);
124 }
125 });
126 this.editor.focus_handle(cx).focus(window);
127 this
128 }
129}
130
131impl Focusable for ProjectPicker {
132 fn focus_handle(&self, cx: &App) -> FocusHandle {
133 self.picker.focus_handle(cx)
134 }
135}
136
137impl ProjectPicker {
138 fn new(
139 ix: usize,
140 connection: SshConnectionOptions,
141 project: Entity<Project>,
142 home_dir: PathBuf,
143 workspace: WeakEntity<Workspace>,
144 window: &mut Window,
145 cx: &mut Context<RemoteServerProjects>,
146 ) -> Entity<Self> {
147 let (tx, rx) = oneshot::channel();
148 let lister = project::DirectoryLister::Project(project.clone());
149 let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
150
151 let picker = cx.new(|cx| {
152 let picker = Picker::uniform_list(delegate, window, cx)
153 .width(rems(34.))
154 .modal(false);
155 picker.set_query(home_dir.to_string_lossy().to_string(), window, cx);
156 picker
157 });
158 let connection_string = connection.connection_string().into();
159 let nickname = connection.nickname.clone().map(|nick| nick.into());
160 let _path_task = cx
161 .spawn_in(window, {
162 let workspace = workspace.clone();
163 async move |this, cx| {
164 let Ok(Some(paths)) = rx.await else {
165 workspace
166 .update_in(cx, |workspace, window, cx| {
167 let fs = workspace.project().read(cx).fs().clone();
168 let weak = cx.entity().downgrade();
169 workspace.toggle_modal(window, cx, |window, cx| {
170 RemoteServerProjects::new(fs, window, cx, weak)
171 });
172 })
173 .log_err()?;
174 return None;
175 };
176
177 let app_state = workspace
178 .read_with(cx, |workspace, _| workspace.app_state().clone())
179 .ok()?;
180
181 cx.update(|_, cx| {
182 let fs = app_state.fs.clone();
183 update_settings_file::<SshSettings>(fs, cx, {
184 let paths = paths
185 .iter()
186 .map(|path| path.to_string_lossy().to_string())
187 .collect();
188 move |setting, _| {
189 if let Some(server) = setting
190 .ssh_connections
191 .as_mut()
192 .and_then(|connections| connections.get_mut(ix))
193 {
194 server.projects.insert(SshProject { paths });
195 }
196 }
197 });
198 })
199 .log_err();
200
201 let options = cx
202 .update(|_, cx| (app_state.build_window_options)(None, cx))
203 .log_err()?;
204 let window = cx
205 .open_window(options, |window, cx| {
206 cx.new(|cx| {
207 telemetry::event!("SSH Project Created");
208 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
209 })
210 })
211 .log_err()?;
212
213 open_ssh_project_with_existing_connection(
214 connection, project, paths, app_state, window, cx,
215 )
216 .await
217 .log_err();
218
219 this.update(cx, |_, cx| {
220 cx.emit(DismissEvent);
221 })
222 .ok();
223 Some(())
224 }
225 })
226 .shared();
227 cx.new(|_| Self {
228 _path_task,
229 picker,
230 connection_string,
231 nickname,
232 })
233 }
234}
235
236impl gpui::Render for ProjectPicker {
237 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
238 v_flex()
239 .child(
240 SshConnectionHeader {
241 connection_string: self.connection_string.clone(),
242 paths: Default::default(),
243 nickname: self.nickname.clone(),
244 }
245 .render(window, cx),
246 )
247 .child(
248 div()
249 .border_t_1()
250 .border_color(cx.theme().colors().border_variant)
251 .child(self.picker.clone()),
252 )
253 }
254}
255
256#[derive(Clone)]
257enum RemoteEntry {
258 Project {
259 open_folder: NavigableEntry,
260 projects: Vec<(NavigableEntry, SshProject)>,
261 configure: NavigableEntry,
262 connection: SshConnection,
263 },
264 SshConfig {
265 open_folder: NavigableEntry,
266 host: SharedString,
267 },
268}
269
270impl RemoteEntry {
271 fn is_from_zed(&self) -> bool {
272 matches!(self, Self::Project { .. })
273 }
274
275 fn connection(&self) -> Cow<SshConnection> {
276 match self {
277 Self::Project { connection, .. } => Cow::Borrowed(connection),
278 Self::SshConfig { host, .. } => Cow::Owned(SshConnection {
279 host: host.clone(),
280 ..SshConnection::default()
281 }),
282 }
283 }
284}
285
286#[derive(Clone)]
287struct DefaultState {
288 scrollbar: ScrollbarState,
289 add_new_server: NavigableEntry,
290 servers: Vec<RemoteEntry>,
291}
292
293impl DefaultState {
294 fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
295 let handle = ScrollHandle::new();
296 let scrollbar = ScrollbarState::new(handle.clone());
297 let add_new_server = NavigableEntry::new(&handle, cx);
298
299 let ssh_settings = SshSettings::get_global(cx);
300 let read_ssh_config = ssh_settings.read_ssh_config;
301
302 let mut servers: Vec<RemoteEntry> = ssh_settings
303 .ssh_connections()
304 .map(|connection| {
305 let open_folder = NavigableEntry::new(&handle, cx);
306 let configure = NavigableEntry::new(&handle, cx);
307 let projects = connection
308 .projects
309 .iter()
310 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
311 .collect();
312 RemoteEntry::Project {
313 open_folder,
314 configure,
315 projects,
316 connection,
317 }
318 })
319 .collect();
320
321 if read_ssh_config {
322 let mut extra_servers_from_config = ssh_config_servers.clone();
323 for server in &servers {
324 if let RemoteEntry::Project { connection, .. } = server {
325 extra_servers_from_config.remove(&connection.host);
326 }
327 }
328 servers.extend(extra_servers_from_config.into_iter().map(|host| {
329 RemoteEntry::SshConfig {
330 open_folder: NavigableEntry::new(&handle, cx),
331 host,
332 }
333 }));
334 }
335
336 Self {
337 scrollbar,
338 add_new_server,
339 servers,
340 }
341 }
342}
343
344#[derive(Clone)]
345struct ViewServerOptionsState {
346 server_index: usize,
347 connection: SshConnection,
348 entries: [NavigableEntry; 4],
349}
350enum Mode {
351 Default(DefaultState),
352 ViewServerOptions(ViewServerOptionsState),
353 EditNickname(EditNicknameState),
354 ProjectPicker(Entity<ProjectPicker>),
355 CreateRemoteServer(CreateRemoteServer),
356}
357
358impl Mode {
359 fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
360 Self::Default(DefaultState::new(ssh_config_servers, cx))
361 }
362}
363impl RemoteServerProjects {
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(&BTreeSet::new(), 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(&this.ssh_config_servers, 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(&self.ssh_config_servers, 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(&self.ssh_config_servers, 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(
1233 &this.ssh_config_servers,
1234 cx,
1235 );
1236 cx.notify();
1237 })
1238 .ok();
1239 }
1240 anyhow::Ok(())
1241 })
1242 .detach_and_log_err(cx);
1243 }
1244 div()
1245 .id("ssh-options-copy-server-address")
1246 .track_focus(&entries[2].focus_handle)
1247 .on_action(cx.listener({
1248 let connection_string = connection_string.clone();
1249 move |_, _: &menu::Confirm, window, cx| {
1250 remove_ssh_server(
1251 cx.entity().clone(),
1252 server_index,
1253 connection_string.clone(),
1254 window,
1255 cx,
1256 );
1257 cx.focus_self(window);
1258 }
1259 }))
1260 .child(
1261 ListItem::new("remove-server")
1262 .toggle_state(
1263 entries[2].focus_handle.contains_focused(window, cx),
1264 )
1265 .inset(true)
1266 .spacing(ui::ListItemSpacing::Sparse)
1267 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1268 .child(Label::new("Remove Server").color(Color::Error))
1269 .on_click(cx.listener(move |_, _, window, cx| {
1270 remove_ssh_server(
1271 cx.entity().clone(),
1272 server_index,
1273 connection_string.clone(),
1274 window,
1275 cx,
1276 );
1277 cx.focus_self(window);
1278 })),
1279 )
1280 })
1281 .child(ListSeparator)
1282 .child({
1283 div()
1284 .id("ssh-options-copy-server-address")
1285 .track_focus(&entries[3].focus_handle)
1286 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1287 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1288 cx.focus_self(window);
1289 cx.notify();
1290 }))
1291 .child(
1292 ListItem::new("go-back")
1293 .toggle_state(
1294 entries[3].focus_handle.contains_focused(window, cx),
1295 )
1296 .inset(true)
1297 .spacing(ui::ListItemSpacing::Sparse)
1298 .start_slot(
1299 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1300 )
1301 .child(Label::new("Go Back"))
1302 .on_click(cx.listener(|this, _, window, cx| {
1303 this.mode =
1304 Mode::default_mode(&this.ssh_config_servers, cx);
1305 cx.focus_self(window);
1306 cx.notify()
1307 })),
1308 )
1309 }),
1310 )
1311 .into_any_element(),
1312 );
1313 for entry in entries {
1314 view = view.entry(entry);
1315 }
1316
1317 view.render(window, cx).into_any_element()
1318 }
1319
1320 fn render_edit_nickname(
1321 &self,
1322 state: &EditNicknameState,
1323 window: &mut Window,
1324 cx: &mut Context<Self>,
1325 ) -> impl IntoElement {
1326 let Some(connection) = SshSettings::get_global(cx)
1327 .ssh_connections()
1328 .nth(state.index)
1329 else {
1330 return v_flex()
1331 .id("ssh-edit-nickname")
1332 .track_focus(&self.focus_handle(cx));
1333 };
1334
1335 let connection_string = connection.host.clone();
1336 let nickname = connection.nickname.clone().map(|s| s.into());
1337
1338 v_flex()
1339 .id("ssh-edit-nickname")
1340 .track_focus(&self.focus_handle(cx))
1341 .child(
1342 SshConnectionHeader {
1343 connection_string,
1344 paths: Default::default(),
1345 nickname,
1346 }
1347 .render(window, cx),
1348 )
1349 .child(
1350 h_flex()
1351 .p_2()
1352 .border_t_1()
1353 .border_color(cx.theme().colors().border_variant)
1354 .child(state.editor.clone()),
1355 )
1356 }
1357
1358 fn render_default(
1359 &mut self,
1360 mut state: DefaultState,
1361 window: &mut Window,
1362 cx: &mut Context<Self>,
1363 ) -> impl IntoElement {
1364 let ssh_settings = SshSettings::get_global(cx);
1365 let mut should_rebuild = false;
1366
1367 if ssh_settings
1368 .ssh_connections
1369 .as_ref()
1370 .map_or(false, |connections| {
1371 state
1372 .servers
1373 .iter()
1374 .filter_map(|server| match server {
1375 RemoteEntry::Project { connection, .. } => Some(connection),
1376 RemoteEntry::SshConfig { .. } => None,
1377 })
1378 .ne(connections.iter())
1379 })
1380 {
1381 should_rebuild = true;
1382 };
1383
1384 if !should_rebuild && ssh_settings.read_ssh_config {
1385 let current_ssh_hosts: BTreeSet<SharedString> = state
1386 .servers
1387 .iter()
1388 .filter_map(|server| match server {
1389 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1390 _ => None,
1391 })
1392 .collect();
1393 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1394 for server in &state.servers {
1395 if let RemoteEntry::Project { connection, .. } = server {
1396 expected_ssh_hosts.remove(&connection.host);
1397 }
1398 }
1399 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
1400 }
1401
1402 if should_rebuild {
1403 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1404 if let Mode::Default(new_state) = &self.mode {
1405 state = new_state.clone();
1406 }
1407 }
1408
1409 let scroll_state = state.scrollbar.parent_entity(&cx.entity());
1410 let connect_button = div()
1411 .id("ssh-connect-new-server-container")
1412 .track_focus(&state.add_new_server.focus_handle)
1413 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1414 .child(
1415 ListItem::new("register-remove-server-button")
1416 .toggle_state(
1417 state
1418 .add_new_server
1419 .focus_handle
1420 .contains_focused(window, cx),
1421 )
1422 .inset(true)
1423 .spacing(ui::ListItemSpacing::Sparse)
1424 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1425 .child(Label::new("Connect New Server"))
1426 .on_click(cx.listener(|this, _, window, cx| {
1427 let state = CreateRemoteServer::new(window, cx);
1428 this.mode = Mode::CreateRemoteServer(state);
1429
1430 cx.notify();
1431 })),
1432 )
1433 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1434 let state = CreateRemoteServer::new(window, cx);
1435 this.mode = Mode::CreateRemoteServer(state);
1436
1437 cx.notify();
1438 }));
1439
1440 let handle = &**scroll_state.scroll_handle() as &dyn Any;
1441 let Some(scroll_handle) = handle.downcast_ref::<ScrollHandle>() else {
1442 unreachable!()
1443 };
1444
1445 let mut modal_section = Navigable::new(
1446 v_flex()
1447 .track_focus(&self.focus_handle(cx))
1448 .id("ssh-server-list")
1449 .overflow_y_scroll()
1450 .track_scroll(&scroll_handle)
1451 .size_full()
1452 .child(connect_button)
1453 .child(
1454 List::new()
1455 .empty_message(
1456 v_flex()
1457 .child(
1458 div().px_3().child(
1459 Label::new("No remote servers registered yet.")
1460 .color(Color::Muted),
1461 ),
1462 )
1463 .into_any_element(),
1464 )
1465 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
1466 self.render_ssh_connection(ix, connection.clone(), window, cx)
1467 .into_any_element()
1468 })),
1469 )
1470 .into_any_element(),
1471 )
1472 .entry(state.add_new_server.clone());
1473
1474 for server in &state.servers {
1475 match server {
1476 RemoteEntry::Project {
1477 open_folder,
1478 projects,
1479 configure,
1480 ..
1481 } => {
1482 for (navigation_state, _) in projects {
1483 modal_section = modal_section.entry(navigation_state.clone());
1484 }
1485 modal_section = modal_section
1486 .entry(open_folder.clone())
1487 .entry(configure.clone());
1488 }
1489 RemoteEntry::SshConfig { open_folder, .. } => {
1490 modal_section = modal_section.entry(open_folder.clone());
1491 }
1492 }
1493 }
1494 let mut modal_section = modal_section.render(window, cx).into_any_element();
1495
1496 Modal::new("remote-projects", None)
1497 .header(
1498 ModalHeader::new()
1499 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)),
1500 )
1501 .section(
1502 Section::new().padded(false).child(
1503 v_flex()
1504 .min_h(rems(20.))
1505 .size_full()
1506 .relative()
1507 .child(ListSeparator)
1508 .child(
1509 canvas(
1510 |bounds, window, cx| {
1511 modal_section.prepaint_as_root(
1512 bounds.origin,
1513 bounds.size.into(),
1514 window,
1515 cx,
1516 );
1517 modal_section
1518 },
1519 |_, mut modal_section, window, cx| {
1520 modal_section.paint(window, cx);
1521 },
1522 )
1523 .size_full(),
1524 )
1525 .child(
1526 div()
1527 .occlude()
1528 .h_full()
1529 .absolute()
1530 .top_1()
1531 .bottom_1()
1532 .right_1()
1533 .w(px(8.))
1534 .children(Scrollbar::vertical(scroll_state)),
1535 ),
1536 ),
1537 )
1538 .into_any_element()
1539 }
1540
1541 fn create_host_from_ssh_config(
1542 &mut self,
1543 ssh_config_host: &SharedString,
1544 cx: &mut Context<'_, Self>,
1545 ) -> usize {
1546 let new_ix = Arc::new(AtomicUsize::new(0));
1547
1548 let update_new_ix = new_ix.clone();
1549 self.update_settings_file(cx, move |settings, _| {
1550 update_new_ix.store(
1551 settings
1552 .ssh_connections
1553 .as_ref()
1554 .map_or(0, |connections| connections.len()),
1555 atomic::Ordering::Release,
1556 );
1557 });
1558
1559 self.add_ssh_server(
1560 SshConnectionOptions {
1561 host: ssh_config_host.to_string(),
1562 ..SshConnectionOptions::default()
1563 },
1564 cx,
1565 );
1566 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1567 new_ix.load(atomic::Ordering::Acquire)
1568 }
1569}
1570
1571fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
1572 let mut user_ssh_config_watcher =
1573 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
1574 let mut global_ssh_config_watcher = watch_config_file(
1575 cx.background_executor(),
1576 fs,
1577 global_ssh_config_file().to_owned(),
1578 );
1579
1580 cx.spawn(async move |remote_server_projects, cx| {
1581 let mut global_hosts = BTreeSet::default();
1582 let mut user_hosts = BTreeSet::default();
1583 let mut running_receivers = 2;
1584
1585 loop {
1586 select! {
1587 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
1588 match new_global_file_contents {
1589 Some(new_global_file_contents) => {
1590 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
1591 if remote_server_projects.update(cx, |remote_server_projects, cx| {
1592 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1593 cx.notify();
1594 }).is_err() {
1595 return;
1596 }
1597 },
1598 None => {
1599 running_receivers -= 1;
1600 if running_receivers == 0 {
1601 return;
1602 }
1603 }
1604 }
1605 },
1606 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
1607 match new_user_file_contents {
1608 Some(new_user_file_contents) => {
1609 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
1610 if remote_server_projects.update(cx, |remote_server_projects, cx| {
1611 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
1612 cx.notify();
1613 }).is_err() {
1614 return;
1615 }
1616 },
1617 None => {
1618 running_receivers -= 1;
1619 if running_receivers == 0 {
1620 return;
1621 }
1622 }
1623 }
1624 },
1625 }
1626 }
1627 })
1628}
1629
1630fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
1631 element.read(cx).text(cx).trim().to_string()
1632}
1633
1634impl ModalView for RemoteServerProjects {}
1635
1636impl Focusable for RemoteServerProjects {
1637 fn focus_handle(&self, cx: &App) -> FocusHandle {
1638 match &self.mode {
1639 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
1640 _ => self.focus_handle.clone(),
1641 }
1642 }
1643}
1644
1645impl EventEmitter<DismissEvent> for RemoteServerProjects {}
1646
1647impl Render for RemoteServerProjects {
1648 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1649 div()
1650 .elevation_3(cx)
1651 .w(rems(34.))
1652 .key_context("RemoteServerModal")
1653 .on_action(cx.listener(Self::cancel))
1654 .on_action(cx.listener(Self::confirm))
1655 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
1656 this.focus_handle(cx).focus(window);
1657 }))
1658 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
1659 if matches!(this.mode, Mode::Default(_)) {
1660 cx.emit(DismissEvent)
1661 }
1662 }))
1663 .child(match &self.mode {
1664 Mode::Default(state) => self
1665 .render_default(state.clone(), window, cx)
1666 .into_any_element(),
1667 Mode::ViewServerOptions(state) => self
1668 .render_view_options(state.clone(), window, cx)
1669 .into_any_element(),
1670 Mode::ProjectPicker(element) => element.clone().into_any_element(),
1671 Mode::CreateRemoteServer(state) => self
1672 .render_create_remote_server(state, cx)
1673 .into_any_element(),
1674 Mode::EditNickname(state) => self
1675 .render_edit_nickname(state, window, cx)
1676 .into_any_element(),
1677 })
1678 }
1679}