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