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