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