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