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