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