1use crate::{
2 remote_connections::{
3 Connection, RemoteConnectionModal, RemoteConnectionPrompt, SshConnection,
4 SshConnectionHeader, SshSettings, connect, 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 log::info;
17use paths::{global_ssh_config_file, user_ssh_config_file};
18use picker::Picker;
19use project::{Fs, Project};
20use remote::{
21 RemoteClient, RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions,
22 remote_client::ConnectionIdentifier,
23};
24use settings::{
25 RemoteSettingsContent, Settings as _, SettingsStore, SshProject, update_settings_file,
26 watch_config_file,
27};
28use smol::stream::StreamExt as _;
29use std::{
30 borrow::Cow,
31 collections::BTreeSet,
32 path::PathBuf,
33 rc::Rc,
34 sync::{
35 Arc,
36 atomic::{self, AtomicUsize},
37 },
38};
39use ui::{
40 IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
41 Section, Tooltip, WithScrollbar, prelude::*,
42};
43use util::{
44 ResultExt,
45 paths::{PathStyle, RemotePathBuf},
46};
47use workspace::{
48 ModalView, OpenOptions, Toast, Workspace,
49 notifications::{DetachAndPromptErr, NotificationId},
50 open_remote_project_with_existing_connection,
51};
52
53pub struct RemoteServerProjects {
54 mode: Mode,
55 focus_handle: FocusHandle,
56 workspace: WeakEntity<Workspace>,
57 retained_connections: Vec<Entity<RemoteClient>>,
58 ssh_config_updates: Task<()>,
59 ssh_config_servers: BTreeSet<SharedString>,
60 create_new_window: bool,
61 _subscription: Subscription,
62}
63
64struct CreateRemoteServer {
65 address_editor: Entity<Editor>,
66 address_error: Option<SharedString>,
67 ssh_prompt: Option<Entity<RemoteConnectionPrompt>>,
68 _creating: Option<Task<Option<()>>>,
69}
70
71impl CreateRemoteServer {
72 fn new(window: &mut Window, cx: &mut App) -> Self {
73 let address_editor = cx.new(|cx| Editor::single_line(window, cx));
74 address_editor.update(cx, |this, cx| {
75 this.focus_handle(cx).focus(window);
76 });
77 Self {
78 address_editor,
79 address_error: None,
80 ssh_prompt: None,
81 _creating: None,
82 }
83 }
84}
85
86#[cfg(target_os = "windows")]
87struct AddWslDistro {
88 picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
89 connection_prompt: Option<Entity<RemoteConnectionPrompt>>,
90 _creating: Option<Task<()>>,
91}
92
93#[cfg(target_os = "windows")]
94impl AddWslDistro {
95 fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
96 use crate::wsl_picker::{WslDistroSelected, WslPickerDelegate, WslPickerDismissed};
97
98 let delegate = WslPickerDelegate::new();
99 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
100
101 cx.subscribe_in(
102 &picker,
103 window,
104 |this, _, _: &WslDistroSelected, window, cx| {
105 this.confirm(&menu::Confirm, window, cx);
106 },
107 )
108 .detach();
109
110 cx.subscribe_in(
111 &picker,
112 window,
113 |this, _, _: &WslPickerDismissed, window, cx| {
114 this.cancel(&menu::Cancel, window, cx);
115 },
116 )
117 .detach();
118
119 AddWslDistro {
120 picker,
121 connection_prompt: None,
122 _creating: None,
123 }
124 }
125}
126
127enum ProjectPickerData {
128 Ssh {
129 connection_string: SharedString,
130 nickname: Option<SharedString>,
131 },
132 Wsl {
133 distro_name: SharedString,
134 },
135}
136
137struct ProjectPicker {
138 data: ProjectPickerData,
139 picker: Entity<Picker<OpenPathDelegate>>,
140 _path_task: Shared<Task<Option<()>>>,
141}
142
143struct EditNicknameState {
144 index: SshServerIndex,
145 editor: Entity<Editor>,
146}
147
148impl EditNicknameState {
149 fn new(index: SshServerIndex, window: &mut Window, cx: &mut App) -> Self {
150 let this = Self {
151 index,
152 editor: cx.new(|cx| Editor::single_line(window, cx)),
153 };
154 let starting_text = SshSettings::get_global(cx)
155 .ssh_connections()
156 .nth(index.0)
157 .and_then(|state| state.nickname)
158 .filter(|text| !text.is_empty());
159 this.editor.update(cx, |this, cx| {
160 this.set_placeholder_text("Add a nickname for this server", window, cx);
161 if let Some(starting_text) = starting_text {
162 this.set_text(starting_text, window, cx);
163 }
164 });
165 this.editor.focus_handle(cx).focus(window);
166 this
167 }
168}
169
170impl Focusable for ProjectPicker {
171 fn focus_handle(&self, cx: &App) -> FocusHandle {
172 self.picker.focus_handle(cx)
173 }
174}
175
176impl ProjectPicker {
177 fn new(
178 create_new_window: bool,
179 index: ServerIndex,
180 connection: RemoteConnectionOptions,
181 project: Entity<Project>,
182 home_dir: RemotePathBuf,
183 path_style: PathStyle,
184 workspace: WeakEntity<Workspace>,
185 window: &mut Window,
186 cx: &mut Context<RemoteServerProjects>,
187 ) -> Entity<Self> {
188 let (tx, rx) = oneshot::channel();
189 let lister = project::DirectoryLister::Project(project.clone());
190 let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style);
191
192 let picker = cx.new(|cx| {
193 let picker = Picker::uniform_list(delegate, window, cx)
194 .width(rems(34.))
195 .modal(false);
196 picker.set_query(home_dir.to_string(), window, cx);
197 picker
198 });
199
200 let data = match &connection {
201 RemoteConnectionOptions::Ssh(connection) => ProjectPickerData::Ssh {
202 connection_string: connection.connection_string().into(),
203 nickname: connection.nickname.clone().map(|nick| nick.into()),
204 },
205 RemoteConnectionOptions::Wsl(connection) => ProjectPickerData::Wsl {
206 distro_name: connection.distro_name.clone().into(),
207 },
208 };
209 let _path_task = cx
210 .spawn_in(window, {
211 let workspace = workspace;
212 async move |this, cx| {
213 let Ok(Some(paths)) = rx.await else {
214 workspace
215 .update_in(cx, |workspace, window, cx| {
216 let fs = workspace.project().read(cx).fs().clone();
217 let weak = cx.entity().downgrade();
218 workspace.toggle_modal(window, cx, |window, cx| {
219 RemoteServerProjects::new(
220 create_new_window,
221 fs,
222 window,
223 weak,
224 cx,
225 )
226 });
227 })
228 .log_err()?;
229 return None;
230 };
231
232 let app_state = workspace
233 .read_with(cx, |workspace, _| workspace.app_state().clone())
234 .ok()?;
235
236 cx.update(|_, cx| {
237 let fs = app_state.fs.clone();
238 update_settings_file(fs, cx, {
239 let paths = paths
240 .iter()
241 .map(|path| path.to_string_lossy().to_string())
242 .collect();
243 move |settings, _| match index {
244 ServerIndex::Ssh(index) => {
245 if let Some(server) = settings
246 .remote
247 .ssh_connections
248 .as_mut()
249 .and_then(|connections| connections.get_mut(index.0))
250 {
251 server.projects.insert(SshProject { paths });
252 };
253 }
254 ServerIndex::Wsl(index) => {
255 if let Some(server) = settings
256 .remote
257 .wsl_connections
258 .as_mut()
259 .and_then(|connections| connections.get_mut(index.0))
260 {
261 server.projects.insert(SshProject { paths });
262 };
263 }
264 }
265 });
266 })
267 .log_err();
268
269 let options = cx
270 .update(|_, cx| (app_state.build_window_options)(None, cx))
271 .log_err()?;
272 let window = cx
273 .open_window(options, |window, cx| {
274 cx.new(|cx| {
275 telemetry::event!("SSH Project Created");
276 Workspace::new(None, project.clone(), app_state.clone(), window, cx)
277 })
278 })
279 .log_err()?;
280
281 open_remote_project_with_existing_connection(
282 connection, project, paths, app_state, window, cx,
283 )
284 .await
285 .log_err();
286
287 this.update(cx, |_, cx| {
288 cx.emit(DismissEvent);
289 })
290 .ok();
291 Some(())
292 }
293 })
294 .shared();
295 cx.new(|_| Self {
296 _path_task,
297 picker,
298 data,
299 })
300 }
301}
302
303impl gpui::Render for ProjectPicker {
304 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
305 v_flex()
306 .child(match &self.data {
307 ProjectPickerData::Ssh {
308 connection_string,
309 nickname,
310 } => SshConnectionHeader {
311 connection_string: connection_string.clone(),
312 paths: Default::default(),
313 nickname: nickname.clone(),
314 is_wsl: false,
315 }
316 .render(window, cx),
317 ProjectPickerData::Wsl { distro_name } => SshConnectionHeader {
318 connection_string: distro_name.clone(),
319 paths: Default::default(),
320 nickname: None,
321 is_wsl: true,
322 }
323 .render(window, cx),
324 })
325 .child(
326 div()
327 .border_t_1()
328 .border_color(cx.theme().colors().border_variant)
329 .child(self.picker.clone()),
330 )
331 }
332}
333
334#[repr(transparent)]
335#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
336struct SshServerIndex(usize);
337impl std::fmt::Display for SshServerIndex {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 self.0.fmt(f)
340 }
341}
342
343#[repr(transparent)]
344#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
345struct WslServerIndex(usize);
346impl std::fmt::Display for WslServerIndex {
347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348 self.0.fmt(f)
349 }
350}
351
352#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
353enum ServerIndex {
354 Ssh(SshServerIndex),
355 Wsl(WslServerIndex),
356}
357impl From<SshServerIndex> for ServerIndex {
358 fn from(index: SshServerIndex) -> Self {
359 Self::Ssh(index)
360 }
361}
362impl From<WslServerIndex> for ServerIndex {
363 fn from(index: WslServerIndex) -> Self {
364 Self::Wsl(index)
365 }
366}
367
368#[derive(Clone)]
369enum RemoteEntry {
370 Project {
371 open_folder: NavigableEntry,
372 projects: Vec<(NavigableEntry, SshProject)>,
373 configure: NavigableEntry,
374 connection: Connection,
375 index: ServerIndex,
376 },
377 SshConfig {
378 open_folder: NavigableEntry,
379 host: SharedString,
380 },
381}
382
383impl RemoteEntry {
384 fn is_from_zed(&self) -> bool {
385 matches!(self, Self::Project { .. })
386 }
387
388 fn connection(&self) -> Cow<'_, Connection> {
389 match self {
390 Self::Project { connection, .. } => Cow::Borrowed(connection),
391 Self::SshConfig { host, .. } => Cow::Owned(
392 SshConnection {
393 host: host.clone(),
394 ..SshConnection::default()
395 }
396 .into(),
397 ),
398 }
399 }
400}
401
402#[derive(Clone)]
403struct DefaultState {
404 scroll_handle: ScrollHandle,
405 add_new_server: NavigableEntry,
406 add_new_wsl: NavigableEntry,
407 servers: Vec<RemoteEntry>,
408}
409
410impl DefaultState {
411 fn new(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
412 let handle = ScrollHandle::new();
413 let add_new_server = NavigableEntry::new(&handle, cx);
414 let add_new_wsl = NavigableEntry::new(&handle, cx);
415
416 let ssh_settings = SshSettings::get_global(cx);
417 let read_ssh_config = ssh_settings.read_ssh_config;
418
419 let ssh_servers = ssh_settings
420 .ssh_connections()
421 .enumerate()
422 .map(|(index, connection)| {
423 let open_folder = NavigableEntry::new(&handle, cx);
424 let configure = NavigableEntry::new(&handle, cx);
425 let projects = connection
426 .projects
427 .iter()
428 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
429 .collect();
430 RemoteEntry::Project {
431 open_folder,
432 configure,
433 projects,
434 index: ServerIndex::Ssh(SshServerIndex(index)),
435 connection: connection.into(),
436 }
437 });
438
439 let wsl_servers = ssh_settings
440 .wsl_connections()
441 .enumerate()
442 .map(|(index, connection)| {
443 let open_folder = NavigableEntry::new(&handle, cx);
444 let configure = NavigableEntry::new(&handle, cx);
445 let projects = connection
446 .projects
447 .iter()
448 .map(|project| (NavigableEntry::new(&handle, cx), project.clone()))
449 .collect();
450 RemoteEntry::Project {
451 open_folder,
452 configure,
453 projects,
454 index: ServerIndex::Wsl(WslServerIndex(index)),
455 connection: connection.into(),
456 }
457 });
458
459 let mut servers = ssh_servers.chain(wsl_servers).collect::<Vec<RemoteEntry>>();
460
461 if read_ssh_config {
462 let mut extra_servers_from_config = ssh_config_servers.clone();
463 for server in &servers {
464 if let RemoteEntry::Project {
465 connection: Connection::Ssh(ssh_options),
466 ..
467 } = server
468 {
469 extra_servers_from_config.remove(&SharedString::new(ssh_options.host.clone()));
470 }
471 }
472 servers.extend(extra_servers_from_config.into_iter().map(|host| {
473 RemoteEntry::SshConfig {
474 open_folder: NavigableEntry::new(&handle, cx),
475 host,
476 }
477 }));
478 }
479
480 Self {
481 scroll_handle: handle,
482 add_new_server,
483 add_new_wsl,
484 servers,
485 }
486 }
487}
488
489#[derive(Clone)]
490enum ViewServerOptionsState {
491 Ssh {
492 connection: SshConnectionOptions,
493 server_index: SshServerIndex,
494 entries: [NavigableEntry; 4],
495 },
496 Wsl {
497 connection: WslConnectionOptions,
498 server_index: WslServerIndex,
499 entries: [NavigableEntry; 2],
500 },
501}
502
503impl ViewServerOptionsState {
504 fn entries(&self) -> &[NavigableEntry] {
505 match self {
506 Self::Ssh { entries, .. } => entries,
507 Self::Wsl { entries, .. } => entries,
508 }
509 }
510}
511
512enum Mode {
513 Default(DefaultState),
514 ViewServerOptions(ViewServerOptionsState),
515 EditNickname(EditNicknameState),
516 ProjectPicker(Entity<ProjectPicker>),
517 CreateRemoteServer(CreateRemoteServer),
518 #[cfg(target_os = "windows")]
519 AddWslDistro(AddWslDistro),
520}
521
522impl Mode {
523 fn default_mode(ssh_config_servers: &BTreeSet<SharedString>, cx: &mut App) -> Self {
524 Self::Default(DefaultState::new(ssh_config_servers, cx))
525 }
526}
527
528impl RemoteServerProjects {
529 #[cfg(target_os = "windows")]
530 pub fn wsl(
531 create_new_window: bool,
532 fs: Arc<dyn Fs>,
533 window: &mut Window,
534 workspace: WeakEntity<Workspace>,
535 cx: &mut Context<Self>,
536 ) -> Self {
537 Self::new_inner(
538 Mode::AddWslDistro(AddWslDistro::new(window, cx)),
539 create_new_window,
540 fs,
541 window,
542 workspace,
543 cx,
544 )
545 }
546
547 pub fn new(
548 create_new_window: bool,
549 fs: Arc<dyn Fs>,
550 window: &mut Window,
551 workspace: WeakEntity<Workspace>,
552 cx: &mut Context<Self>,
553 ) -> Self {
554 Self::new_inner(
555 Mode::default_mode(&BTreeSet::new(), cx),
556 create_new_window,
557 fs,
558 window,
559 workspace,
560 cx,
561 )
562 }
563
564 fn new_inner(
565 mode: Mode,
566 create_new_window: bool,
567 fs: Arc<dyn Fs>,
568 window: &mut Window,
569 workspace: WeakEntity<Workspace>,
570 cx: &mut Context<Self>,
571 ) -> Self {
572 let focus_handle = cx.focus_handle();
573 let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
574 let ssh_config_updates = if read_ssh_config {
575 spawn_ssh_config_watch(fs.clone(), cx)
576 } else {
577 Task::ready(())
578 };
579
580 let mut base_style = window.text_style();
581 base_style.refine(&gpui::TextStyleRefinement {
582 color: Some(cx.theme().colors().editor_foreground),
583 ..Default::default()
584 });
585
586 let _subscription =
587 cx.observe_global_in::<SettingsStore>(window, move |recent_projects, _, cx| {
588 let new_read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
589 if read_ssh_config != new_read_ssh_config {
590 read_ssh_config = new_read_ssh_config;
591 if read_ssh_config {
592 recent_projects.ssh_config_updates = spawn_ssh_config_watch(fs.clone(), cx);
593 } else {
594 recent_projects.ssh_config_servers.clear();
595 recent_projects.ssh_config_updates = Task::ready(());
596 }
597 }
598 });
599
600 Self {
601 mode,
602 focus_handle,
603 workspace,
604 retained_connections: Vec::new(),
605 ssh_config_updates,
606 ssh_config_servers: BTreeSet::new(),
607 create_new_window,
608 _subscription,
609 }
610 }
611
612 fn project_picker(
613 create_new_window: bool,
614 index: ServerIndex,
615 connection_options: remote::RemoteConnectionOptions,
616 project: Entity<Project>,
617 home_dir: RemotePathBuf,
618 path_style: PathStyle,
619 window: &mut Window,
620 cx: &mut Context<Self>,
621 workspace: WeakEntity<Workspace>,
622 ) -> Self {
623 let fs = project.read(cx).fs().clone();
624 let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
625 this.mode = Mode::ProjectPicker(ProjectPicker::new(
626 create_new_window,
627 index,
628 connection_options,
629 project,
630 home_dir,
631 path_style,
632 workspace,
633 window,
634 cx,
635 ));
636 cx.notify();
637
638 this
639 }
640
641 fn create_ssh_server(
642 &mut self,
643 editor: Entity<Editor>,
644 window: &mut Window,
645 cx: &mut Context<Self>,
646 ) {
647 let input = get_text(&editor, cx);
648 if input.is_empty() {
649 return;
650 }
651
652 let connection_options = match SshConnectionOptions::parse_command_line(&input) {
653 Ok(c) => c,
654 Err(e) => {
655 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
656 address_editor: editor,
657 address_error: Some(format!("could not parse: {:?}", e).into()),
658 ssh_prompt: None,
659 _creating: None,
660 });
661 return;
662 }
663 };
664 let ssh_prompt = cx.new(|cx| {
665 RemoteConnectionPrompt::new(
666 connection_options.connection_string(),
667 connection_options.nickname.clone(),
668 false,
669 window,
670 cx,
671 )
672 });
673
674 let connection = connect_over_ssh(
675 ConnectionIdentifier::setup(),
676 connection_options.clone(),
677 ssh_prompt.clone(),
678 window,
679 cx,
680 )
681 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
682
683 let address_editor = editor.clone();
684 let creating = cx.spawn_in(window, async move |this, cx| {
685 match connection.await {
686 Some(Some(client)) => this
687 .update_in(cx, |this, window, cx| {
688 info!("ssh server created");
689 telemetry::event!("SSH Server Created");
690 this.retained_connections.push(client);
691 this.add_ssh_server(connection_options, cx);
692 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
693 this.focus_handle(cx).focus(window);
694 cx.notify()
695 })
696 .log_err(),
697 _ => this
698 .update(cx, |this, cx| {
699 address_editor.update(cx, |this, _| {
700 this.set_read_only(false);
701 });
702 this.mode = Mode::CreateRemoteServer(CreateRemoteServer {
703 address_editor,
704 address_error: None,
705 ssh_prompt: None,
706 _creating: None,
707 });
708 cx.notify()
709 })
710 .log_err(),
711 };
712 None
713 });
714
715 editor.update(cx, |this, _| {
716 this.set_read_only(true);
717 });
718 self.mode = Mode::CreateRemoteServer(CreateRemoteServer {
719 address_editor: editor,
720 address_error: None,
721 ssh_prompt: Some(ssh_prompt),
722 _creating: Some(creating),
723 });
724 }
725
726 #[cfg(target_os = "windows")]
727 fn connect_wsl_distro(
728 &mut self,
729 picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
730 distro: String,
731 window: &mut Window,
732 cx: &mut Context<Self>,
733 ) {
734 let connection_options = WslConnectionOptions {
735 distro_name: distro,
736 user: None,
737 };
738
739 let prompt = cx.new(|cx| {
740 RemoteConnectionPrompt::new(
741 connection_options.distro_name.clone(),
742 None,
743 true,
744 window,
745 cx,
746 )
747 });
748 let connection = connect(
749 ConnectionIdentifier::setup(),
750 connection_options.clone().into(),
751 prompt.clone(),
752 window,
753 cx,
754 )
755 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
756
757 let wsl_picker = picker.clone();
758 let creating = cx.spawn_in(window, async move |this, cx| {
759 match connection.await {
760 Some(Some(client)) => this
761 .update_in(cx, |this, window, cx| {
762 telemetry::event!("WSL Distro Added");
763 this.retained_connections.push(client);
764 this.add_wsl_distro(connection_options, cx);
765 this.mode = Mode::default_mode(&BTreeSet::new(), cx);
766 this.focus_handle(cx).focus(window);
767 cx.notify()
768 })
769 .log_err(),
770 _ => this
771 .update(cx, |this, cx| {
772 this.mode = Mode::AddWslDistro(AddWslDistro {
773 picker: wsl_picker,
774 connection_prompt: None,
775 _creating: None,
776 });
777 cx.notify()
778 })
779 .log_err(),
780 };
781 ()
782 });
783
784 self.mode = Mode::AddWslDistro(AddWslDistro {
785 picker,
786 connection_prompt: Some(prompt),
787 _creating: Some(creating),
788 });
789 }
790
791 fn view_server_options(
792 &mut self,
793 (server_index, connection): (ServerIndex, RemoteConnectionOptions),
794 window: &mut Window,
795 cx: &mut Context<Self>,
796 ) {
797 self.mode = Mode::ViewServerOptions(match (server_index, connection) {
798 (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => {
799 ViewServerOptionsState::Ssh {
800 connection,
801 server_index,
802 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
803 }
804 }
805 (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => {
806 ViewServerOptionsState::Wsl {
807 connection,
808 server_index,
809 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
810 }
811 }
812 _ => {
813 log::error!("server index and connection options mismatch");
814 self.mode = Mode::default_mode(&BTreeSet::default(), cx);
815 return;
816 }
817 });
818 self.focus_handle(cx).focus(window);
819 cx.notify();
820 }
821
822 fn create_remote_project(
823 &mut self,
824 index: ServerIndex,
825 connection_options: RemoteConnectionOptions,
826 window: &mut Window,
827 cx: &mut Context<Self>,
828 ) {
829 let Some(workspace) = self.workspace.upgrade() else {
830 return;
831 };
832
833 let create_new_window = self.create_new_window;
834 workspace.update(cx, |_, cx| {
835 cx.defer_in(window, move |workspace, window, cx| {
836 let app_state = workspace.app_state().clone();
837 workspace.toggle_modal(window, cx, |window, cx| {
838 RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
839 });
840 let prompt = workspace
841 .active_modal::<RemoteConnectionModal>(cx)
842 .unwrap()
843 .read(cx)
844 .prompt
845 .clone();
846
847 let connect = connect(
848 ConnectionIdentifier::setup(),
849 connection_options.clone(),
850 prompt,
851 window,
852 cx,
853 )
854 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
855
856 cx.spawn_in(window, async move |workspace, cx| {
857 let session = connect.await;
858
859 workspace.update(cx, |workspace, cx| {
860 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
861 prompt.update(cx, |prompt, cx| prompt.finished(cx))
862 }
863 })?;
864
865 let Some(Some(session)) = session else {
866 return workspace.update_in(cx, |workspace, window, cx| {
867 let weak = cx.entity().downgrade();
868 let fs = workspace.project().read(cx).fs().clone();
869 workspace.toggle_modal(window, cx, |window, cx| {
870 RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
871 });
872 });
873 };
874
875 let (path_style, project) = cx.update(|_, cx| {
876 (
877 session.read(cx).path_style(),
878 project::Project::remote(
879 session,
880 app_state.client.clone(),
881 app_state.node_runtime.clone(),
882 app_state.user_store.clone(),
883 app_state.languages.clone(),
884 app_state.fs.clone(),
885 cx,
886 ),
887 )
888 })?;
889
890 let home_dir = project
891 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
892 .await
893 .and_then(|path| path.into_abs_path())
894 .map(|path| RemotePathBuf::new(path, path_style))
895 .unwrap_or_else(|| match path_style {
896 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
897 PathStyle::Windows => {
898 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
899 }
900 });
901
902 workspace
903 .update_in(cx, |workspace, window, cx| {
904 let weak = cx.entity().downgrade();
905 workspace.toggle_modal(window, cx, |window, cx| {
906 RemoteServerProjects::project_picker(
907 create_new_window,
908 index,
909 connection_options,
910 project,
911 home_dir,
912 path_style,
913 window,
914 cx,
915 weak,
916 )
917 });
918 })
919 .ok();
920 Ok(())
921 })
922 .detach();
923 })
924 })
925 }
926
927 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
928 match &self.mode {
929 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
930 Mode::ProjectPicker(_) => {}
931 Mode::CreateRemoteServer(state) => {
932 if let Some(prompt) = state.ssh_prompt.as_ref() {
933 prompt.update(cx, |prompt, cx| {
934 prompt.confirm(window, cx);
935 });
936 return;
937 }
938
939 self.create_ssh_server(state.address_editor.clone(), window, cx);
940 }
941 Mode::EditNickname(state) => {
942 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
943 let index = state.index;
944 self.update_settings_file(cx, move |setting, _| {
945 if let Some(connections) = setting.ssh_connections.as_mut()
946 && let Some(connection) = connections.get_mut(index.0)
947 {
948 connection.nickname = text;
949 }
950 });
951 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
952 self.focus_handle.focus(window);
953 }
954 #[cfg(target_os = "windows")]
955 Mode::AddWslDistro(state) => {
956 let delegate = &state.picker.read(cx).delegate;
957 let distro = delegate.selected_distro().unwrap();
958 self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
959 }
960 }
961 }
962
963 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
964 match &self.mode {
965 Mode::Default(_) => cx.emit(DismissEvent),
966 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
967 let new_state = CreateRemoteServer::new(window, cx);
968 let old_prompt = state.address_editor.read(cx).text(cx);
969 new_state.address_editor.update(cx, |this, cx| {
970 this.set_text(old_prompt, window, cx);
971 });
972
973 self.mode = Mode::CreateRemoteServer(new_state);
974 cx.notify();
975 }
976 _ => {
977 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
978 self.focus_handle(cx).focus(window);
979 cx.notify();
980 }
981 }
982 }
983
984 fn render_ssh_connection(
985 &mut self,
986 ix: usize,
987 ssh_server: RemoteEntry,
988 window: &mut Window,
989 cx: &mut Context<Self>,
990 ) -> impl IntoElement {
991 let connection = ssh_server.connection().into_owned();
992
993 let (main_label, aux_label, is_wsl) = match &connection {
994 Connection::Ssh(connection) => {
995 if let Some(nickname) = connection.nickname.clone() {
996 let aux_label = SharedString::from(format!("({})", connection.host));
997 (nickname.into(), Some(aux_label), false)
998 } else {
999 (connection.host.clone(), None, false)
1000 }
1001 }
1002 Connection::Wsl(wsl_connection_options) => {
1003 (wsl_connection_options.distro_name.clone(), None, true)
1004 }
1005 };
1006 v_flex()
1007 .w_full()
1008 .child(ListSeparator)
1009 .child(
1010 h_flex()
1011 .group("ssh-server")
1012 .w_full()
1013 .pt_0p5()
1014 .px_3()
1015 .gap_1()
1016 .overflow_hidden()
1017 .child(
1018 h_flex()
1019 .gap_1()
1020 .max_w_96()
1021 .overflow_hidden()
1022 .text_ellipsis()
1023 .when(is_wsl, |this| {
1024 this.child(
1025 Label::new("WSL:")
1026 .size(LabelSize::Small)
1027 .color(Color::Muted),
1028 )
1029 })
1030 .child(
1031 Label::new(main_label)
1032 .size(LabelSize::Small)
1033 .color(Color::Muted),
1034 ),
1035 )
1036 .children(
1037 aux_label.map(|label| {
1038 Label::new(label).size(LabelSize::Small).color(Color::Muted)
1039 }),
1040 ),
1041 )
1042 .child(match &ssh_server {
1043 RemoteEntry::Project {
1044 open_folder,
1045 projects,
1046 configure,
1047 connection,
1048 index,
1049 } => {
1050 let index = *index;
1051 List::new()
1052 .empty_message("No projects.")
1053 .children(projects.iter().enumerate().map(|(pix, p)| {
1054 v_flex().gap_0p5().child(self.render_ssh_project(
1055 index,
1056 ssh_server.clone(),
1057 pix,
1058 p,
1059 window,
1060 cx,
1061 ))
1062 }))
1063 .child(
1064 h_flex()
1065 .id(("new-remote-project-container", ix))
1066 .track_focus(&open_folder.focus_handle)
1067 .anchor_scroll(open_folder.scroll_anchor.clone())
1068 .on_action(cx.listener({
1069 let connection = connection.clone();
1070 move |this, _: &menu::Confirm, window, cx| {
1071 this.create_remote_project(
1072 index,
1073 connection.clone().into(),
1074 window,
1075 cx,
1076 );
1077 }
1078 }))
1079 .child(
1080 ListItem::new(("new-remote-project", ix))
1081 .toggle_state(
1082 open_folder.focus_handle.contains_focused(window, cx),
1083 )
1084 .inset(true)
1085 .spacing(ui::ListItemSpacing::Sparse)
1086 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1087 .child(Label::new("Open Folder"))
1088 .on_click(cx.listener({
1089 let connection = connection.clone();
1090 move |this, _, window, cx| {
1091 this.create_remote_project(
1092 index,
1093 connection.clone().into(),
1094 window,
1095 cx,
1096 );
1097 }
1098 })),
1099 ),
1100 )
1101 .child(
1102 h_flex()
1103 .id(("server-options-container", ix))
1104 .track_focus(&configure.focus_handle)
1105 .anchor_scroll(configure.scroll_anchor.clone())
1106 .on_action(cx.listener({
1107 let connection = connection.clone();
1108 move |this, _: &menu::Confirm, window, cx| {
1109 this.view_server_options(
1110 (index, connection.clone().into()),
1111 window,
1112 cx,
1113 );
1114 }
1115 }))
1116 .child(
1117 ListItem::new(("server-options", ix))
1118 .toggle_state(
1119 configure.focus_handle.contains_focused(window, cx),
1120 )
1121 .inset(true)
1122 .spacing(ui::ListItemSpacing::Sparse)
1123 .start_slot(
1124 Icon::new(IconName::Settings).color(Color::Muted),
1125 )
1126 .child(Label::new("View Server Options"))
1127 .on_click(cx.listener({
1128 let ssh_connection = connection.clone();
1129 move |this, _, window, cx| {
1130 this.view_server_options(
1131 (index, ssh_connection.clone().into()),
1132 window,
1133 cx,
1134 );
1135 }
1136 })),
1137 ),
1138 )
1139 }
1140 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1141 h_flex()
1142 .id(("new-remote-project-container", ix))
1143 .track_focus(&open_folder.focus_handle)
1144 .anchor_scroll(open_folder.scroll_anchor.clone())
1145 .on_action(cx.listener({
1146 let connection = connection.clone();
1147 let host = host.clone();
1148 move |this, _: &menu::Confirm, window, cx| {
1149 let new_ix = this.create_host_from_ssh_config(&host, cx);
1150 this.create_remote_project(
1151 new_ix.into(),
1152 connection.clone().into(),
1153 window,
1154 cx,
1155 );
1156 }
1157 }))
1158 .child(
1159 ListItem::new(("new-remote-project", ix))
1160 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1161 .inset(true)
1162 .spacing(ui::ListItemSpacing::Sparse)
1163 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1164 .child(Label::new("Open Folder"))
1165 .on_click(cx.listener({
1166 let host = host.clone();
1167 move |this, _, window, cx| {
1168 let new_ix = this.create_host_from_ssh_config(&host, cx);
1169 this.create_remote_project(
1170 new_ix.into(),
1171 connection.clone().into(),
1172 window,
1173 cx,
1174 );
1175 }
1176 })),
1177 ),
1178 ),
1179 })
1180 }
1181
1182 fn render_ssh_project(
1183 &mut self,
1184 server_ix: ServerIndex,
1185 server: RemoteEntry,
1186 ix: usize,
1187 (navigation, project): &(NavigableEntry, SshProject),
1188 window: &mut Window,
1189 cx: &mut Context<Self>,
1190 ) -> impl IntoElement {
1191 let create_new_window = self.create_new_window;
1192 let is_from_zed = server.is_from_zed();
1193 let element_id_base = SharedString::from(format!(
1194 "remote-project-{}",
1195 match server_ix {
1196 ServerIndex::Ssh(index) => format!("ssh-{index}"),
1197 ServerIndex::Wsl(index) => format!("wsl-{index}"),
1198 }
1199 ));
1200 let container_element_id_base =
1201 SharedString::from(format!("remote-project-container-{element_id_base}"));
1202
1203 let callback = Rc::new({
1204 let project = project.clone();
1205 move |remote_server_projects: &mut Self,
1206 secondary_confirm: bool,
1207 window: &mut Window,
1208 cx: &mut Context<Self>| {
1209 let Some(app_state) = remote_server_projects
1210 .workspace
1211 .read_with(cx, |workspace, _| workspace.app_state().clone())
1212 .log_err()
1213 else {
1214 return;
1215 };
1216 let project = project.clone();
1217 let server = server.connection().into_owned();
1218 cx.emit(DismissEvent);
1219
1220 let replace_window = match (create_new_window, secondary_confirm) {
1221 (true, false) | (false, true) => None,
1222 (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
1223 };
1224
1225 cx.spawn_in(window, async move |_, cx| {
1226 let result = open_remote_project(
1227 server.into(),
1228 project.paths.into_iter().map(PathBuf::from).collect(),
1229 app_state,
1230 OpenOptions {
1231 replace_window,
1232 ..OpenOptions::default()
1233 },
1234 cx,
1235 )
1236 .await;
1237 if let Err(e) = result {
1238 log::error!("Failed to connect: {e:#}");
1239 cx.prompt(
1240 gpui::PromptLevel::Critical,
1241 "Failed to connect",
1242 Some(&e.to_string()),
1243 &["Ok"],
1244 )
1245 .await
1246 .ok();
1247 }
1248 })
1249 .detach();
1250 }
1251 });
1252
1253 div()
1254 .id((container_element_id_base, ix))
1255 .track_focus(&navigation.focus_handle)
1256 .anchor_scroll(navigation.scroll_anchor.clone())
1257 .on_action(cx.listener({
1258 let callback = callback.clone();
1259 move |this, _: &menu::Confirm, window, cx| {
1260 callback(this, false, window, cx);
1261 }
1262 }))
1263 .on_action(cx.listener({
1264 let callback = callback.clone();
1265 move |this, _: &menu::SecondaryConfirm, window, cx| {
1266 callback(this, true, window, cx);
1267 }
1268 }))
1269 .child(
1270 ListItem::new((element_id_base, ix))
1271 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1272 .inset(true)
1273 .spacing(ui::ListItemSpacing::Sparse)
1274 .start_slot(
1275 Icon::new(IconName::Folder)
1276 .color(Color::Muted)
1277 .size(IconSize::Small),
1278 )
1279 .child(Label::new(project.paths.join(", ")))
1280 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1281 let secondary_confirm = e.modifiers().platform;
1282 callback(this, secondary_confirm, window, cx)
1283 }))
1284 .when(
1285 is_from_zed && matches!(server_ix, ServerIndex::Ssh(_)),
1286 |server_list_item| {
1287 let ServerIndex::Ssh(server_ix) = server_ix else {
1288 unreachable!()
1289 };
1290 server_list_item.end_hover_slot::<AnyElement>(Some(
1291 div()
1292 .mr_2()
1293 .child({
1294 let project = project.clone();
1295 // Right-margin to offset it from the Scrollbar
1296 IconButton::new("remove-remote-project", IconName::Trash)
1297 .icon_size(IconSize::Small)
1298 .shape(IconButtonShape::Square)
1299 .size(ButtonSize::Large)
1300 .tooltip(Tooltip::text("Delete Remote Project"))
1301 .on_click(cx.listener(move |this, _, _, cx| {
1302 this.delete_ssh_project(server_ix, &project, cx)
1303 }))
1304 })
1305 .into_any_element(),
1306 ))
1307 },
1308 ),
1309 )
1310 }
1311
1312 fn update_settings_file(
1313 &mut self,
1314 cx: &mut Context<Self>,
1315 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1316 ) {
1317 let Some(fs) = self
1318 .workspace
1319 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1320 .log_err()
1321 else {
1322 return;
1323 };
1324 update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
1325 }
1326
1327 fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1328 self.update_settings_file(cx, move |setting, _| {
1329 if let Some(connections) = setting.ssh_connections.as_mut() {
1330 connections.remove(server.0);
1331 }
1332 });
1333 }
1334
1335 fn delete_ssh_project(
1336 &mut self,
1337 server: SshServerIndex,
1338 project: &SshProject,
1339 cx: &mut Context<Self>,
1340 ) {
1341 let project = project.clone();
1342 self.update_settings_file(cx, move |setting, _| {
1343 if let Some(server) = setting
1344 .ssh_connections
1345 .as_mut()
1346 .and_then(|connections| connections.get_mut(server.0))
1347 {
1348 server.projects.remove(&project);
1349 }
1350 });
1351 }
1352
1353 #[cfg(target_os = "windows")]
1354 fn add_wsl_distro(
1355 &mut self,
1356 connection_options: remote::WslConnectionOptions,
1357 cx: &mut Context<Self>,
1358 ) {
1359 self.update_settings_file(cx, move |setting, _| {
1360 setting
1361 .wsl_connections
1362 .get_or_insert(Default::default())
1363 .push(settings::WslConnection {
1364 distro_name: SharedString::from(connection_options.distro_name),
1365 user: connection_options.user,
1366 projects: BTreeSet::new(),
1367 })
1368 });
1369 }
1370
1371 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1372 self.update_settings_file(cx, move |setting, _| {
1373 if let Some(connections) = setting.wsl_connections.as_mut() {
1374 connections.remove(server.0);
1375 }
1376 });
1377 }
1378
1379 fn add_ssh_server(
1380 &mut self,
1381 connection_options: remote::SshConnectionOptions,
1382 cx: &mut Context<Self>,
1383 ) {
1384 self.update_settings_file(cx, move |setting, _| {
1385 setting
1386 .ssh_connections
1387 .get_or_insert(Default::default())
1388 .push(SshConnection {
1389 host: SharedString::from(connection_options.host),
1390 username: connection_options.username,
1391 port: connection_options.port,
1392 projects: BTreeSet::new(),
1393 nickname: None,
1394 args: connection_options.args.unwrap_or_default(),
1395 upload_binary_over_ssh: None,
1396 port_forwards: connection_options.port_forwards,
1397 })
1398 });
1399 }
1400
1401 fn render_create_remote_server(
1402 &self,
1403 state: &CreateRemoteServer,
1404 window: &mut Window,
1405 cx: &mut Context<Self>,
1406 ) -> impl IntoElement {
1407 let ssh_prompt = state.ssh_prompt.clone();
1408
1409 state.address_editor.update(cx, |editor, cx| {
1410 if editor.text(cx).is_empty() {
1411 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1412 }
1413 });
1414
1415 let theme = cx.theme();
1416
1417 v_flex()
1418 .track_focus(&self.focus_handle(cx))
1419 .id("create-remote-server")
1420 .overflow_hidden()
1421 .size_full()
1422 .flex_1()
1423 .child(
1424 div()
1425 .p_2()
1426 .border_b_1()
1427 .border_color(theme.colors().border_variant)
1428 .child(state.address_editor.clone()),
1429 )
1430 .child(
1431 h_flex()
1432 .bg(theme.colors().editor_background)
1433 .rounded_b_sm()
1434 .w_full()
1435 .map(|this| {
1436 if let Some(ssh_prompt) = ssh_prompt {
1437 this.child(h_flex().w_full().child(ssh_prompt))
1438 } else if let Some(address_error) = &state.address_error {
1439 this.child(
1440 h_flex().p_2().w_full().gap_2().child(
1441 Label::new(address_error.clone())
1442 .size(LabelSize::Small)
1443 .color(Color::Error),
1444 ),
1445 )
1446 } else {
1447 this.child(
1448 h_flex()
1449 .p_2()
1450 .w_full()
1451 .gap_1()
1452 .child(
1453 Label::new(
1454 "Enter the command you use to SSH into this server.",
1455 )
1456 .color(Color::Muted)
1457 .size(LabelSize::Small),
1458 )
1459 .child(
1460 Button::new("learn-more", "Learn More")
1461 .label_size(LabelSize::Small)
1462 .icon(IconName::ArrowUpRight)
1463 .icon_size(IconSize::XSmall)
1464 .on_click(|_, _, cx| {
1465 cx.open_url(
1466 "https://zed.dev/docs/remote-development",
1467 );
1468 }),
1469 ),
1470 )
1471 }
1472 }),
1473 )
1474 }
1475
1476 #[cfg(target_os = "windows")]
1477 fn render_add_wsl_distro(
1478 &self,
1479 state: &AddWslDistro,
1480 window: &mut Window,
1481 cx: &mut Context<Self>,
1482 ) -> impl IntoElement {
1483 let connection_prompt = state.connection_prompt.clone();
1484
1485 state.picker.update(cx, |picker, cx| {
1486 picker.focus_handle(cx).focus(window);
1487 });
1488
1489 v_flex()
1490 .id("add-wsl-distro")
1491 .overflow_hidden()
1492 .size_full()
1493 .flex_1()
1494 .map(|this| {
1495 if let Some(connection_prompt) = connection_prompt {
1496 this.child(connection_prompt)
1497 } else {
1498 this.child(state.picker.clone())
1499 }
1500 })
1501 }
1502
1503 fn render_view_options(
1504 &mut self,
1505 options: ViewServerOptionsState,
1506 window: &mut Window,
1507 cx: &mut Context<Self>,
1508 ) -> impl IntoElement {
1509 let last_entry = options.entries().last().unwrap();
1510
1511 let mut view = Navigable::new(
1512 div()
1513 .track_focus(&self.focus_handle(cx))
1514 .size_full()
1515 .child(match &options {
1516 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1517 connection_string: connection.host.clone().into(),
1518 paths: Default::default(),
1519 nickname: connection.nickname.clone().map(|s| s.into()),
1520 is_wsl: false,
1521 }
1522 .render(window, cx)
1523 .into_any_element(),
1524 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1525 connection_string: connection.distro_name.clone().into(),
1526 paths: Default::default(),
1527 nickname: None,
1528 is_wsl: true,
1529 }
1530 .render(window, cx)
1531 .into_any_element(),
1532 })
1533 .child(
1534 v_flex()
1535 .pb_1()
1536 .child(ListSeparator)
1537 .map(|this| match &options {
1538 ViewServerOptionsState::Ssh {
1539 connection,
1540 entries,
1541 server_index,
1542 } => this.child(self.render_edit_ssh(
1543 connection,
1544 *server_index,
1545 entries,
1546 window,
1547 cx,
1548 )),
1549 ViewServerOptionsState::Wsl {
1550 connection,
1551 entries,
1552 server_index,
1553 } => this.child(self.render_edit_wsl(
1554 connection,
1555 *server_index,
1556 entries,
1557 window,
1558 cx,
1559 )),
1560 })
1561 .child(ListSeparator)
1562 .child({
1563 div()
1564 .id("ssh-options-copy-server-address")
1565 .track_focus(&last_entry.focus_handle)
1566 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1567 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1568 cx.focus_self(window);
1569 cx.notify();
1570 }))
1571 .child(
1572 ListItem::new("go-back")
1573 .toggle_state(
1574 last_entry.focus_handle.contains_focused(window, cx),
1575 )
1576 .inset(true)
1577 .spacing(ui::ListItemSpacing::Sparse)
1578 .start_slot(
1579 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1580 )
1581 .child(Label::new("Go Back"))
1582 .on_click(cx.listener(|this, _, window, cx| {
1583 this.mode =
1584 Mode::default_mode(&this.ssh_config_servers, cx);
1585 cx.focus_self(window);
1586 cx.notify()
1587 })),
1588 )
1589 }),
1590 )
1591 .into_any_element(),
1592 );
1593
1594 for entry in options.entries() {
1595 view = view.entry(entry.clone());
1596 }
1597
1598 view.render(window, cx).into_any_element()
1599 }
1600
1601 fn render_edit_wsl(
1602 &self,
1603 connection: &WslConnectionOptions,
1604 index: WslServerIndex,
1605 entries: &[NavigableEntry],
1606 window: &mut Window,
1607 cx: &mut Context<Self>,
1608 ) -> impl IntoElement {
1609 let distro_name = SharedString::new(connection.distro_name.clone());
1610
1611 v_flex().child({
1612 fn remove_wsl_distro(
1613 remote_servers: Entity<RemoteServerProjects>,
1614 index: WslServerIndex,
1615 distro_name: SharedString,
1616 window: &mut Window,
1617 cx: &mut App,
1618 ) {
1619 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
1620
1621 let confirmation = window.prompt(
1622 PromptLevel::Warning,
1623 &prompt_message,
1624 None,
1625 &["Yes, remove it", "No, keep it"],
1626 cx,
1627 );
1628
1629 cx.spawn(async move |cx| {
1630 if confirmation.await.ok() == Some(0) {
1631 remote_servers
1632 .update(cx, |this, cx| {
1633 this.delete_wsl_distro(index, cx);
1634 })
1635 .ok();
1636 remote_servers
1637 .update(cx, |this, cx| {
1638 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1639 cx.notify();
1640 })
1641 .ok();
1642 }
1643 anyhow::Ok(())
1644 })
1645 .detach_and_log_err(cx);
1646 }
1647 div()
1648 .id("wsl-options-remove-distro")
1649 .track_focus(&entries[0].focus_handle)
1650 .on_action(cx.listener({
1651 let distro_name = distro_name.clone();
1652 move |_, _: &menu::Confirm, window, cx| {
1653 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1654 cx.focus_self(window);
1655 }
1656 }))
1657 .child(
1658 ListItem::new("remove-distro")
1659 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1660 .inset(true)
1661 .spacing(ui::ListItemSpacing::Sparse)
1662 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1663 .child(Label::new("Remove Distro").color(Color::Error))
1664 .on_click(cx.listener(move |_, _, window, cx| {
1665 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1666 cx.focus_self(window);
1667 })),
1668 )
1669 })
1670 }
1671
1672 fn render_edit_ssh(
1673 &self,
1674 connection: &SshConnectionOptions,
1675 index: SshServerIndex,
1676 entries: &[NavigableEntry],
1677 window: &mut Window,
1678 cx: &mut Context<Self>,
1679 ) -> impl IntoElement {
1680 let connection_string = SharedString::new(connection.host.clone());
1681
1682 v_flex()
1683 .child({
1684 let label = if connection.nickname.is_some() {
1685 "Edit Nickname"
1686 } else {
1687 "Add Nickname to Server"
1688 };
1689 div()
1690 .id("ssh-options-add-nickname")
1691 .track_focus(&entries[0].focus_handle)
1692 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
1693 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
1694 cx.notify();
1695 }))
1696 .child(
1697 ListItem::new("add-nickname")
1698 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1699 .inset(true)
1700 .spacing(ui::ListItemSpacing::Sparse)
1701 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1702 .child(Label::new(label))
1703 .on_click(cx.listener(move |this, _, window, cx| {
1704 this.mode =
1705 Mode::EditNickname(EditNicknameState::new(index, window, cx));
1706 cx.notify();
1707 })),
1708 )
1709 })
1710 .child({
1711 let workspace = self.workspace.clone();
1712 fn callback(
1713 workspace: WeakEntity<Workspace>,
1714 connection_string: SharedString,
1715 cx: &mut App,
1716 ) {
1717 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
1718 workspace
1719 .update(cx, |this, cx| {
1720 struct SshServerAddressCopiedToClipboard;
1721 let notification = format!(
1722 "Copied server address ({}) to clipboard",
1723 connection_string
1724 );
1725
1726 this.show_toast(
1727 Toast::new(
1728 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
1729 connection_string.clone(),
1730 ),
1731 notification,
1732 )
1733 .autohide(),
1734 cx,
1735 );
1736 })
1737 .ok();
1738 }
1739 div()
1740 .id("ssh-options-copy-server-address")
1741 .track_focus(&entries[1].focus_handle)
1742 .on_action({
1743 let connection_string = connection_string.clone();
1744 let workspace = self.workspace.clone();
1745 move |_: &menu::Confirm, _, cx| {
1746 callback(workspace.clone(), connection_string.clone(), cx);
1747 }
1748 })
1749 .child(
1750 ListItem::new("copy-server-address")
1751 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
1752 .inset(true)
1753 .spacing(ui::ListItemSpacing::Sparse)
1754 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1755 .child(Label::new("Copy Server Address"))
1756 .end_hover_slot(
1757 Label::new(connection_string.clone()).color(Color::Muted),
1758 )
1759 .on_click({
1760 let connection_string = connection_string.clone();
1761 move |_, _, cx| {
1762 callback(workspace.clone(), connection_string.clone(), cx);
1763 }
1764 }),
1765 )
1766 })
1767 .child({
1768 fn remove_ssh_server(
1769 remote_servers: Entity<RemoteServerProjects>,
1770 index: SshServerIndex,
1771 connection_string: SharedString,
1772 window: &mut Window,
1773 cx: &mut App,
1774 ) {
1775 let prompt_message = format!("Remove server `{}`?", connection_string);
1776
1777 let confirmation = window.prompt(
1778 PromptLevel::Warning,
1779 &prompt_message,
1780 None,
1781 &["Yes, remove it", "No, keep it"],
1782 cx,
1783 );
1784
1785 cx.spawn(async move |cx| {
1786 if confirmation.await.ok() == Some(0) {
1787 remote_servers
1788 .update(cx, |this, cx| {
1789 this.delete_ssh_server(index, cx);
1790 })
1791 .ok();
1792 remote_servers
1793 .update(cx, |this, cx| {
1794 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1795 cx.notify();
1796 })
1797 .ok();
1798 }
1799 anyhow::Ok(())
1800 })
1801 .detach_and_log_err(cx);
1802 }
1803 div()
1804 .id("ssh-options-copy-server-address")
1805 .track_focus(&entries[2].focus_handle)
1806 .on_action(cx.listener({
1807 let connection_string = connection_string.clone();
1808 move |_, _: &menu::Confirm, window, cx| {
1809 remove_ssh_server(
1810 cx.entity(),
1811 index,
1812 connection_string.clone(),
1813 window,
1814 cx,
1815 );
1816 cx.focus_self(window);
1817 }
1818 }))
1819 .child(
1820 ListItem::new("remove-server")
1821 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
1822 .inset(true)
1823 .spacing(ui::ListItemSpacing::Sparse)
1824 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1825 .child(Label::new("Remove Server").color(Color::Error))
1826 .on_click(cx.listener(move |_, _, window, cx| {
1827 remove_ssh_server(
1828 cx.entity(),
1829 index,
1830 connection_string.clone(),
1831 window,
1832 cx,
1833 );
1834 cx.focus_self(window);
1835 })),
1836 )
1837 })
1838 }
1839
1840 fn render_edit_nickname(
1841 &self,
1842 state: &EditNicknameState,
1843 window: &mut Window,
1844 cx: &mut Context<Self>,
1845 ) -> impl IntoElement {
1846 let Some(connection) = SshSettings::get_global(cx)
1847 .ssh_connections()
1848 .nth(state.index.0)
1849 else {
1850 return v_flex()
1851 .id("ssh-edit-nickname")
1852 .track_focus(&self.focus_handle(cx));
1853 };
1854
1855 let connection_string = connection.host.clone();
1856 let nickname = connection.nickname.map(|s| s.into());
1857
1858 v_flex()
1859 .id("ssh-edit-nickname")
1860 .track_focus(&self.focus_handle(cx))
1861 .child(
1862 SshConnectionHeader {
1863 connection_string,
1864 paths: Default::default(),
1865 nickname,
1866 is_wsl: false,
1867 }
1868 .render(window, cx),
1869 )
1870 .child(
1871 h_flex()
1872 .p_2()
1873 .border_t_1()
1874 .border_color(cx.theme().colors().border_variant)
1875 .child(state.editor.clone()),
1876 )
1877 }
1878
1879 fn render_default(
1880 &mut self,
1881 mut state: DefaultState,
1882 window: &mut Window,
1883 cx: &mut Context<Self>,
1884 ) -> impl IntoElement {
1885 let ssh_settings = SshSettings::get_global(cx);
1886 let mut should_rebuild = false;
1887
1888 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
1889 .servers
1890 .iter()
1891 .filter_map(|server| match server {
1892 RemoteEntry::Project {
1893 connection: Connection::Ssh(connection),
1894 ..
1895 } => Some(connection),
1896 _ => None,
1897 }));
1898
1899 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
1900 .servers
1901 .iter()
1902 .filter_map(|server| match server {
1903 RemoteEntry::Project {
1904 connection: Connection::Wsl(connection),
1905 ..
1906 } => Some(connection),
1907 _ => None,
1908 }));
1909
1910 if ssh_connections_changed || wsl_connections_changed {
1911 should_rebuild = true;
1912 };
1913
1914 if !should_rebuild && ssh_settings.read_ssh_config {
1915 let current_ssh_hosts: BTreeSet<SharedString> = state
1916 .servers
1917 .iter()
1918 .filter_map(|server| match server {
1919 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1920 _ => None,
1921 })
1922 .collect();
1923 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1924 for server in &state.servers {
1925 if let RemoteEntry::Project {
1926 connection: Connection::Ssh(connection),
1927 ..
1928 } = server
1929 {
1930 expected_ssh_hosts.remove(&connection.host);
1931 }
1932 }
1933 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
1934 }
1935
1936 if should_rebuild {
1937 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1938 if let Mode::Default(new_state) = &self.mode {
1939 state = new_state.clone();
1940 }
1941 }
1942
1943 let connect_button = div()
1944 .id("ssh-connect-new-server-container")
1945 .track_focus(&state.add_new_server.focus_handle)
1946 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1947 .child(
1948 ListItem::new("register-remove-server-button")
1949 .toggle_state(
1950 state
1951 .add_new_server
1952 .focus_handle
1953 .contains_focused(window, cx),
1954 )
1955 .inset(true)
1956 .spacing(ui::ListItemSpacing::Sparse)
1957 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1958 .child(Label::new("Connect New Server"))
1959 .on_click(cx.listener(|this, _, window, cx| {
1960 let state = CreateRemoteServer::new(window, cx);
1961 this.mode = Mode::CreateRemoteServer(state);
1962
1963 cx.notify();
1964 })),
1965 )
1966 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1967 let state = CreateRemoteServer::new(window, cx);
1968 this.mode = Mode::CreateRemoteServer(state);
1969
1970 cx.notify();
1971 }));
1972
1973 #[cfg(target_os = "windows")]
1974 let wsl_connect_button = div()
1975 .id("wsl-connect-new-server")
1976 .track_focus(&state.add_new_wsl.focus_handle)
1977 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
1978 .child(
1979 ListItem::new("wsl-add-new-server")
1980 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
1981 .inset(true)
1982 .spacing(ui::ListItemSpacing::Sparse)
1983 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1984 .child(Label::new("Add WSL Distro"))
1985 .on_click(cx.listener(|this, _, window, cx| {
1986 let state = AddWslDistro::new(window, cx);
1987 this.mode = Mode::AddWslDistro(state);
1988
1989 cx.notify();
1990 })),
1991 )
1992 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1993 let state = AddWslDistro::new(window, cx);
1994 this.mode = Mode::AddWslDistro(state);
1995
1996 cx.notify();
1997 }));
1998
1999 let modal_section = v_flex()
2000 .track_focus(&self.focus_handle(cx))
2001 .id("ssh-server-list")
2002 .overflow_y_scroll()
2003 .track_scroll(&state.scroll_handle)
2004 .size_full()
2005 .child(connect_button);
2006
2007 #[cfg(target_os = "windows")]
2008 let modal_section = modal_section.child(wsl_connect_button);
2009 #[cfg(not(target_os = "windows"))]
2010 let modal_section = modal_section;
2011
2012 let mut modal_section = Navigable::new(
2013 modal_section
2014 .child(
2015 List::new()
2016 .empty_message(
2017 v_flex()
2018 .child(
2019 div().px_3().child(
2020 Label::new("No remote servers registered yet.")
2021 .color(Color::Muted),
2022 ),
2023 )
2024 .into_any_element(),
2025 )
2026 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2027 self.render_ssh_connection(ix, connection.clone(), window, cx)
2028 .into_any_element()
2029 })),
2030 )
2031 .into_any_element(),
2032 )
2033 .entry(state.add_new_server.clone())
2034 .entry(state.add_new_wsl.clone());
2035
2036 for server in &state.servers {
2037 match server {
2038 RemoteEntry::Project {
2039 open_folder,
2040 projects,
2041 configure,
2042 ..
2043 } => {
2044 for (navigation_state, _) in projects {
2045 modal_section = modal_section.entry(navigation_state.clone());
2046 }
2047 modal_section = modal_section
2048 .entry(open_folder.clone())
2049 .entry(configure.clone());
2050 }
2051 RemoteEntry::SshConfig { open_folder, .. } => {
2052 modal_section = modal_section.entry(open_folder.clone());
2053 }
2054 }
2055 }
2056 let mut modal_section = modal_section.render(window, cx).into_any_element();
2057
2058 let (create_window, reuse_window) = if self.create_new_window {
2059 (
2060 window.keystroke_text_for(&menu::Confirm),
2061 window.keystroke_text_for(&menu::SecondaryConfirm),
2062 )
2063 } else {
2064 (
2065 window.keystroke_text_for(&menu::SecondaryConfirm),
2066 window.keystroke_text_for(&menu::Confirm),
2067 )
2068 };
2069 let placeholder_text = Arc::from(format!(
2070 "{reuse_window} reuses this window, {create_window} opens a new one",
2071 ));
2072
2073 Modal::new("remote-projects", None)
2074 .header(
2075 ModalHeader::new()
2076 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2077 .child(
2078 Label::new(placeholder_text)
2079 .color(Color::Muted)
2080 .size(LabelSize::XSmall),
2081 ),
2082 )
2083 .section(
2084 Section::new().padded(false).child(
2085 v_flex()
2086 .min_h(rems(20.))
2087 .size_full()
2088 .relative()
2089 .child(ListSeparator)
2090 .child(
2091 canvas(
2092 |bounds, window, cx| {
2093 modal_section.prepaint_as_root(
2094 bounds.origin,
2095 bounds.size.into(),
2096 window,
2097 cx,
2098 );
2099 modal_section
2100 },
2101 |_, mut modal_section, window, cx| {
2102 modal_section.paint(window, cx);
2103 },
2104 )
2105 .size_full(),
2106 )
2107 .vertical_scrollbar_for(state.scroll_handle, window, cx),
2108 ),
2109 )
2110 .into_any_element()
2111 }
2112
2113 fn create_host_from_ssh_config(
2114 &mut self,
2115 ssh_config_host: &SharedString,
2116 cx: &mut Context<'_, Self>,
2117 ) -> SshServerIndex {
2118 let new_ix = Arc::new(AtomicUsize::new(0));
2119
2120 let update_new_ix = new_ix.clone();
2121 self.update_settings_file(cx, move |settings, _| {
2122 update_new_ix.store(
2123 settings
2124 .ssh_connections
2125 .as_ref()
2126 .map_or(0, |connections| connections.len()),
2127 atomic::Ordering::Release,
2128 );
2129 });
2130
2131 self.add_ssh_server(
2132 SshConnectionOptions {
2133 host: ssh_config_host.to_string(),
2134 ..SshConnectionOptions::default()
2135 },
2136 cx,
2137 );
2138 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2139 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2140 }
2141}
2142
2143fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2144 let mut user_ssh_config_watcher =
2145 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2146 let mut global_ssh_config_watcher = watch_config_file(
2147 cx.background_executor(),
2148 fs,
2149 global_ssh_config_file().to_owned(),
2150 );
2151
2152 cx.spawn(async move |remote_server_projects, cx| {
2153 let mut global_hosts = BTreeSet::default();
2154 let mut user_hosts = BTreeSet::default();
2155 let mut running_receivers = 2;
2156
2157 loop {
2158 select! {
2159 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2160 match new_global_file_contents {
2161 Some(new_global_file_contents) => {
2162 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
2163 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2164 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2165 cx.notify();
2166 }).is_err() {
2167 return;
2168 }
2169 },
2170 None => {
2171 running_receivers -= 1;
2172 if running_receivers == 0 {
2173 return;
2174 }
2175 }
2176 }
2177 },
2178 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2179 match new_user_file_contents {
2180 Some(new_user_file_contents) => {
2181 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2182 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2183 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2184 cx.notify();
2185 }).is_err() {
2186 return;
2187 }
2188 },
2189 None => {
2190 running_receivers -= 1;
2191 if running_receivers == 0 {
2192 return;
2193 }
2194 }
2195 }
2196 },
2197 }
2198 }
2199 })
2200}
2201
2202fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2203 element.read(cx).text(cx).trim().to_string()
2204}
2205
2206impl ModalView for RemoteServerProjects {}
2207
2208impl Focusable for RemoteServerProjects {
2209 fn focus_handle(&self, cx: &App) -> FocusHandle {
2210 match &self.mode {
2211 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2212 _ => self.focus_handle.clone(),
2213 }
2214 }
2215}
2216
2217impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2218
2219impl Render for RemoteServerProjects {
2220 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2221 div()
2222 .elevation_3(cx)
2223 .w(rems(34.))
2224 .key_context("RemoteServerModal")
2225 .on_action(cx.listener(Self::cancel))
2226 .on_action(cx.listener(Self::confirm))
2227 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2228 this.focus_handle(cx).focus(window);
2229 }))
2230 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2231 if matches!(this.mode, Mode::Default(_)) {
2232 cx.emit(DismissEvent)
2233 }
2234 }))
2235 .child(match &self.mode {
2236 Mode::Default(state) => self
2237 .render_default(state.clone(), window, cx)
2238 .into_any_element(),
2239 Mode::ViewServerOptions(state) => self
2240 .render_view_options(state.clone(), window, cx)
2241 .into_any_element(),
2242 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2243 Mode::CreateRemoteServer(state) => self
2244 .render_create_remote_server(state, window, cx)
2245 .into_any_element(),
2246 Mode::EditNickname(state) => self
2247 .render_edit_nickname(state, window, cx)
2248 .into_any_element(),
2249 #[cfg(target_os = "windows")]
2250 Mode::AddWslDistro(state) => self
2251 .render_add_wsl_distro(state, window, cx)
2252 .into_any_element(),
2253 })
2254 }
2255}