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
798 .update_in(cx, |this, window, cx| {
799 telemetry::event!("WSL Distro Added");
800 this.retained_connections.push(client);
801 this.add_wsl_distro(connection_options, cx);
802 this.mode = Mode::default_mode(&BTreeSet::new(), cx);
803 this.focus_handle(cx).focus(window);
804 cx.notify()
805 })
806 .log_err(),
807 _ => this
808 .update(cx, |this, cx| {
809 this.mode = Mode::AddWslDistro(AddWslDistro {
810 picker: wsl_picker,
811 connection_prompt: None,
812 _creating: None,
813 });
814 cx.notify()
815 })
816 .log_err(),
817 };
818 ()
819 });
820
821 self.mode = Mode::AddWslDistro(AddWslDistro {
822 picker,
823 connection_prompt: Some(prompt),
824 _creating: Some(creating),
825 });
826 }
827
828 fn view_server_options(
829 &mut self,
830 (server_index, connection): (ServerIndex, RemoteConnectionOptions),
831 window: &mut Window,
832 cx: &mut Context<Self>,
833 ) {
834 self.mode = Mode::ViewServerOptions(match (server_index, connection) {
835 (ServerIndex::Ssh(server_index), RemoteConnectionOptions::Ssh(connection)) => {
836 ViewServerOptionsState::Ssh {
837 connection,
838 server_index,
839 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
840 }
841 }
842 (ServerIndex::Wsl(server_index), RemoteConnectionOptions::Wsl(connection)) => {
843 ViewServerOptionsState::Wsl {
844 connection,
845 server_index,
846 entries: std::array::from_fn(|_| NavigableEntry::focusable(cx)),
847 }
848 }
849 _ => {
850 log::error!("server index and connection options mismatch");
851 self.mode = Mode::default_mode(&BTreeSet::default(), cx);
852 return;
853 }
854 });
855 self.focus_handle(cx).focus(window);
856 cx.notify();
857 }
858
859 fn create_remote_project(
860 &mut self,
861 index: ServerIndex,
862 connection_options: RemoteConnectionOptions,
863 window: &mut Window,
864 cx: &mut Context<Self>,
865 ) {
866 let Some(workspace) = self.workspace.upgrade() else {
867 return;
868 };
869
870 let create_new_window = self.create_new_window;
871 workspace.update(cx, |_, cx| {
872 cx.defer_in(window, move |workspace, window, cx| {
873 let app_state = workspace.app_state().clone();
874 workspace.toggle_modal(window, cx, |window, cx| {
875 RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
876 });
877 let prompt = workspace
878 .active_modal::<RemoteConnectionModal>(cx)
879 .unwrap()
880 .read(cx)
881 .prompt
882 .clone();
883
884 let connect = connect(
885 ConnectionIdentifier::setup(),
886 connection_options.clone(),
887 prompt,
888 window,
889 cx,
890 )
891 .prompt_err("Failed to connect", window, cx, |_, _, _| None);
892
893 cx.spawn_in(window, async move |workspace, cx| {
894 let session = connect.await;
895
896 workspace.update(cx, |workspace, cx| {
897 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
898 prompt.update(cx, |prompt, cx| prompt.finished(cx))
899 }
900 })?;
901
902 let Some(Some(session)) = session else {
903 return workspace.update_in(cx, |workspace, window, cx| {
904 let weak = cx.entity().downgrade();
905 let fs = workspace.project().read(cx).fs().clone();
906 workspace.toggle_modal(window, cx, |window, cx| {
907 RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
908 });
909 });
910 };
911
912 let (path_style, project) = cx.update(|_, cx| {
913 (
914 session.read(cx).path_style(),
915 project::Project::remote(
916 session,
917 app_state.client.clone(),
918 app_state.node_runtime.clone(),
919 app_state.user_store.clone(),
920 app_state.languages.clone(),
921 app_state.fs.clone(),
922 cx,
923 ),
924 )
925 })?;
926
927 let home_dir = project
928 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
929 .await
930 .and_then(|path| path.into_abs_path())
931 .map(|path| RemotePathBuf::new(path, path_style))
932 .unwrap_or_else(|| match path_style {
933 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
934 PathStyle::Windows => {
935 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
936 }
937 });
938
939 workspace
940 .update_in(cx, |workspace, window, cx| {
941 let weak = cx.entity().downgrade();
942 workspace.toggle_modal(window, cx, |window, cx| {
943 RemoteServerProjects::project_picker(
944 create_new_window,
945 index,
946 connection_options,
947 project,
948 home_dir,
949 path_style,
950 window,
951 cx,
952 weak,
953 )
954 });
955 })
956 .ok();
957 Ok(())
958 })
959 .detach();
960 })
961 })
962 }
963
964 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
965 match &self.mode {
966 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
967 Mode::ProjectPicker(_) => {}
968 Mode::CreateRemoteServer(state) => {
969 if let Some(prompt) = state.ssh_prompt.as_ref() {
970 prompt.update(cx, |prompt, cx| {
971 prompt.confirm(window, cx);
972 });
973 return;
974 }
975
976 self.create_ssh_server(state.address_editor.clone(), window, cx);
977 }
978 Mode::EditNickname(state) => {
979 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
980 let index = state.index;
981 self.update_settings_file(cx, move |setting, _| {
982 if let Some(connections) = setting.ssh_connections.as_mut()
983 && let Some(connection) = connections.get_mut(index.0)
984 {
985 connection.nickname = text;
986 }
987 });
988 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
989 self.focus_handle.focus(window);
990 }
991 #[cfg(target_os = "windows")]
992 Mode::AddWslDistro(state) => {
993 let delegate = &state.picker.read(cx).delegate;
994 let distro = delegate.selected_distro().unwrap();
995 self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
996 }
997 }
998 }
999
1000 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1001 match &self.mode {
1002 Mode::Default(_) => cx.emit(DismissEvent),
1003 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
1004 let new_state = CreateRemoteServer::new(window, cx);
1005 let old_prompt = state.address_editor.read(cx).text(cx);
1006 new_state.address_editor.update(cx, |this, cx| {
1007 this.set_text(old_prompt, window, cx);
1008 });
1009
1010 self.mode = Mode::CreateRemoteServer(new_state);
1011 cx.notify();
1012 }
1013 _ => {
1014 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1015 self.focus_handle(cx).focus(window);
1016 cx.notify();
1017 }
1018 }
1019 }
1020
1021 fn render_ssh_connection(
1022 &mut self,
1023 ix: usize,
1024 ssh_server: RemoteEntry,
1025 window: &mut Window,
1026 cx: &mut Context<Self>,
1027 ) -> impl IntoElement {
1028 let connection = ssh_server.connection().into_owned();
1029
1030 let (main_label, aux_label, is_wsl) = match &connection {
1031 Connection::Ssh(connection) => {
1032 if let Some(nickname) = connection.nickname.clone() {
1033 let aux_label = SharedString::from(format!("({})", connection.host));
1034 (nickname.into(), Some(aux_label), false)
1035 } else {
1036 (connection.host.clone(), None, false)
1037 }
1038 }
1039 Connection::Wsl(wsl_connection_options) => {
1040 (wsl_connection_options.distro_name.clone(), None, true)
1041 }
1042 };
1043 v_flex()
1044 .w_full()
1045 .child(ListSeparator)
1046 .child(
1047 h_flex()
1048 .group("ssh-server")
1049 .w_full()
1050 .pt_0p5()
1051 .px_3()
1052 .gap_1()
1053 .overflow_hidden()
1054 .child(
1055 h_flex()
1056 .gap_1()
1057 .max_w_96()
1058 .overflow_hidden()
1059 .text_ellipsis()
1060 .when(is_wsl, |this| {
1061 this.child(
1062 Label::new("WSL:")
1063 .size(LabelSize::Small)
1064 .color(Color::Muted),
1065 )
1066 })
1067 .child(
1068 Label::new(main_label)
1069 .size(LabelSize::Small)
1070 .color(Color::Muted),
1071 ),
1072 )
1073 .children(
1074 aux_label.map(|label| {
1075 Label::new(label).size(LabelSize::Small).color(Color::Muted)
1076 }),
1077 ),
1078 )
1079 .child(match &ssh_server {
1080 RemoteEntry::Project {
1081 open_folder,
1082 projects,
1083 configure,
1084 connection,
1085 index,
1086 } => {
1087 let index = *index;
1088 List::new()
1089 .empty_message("No projects.")
1090 .children(projects.iter().enumerate().map(|(pix, p)| {
1091 v_flex().gap_0p5().child(self.render_ssh_project(
1092 index,
1093 ssh_server.clone(),
1094 pix,
1095 p,
1096 window,
1097 cx,
1098 ))
1099 }))
1100 .child(
1101 h_flex()
1102 .id(("new-remote-project-container", ix))
1103 .track_focus(&open_folder.focus_handle)
1104 .anchor_scroll(open_folder.scroll_anchor.clone())
1105 .on_action(cx.listener({
1106 let connection = connection.clone();
1107 move |this, _: &menu::Confirm, window, cx| {
1108 this.create_remote_project(
1109 index,
1110 connection.clone().into(),
1111 window,
1112 cx,
1113 );
1114 }
1115 }))
1116 .child(
1117 ListItem::new(("new-remote-project", ix))
1118 .toggle_state(
1119 open_folder.focus_handle.contains_focused(window, cx),
1120 )
1121 .inset(true)
1122 .spacing(ui::ListItemSpacing::Sparse)
1123 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1124 .child(Label::new("Open Folder"))
1125 .on_click(cx.listener({
1126 let connection = connection.clone();
1127 move |this, _, window, cx| {
1128 this.create_remote_project(
1129 index,
1130 connection.clone().into(),
1131 window,
1132 cx,
1133 );
1134 }
1135 })),
1136 ),
1137 )
1138 .child(
1139 h_flex()
1140 .id(("server-options-container", ix))
1141 .track_focus(&configure.focus_handle)
1142 .anchor_scroll(configure.scroll_anchor.clone())
1143 .on_action(cx.listener({
1144 let connection = connection.clone();
1145 move |this, _: &menu::Confirm, window, cx| {
1146 this.view_server_options(
1147 (index, connection.clone().into()),
1148 window,
1149 cx,
1150 );
1151 }
1152 }))
1153 .child(
1154 ListItem::new(("server-options", ix))
1155 .toggle_state(
1156 configure.focus_handle.contains_focused(window, cx),
1157 )
1158 .inset(true)
1159 .spacing(ui::ListItemSpacing::Sparse)
1160 .start_slot(
1161 Icon::new(IconName::Settings).color(Color::Muted),
1162 )
1163 .child(Label::new("View Server Options"))
1164 .on_click(cx.listener({
1165 let ssh_connection = connection.clone();
1166 move |this, _, window, cx| {
1167 this.view_server_options(
1168 (index, ssh_connection.clone().into()),
1169 window,
1170 cx,
1171 );
1172 }
1173 })),
1174 ),
1175 )
1176 }
1177 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1178 h_flex()
1179 .id(("new-remote-project-container", ix))
1180 .track_focus(&open_folder.focus_handle)
1181 .anchor_scroll(open_folder.scroll_anchor.clone())
1182 .on_action(cx.listener({
1183 let connection = connection.clone();
1184 let host = host.clone();
1185 move |this, _: &menu::Confirm, window, cx| {
1186 let new_ix = this.create_host_from_ssh_config(&host, cx);
1187 this.create_remote_project(
1188 new_ix.into(),
1189 connection.clone().into(),
1190 window,
1191 cx,
1192 );
1193 }
1194 }))
1195 .child(
1196 ListItem::new(("new-remote-project", ix))
1197 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1198 .inset(true)
1199 .spacing(ui::ListItemSpacing::Sparse)
1200 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1201 .child(Label::new("Open Folder"))
1202 .on_click(cx.listener({
1203 let host = host.clone();
1204 move |this, _, window, cx| {
1205 let new_ix = this.create_host_from_ssh_config(&host, cx);
1206 this.create_remote_project(
1207 new_ix.into(),
1208 connection.clone().into(),
1209 window,
1210 cx,
1211 );
1212 }
1213 })),
1214 ),
1215 ),
1216 })
1217 }
1218
1219 fn render_ssh_project(
1220 &mut self,
1221 server_ix: ServerIndex,
1222 server: RemoteEntry,
1223 ix: usize,
1224 (navigation, project): &(NavigableEntry, SshProject),
1225 window: &mut Window,
1226 cx: &mut Context<Self>,
1227 ) -> impl IntoElement {
1228 let create_new_window = self.create_new_window;
1229 let is_from_zed = server.is_from_zed();
1230 let element_id_base = SharedString::from(format!(
1231 "remote-project-{}",
1232 match server_ix {
1233 ServerIndex::Ssh(index) => format!("ssh-{index}"),
1234 ServerIndex::Wsl(index) => format!("wsl-{index}"),
1235 }
1236 ));
1237 let container_element_id_base =
1238 SharedString::from(format!("remote-project-container-{element_id_base}"));
1239
1240 let callback = Rc::new({
1241 let project = project.clone();
1242 move |remote_server_projects: &mut Self,
1243 secondary_confirm: bool,
1244 window: &mut Window,
1245 cx: &mut Context<Self>| {
1246 let Some(app_state) = remote_server_projects
1247 .workspace
1248 .read_with(cx, |workspace, _| workspace.app_state().clone())
1249 .log_err()
1250 else {
1251 return;
1252 };
1253 let project = project.clone();
1254 let server = server.connection().into_owned();
1255 cx.emit(DismissEvent);
1256
1257 let replace_window = match (create_new_window, secondary_confirm) {
1258 (true, false) | (false, true) => None,
1259 (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
1260 };
1261
1262 cx.spawn_in(window, async move |_, cx| {
1263 let result = open_remote_project(
1264 server.into(),
1265 project.paths.into_iter().map(PathBuf::from).collect(),
1266 app_state,
1267 OpenOptions {
1268 replace_window,
1269 ..OpenOptions::default()
1270 },
1271 cx,
1272 )
1273 .await;
1274 if let Err(e) = result {
1275 log::error!("Failed to connect: {e:#}");
1276 cx.prompt(
1277 gpui::PromptLevel::Critical,
1278 "Failed to connect",
1279 Some(&e.to_string()),
1280 &["Ok"],
1281 )
1282 .await
1283 .ok();
1284 }
1285 })
1286 .detach();
1287 }
1288 });
1289
1290 div()
1291 .id((container_element_id_base, ix))
1292 .track_focus(&navigation.focus_handle)
1293 .anchor_scroll(navigation.scroll_anchor.clone())
1294 .on_action(cx.listener({
1295 let callback = callback.clone();
1296 move |this, _: &menu::Confirm, window, cx| {
1297 callback(this, false, window, cx);
1298 }
1299 }))
1300 .on_action(cx.listener({
1301 let callback = callback.clone();
1302 move |this, _: &menu::SecondaryConfirm, window, cx| {
1303 callback(this, true, window, cx);
1304 }
1305 }))
1306 .child(
1307 ListItem::new((element_id_base, ix))
1308 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1309 .inset(true)
1310 .spacing(ui::ListItemSpacing::Sparse)
1311 .start_slot(
1312 Icon::new(IconName::Folder)
1313 .color(Color::Muted)
1314 .size(IconSize::Small),
1315 )
1316 .child(Label::new(project.paths.join(", ")))
1317 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1318 let secondary_confirm = e.modifiers().platform;
1319 callback(this, secondary_confirm, window, cx)
1320 }))
1321 .when(is_from_zed, |server_list_item| {
1322 server_list_item.end_hover_slot::<AnyElement>(Some(
1323 div()
1324 .mr_2()
1325 .child({
1326 let project = project.clone();
1327 // Right-margin to offset it from the Scrollbar
1328 IconButton::new("remove-remote-project", IconName::Trash)
1329 .icon_size(IconSize::Small)
1330 .shape(IconButtonShape::Square)
1331 .size(ButtonSize::Large)
1332 .tooltip(Tooltip::text("Delete Remote Project"))
1333 .on_click(cx.listener(move |this, _, _, cx| {
1334 this.delete_remote_project(server_ix, &project, cx)
1335 }))
1336 })
1337 .into_any_element(),
1338 ))
1339 }),
1340 )
1341 }
1342
1343 fn update_settings_file(
1344 &mut self,
1345 cx: &mut Context<Self>,
1346 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1347 ) {
1348 let Some(fs) = self
1349 .workspace
1350 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1351 .log_err()
1352 else {
1353 return;
1354 };
1355 update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
1356 }
1357
1358 fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1359 self.update_settings_file(cx, move |setting, _| {
1360 if let Some(connections) = setting.ssh_connections.as_mut() {
1361 connections.remove(server.0);
1362 }
1363 });
1364 }
1365
1366 fn delete_remote_project(
1367 &mut self,
1368 server: ServerIndex,
1369 project: &SshProject,
1370 cx: &mut Context<Self>,
1371 ) {
1372 match server {
1373 ServerIndex::Ssh(server) => {
1374 self.delete_ssh_project(server, project, cx);
1375 }
1376 ServerIndex::Wsl(server) => {
1377 self.delete_wsl_project(server, project, cx);
1378 }
1379 }
1380 }
1381
1382 fn delete_ssh_project(
1383 &mut self,
1384 server: SshServerIndex,
1385 project: &SshProject,
1386 cx: &mut Context<Self>,
1387 ) {
1388 let project = project.clone();
1389 self.update_settings_file(cx, move |setting, _| {
1390 if let Some(server) = setting
1391 .ssh_connections
1392 .as_mut()
1393 .and_then(|connections| connections.get_mut(server.0))
1394 {
1395 server.projects.remove(&project);
1396 }
1397 });
1398 }
1399
1400 fn delete_wsl_project(
1401 &mut self,
1402 server: WslServerIndex,
1403 project: &SshProject,
1404 cx: &mut Context<Self>,
1405 ) {
1406 let project = project.clone();
1407 self.update_settings_file(cx, move |setting, _| {
1408 if let Some(server) = setting
1409 .wsl_connections
1410 .as_mut()
1411 .and_then(|connections| connections.get_mut(server.0))
1412 {
1413 server.projects.remove(&project);
1414 }
1415 });
1416 }
1417
1418 #[cfg(target_os = "windows")]
1419 fn add_wsl_distro(
1420 &mut self,
1421 connection_options: remote::WslConnectionOptions,
1422 cx: &mut Context<Self>,
1423 ) {
1424 self.update_settings_file(cx, move |setting, _| {
1425 let connections = setting.wsl_connections.get_or_insert(Default::default());
1426
1427 let distro_name = SharedString::from(connection_options.distro_name);
1428 let user = connection_options.user;
1429
1430 if !connections
1431 .iter()
1432 .any(|conn| conn.distro_name == distro_name && conn.user == user)
1433 {
1434 connections.push(settings::WslConnection {
1435 distro_name,
1436 user,
1437 projects: BTreeSet::new(),
1438 })
1439 }
1440 });
1441 }
1442
1443 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1444 self.update_settings_file(cx, move |setting, _| {
1445 if let Some(connections) = setting.wsl_connections.as_mut() {
1446 connections.remove(server.0);
1447 }
1448 });
1449 }
1450
1451 fn add_ssh_server(
1452 &mut self,
1453 connection_options: remote::SshConnectionOptions,
1454 cx: &mut Context<Self>,
1455 ) {
1456 self.update_settings_file(cx, move |setting, _| {
1457 setting
1458 .ssh_connections
1459 .get_or_insert(Default::default())
1460 .push(SshConnection {
1461 host: SharedString::from(connection_options.host),
1462 username: connection_options.username,
1463 port: connection_options.port,
1464 projects: BTreeSet::new(),
1465 nickname: None,
1466 args: connection_options.args.unwrap_or_default(),
1467 upload_binary_over_ssh: None,
1468 port_forwards: connection_options.port_forwards,
1469 })
1470 });
1471 }
1472
1473 fn render_create_remote_server(
1474 &self,
1475 state: &CreateRemoteServer,
1476 window: &mut Window,
1477 cx: &mut Context<Self>,
1478 ) -> impl IntoElement {
1479 let ssh_prompt = state.ssh_prompt.clone();
1480
1481 state.address_editor.update(cx, |editor, cx| {
1482 if editor.text(cx).is_empty() {
1483 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1484 }
1485 });
1486
1487 let theme = cx.theme();
1488
1489 v_flex()
1490 .track_focus(&self.focus_handle(cx))
1491 .id("create-remote-server")
1492 .overflow_hidden()
1493 .size_full()
1494 .flex_1()
1495 .child(
1496 div()
1497 .p_2()
1498 .border_b_1()
1499 .border_color(theme.colors().border_variant)
1500 .child(state.address_editor.clone()),
1501 )
1502 .child(
1503 h_flex()
1504 .bg(theme.colors().editor_background)
1505 .rounded_b_sm()
1506 .w_full()
1507 .map(|this| {
1508 if let Some(ssh_prompt) = ssh_prompt {
1509 this.child(h_flex().w_full().child(ssh_prompt))
1510 } else if let Some(address_error) = &state.address_error {
1511 this.child(
1512 h_flex().p_2().w_full().gap_2().child(
1513 Label::new(address_error.clone())
1514 .size(LabelSize::Small)
1515 .color(Color::Error),
1516 ),
1517 )
1518 } else {
1519 this.child(
1520 h_flex()
1521 .p_2()
1522 .w_full()
1523 .gap_1()
1524 .child(
1525 Label::new(
1526 "Enter the command you use to SSH into this server.",
1527 )
1528 .color(Color::Muted)
1529 .size(LabelSize::Small),
1530 )
1531 .child(
1532 Button::new("learn-more", "Learn More")
1533 .label_size(LabelSize::Small)
1534 .icon(IconName::ArrowUpRight)
1535 .icon_size(IconSize::XSmall)
1536 .on_click(|_, _, cx| {
1537 cx.open_url(
1538 "https://zed.dev/docs/remote-development",
1539 );
1540 }),
1541 ),
1542 )
1543 }
1544 }),
1545 )
1546 }
1547
1548 #[cfg(target_os = "windows")]
1549 fn render_add_wsl_distro(
1550 &self,
1551 state: &AddWslDistro,
1552 window: &mut Window,
1553 cx: &mut Context<Self>,
1554 ) -> impl IntoElement {
1555 let connection_prompt = state.connection_prompt.clone();
1556
1557 state.picker.update(cx, |picker, cx| {
1558 picker.focus_handle(cx).focus(window);
1559 });
1560
1561 v_flex()
1562 .id("add-wsl-distro")
1563 .overflow_hidden()
1564 .size_full()
1565 .flex_1()
1566 .map(|this| {
1567 if let Some(connection_prompt) = connection_prompt {
1568 this.child(connection_prompt)
1569 } else {
1570 this.child(state.picker.clone())
1571 }
1572 })
1573 }
1574
1575 fn render_view_options(
1576 &mut self,
1577 options: ViewServerOptionsState,
1578 window: &mut Window,
1579 cx: &mut Context<Self>,
1580 ) -> impl IntoElement {
1581 let last_entry = options.entries().last().unwrap();
1582
1583 let mut view = Navigable::new(
1584 div()
1585 .track_focus(&self.focus_handle(cx))
1586 .size_full()
1587 .child(match &options {
1588 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1589 connection_string: connection.host.clone().into(),
1590 paths: Default::default(),
1591 nickname: connection.nickname.clone().map(|s| s.into()),
1592 is_wsl: false,
1593 }
1594 .render(window, cx)
1595 .into_any_element(),
1596 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1597 connection_string: connection.distro_name.clone().into(),
1598 paths: Default::default(),
1599 nickname: None,
1600 is_wsl: true,
1601 }
1602 .render(window, cx)
1603 .into_any_element(),
1604 })
1605 .child(
1606 v_flex()
1607 .pb_1()
1608 .child(ListSeparator)
1609 .map(|this| match &options {
1610 ViewServerOptionsState::Ssh {
1611 connection,
1612 entries,
1613 server_index,
1614 } => this.child(self.render_edit_ssh(
1615 connection,
1616 *server_index,
1617 entries,
1618 window,
1619 cx,
1620 )),
1621 ViewServerOptionsState::Wsl {
1622 connection,
1623 entries,
1624 server_index,
1625 } => this.child(self.render_edit_wsl(
1626 connection,
1627 *server_index,
1628 entries,
1629 window,
1630 cx,
1631 )),
1632 })
1633 .child(ListSeparator)
1634 .child({
1635 div()
1636 .id("ssh-options-copy-server-address")
1637 .track_focus(&last_entry.focus_handle)
1638 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1639 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1640 cx.focus_self(window);
1641 cx.notify();
1642 }))
1643 .child(
1644 ListItem::new("go-back")
1645 .toggle_state(
1646 last_entry.focus_handle.contains_focused(window, cx),
1647 )
1648 .inset(true)
1649 .spacing(ui::ListItemSpacing::Sparse)
1650 .start_slot(
1651 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1652 )
1653 .child(Label::new("Go Back"))
1654 .on_click(cx.listener(|this, _, window, cx| {
1655 this.mode =
1656 Mode::default_mode(&this.ssh_config_servers, cx);
1657 cx.focus_self(window);
1658 cx.notify()
1659 })),
1660 )
1661 }),
1662 )
1663 .into_any_element(),
1664 );
1665
1666 for entry in options.entries() {
1667 view = view.entry(entry.clone());
1668 }
1669
1670 view.render(window, cx).into_any_element()
1671 }
1672
1673 fn render_edit_wsl(
1674 &self,
1675 connection: &WslConnectionOptions,
1676 index: WslServerIndex,
1677 entries: &[NavigableEntry],
1678 window: &mut Window,
1679 cx: &mut Context<Self>,
1680 ) -> impl IntoElement {
1681 let distro_name = SharedString::new(connection.distro_name.clone());
1682
1683 v_flex().child({
1684 fn remove_wsl_distro(
1685 remote_servers: Entity<RemoteServerProjects>,
1686 index: WslServerIndex,
1687 distro_name: SharedString,
1688 window: &mut Window,
1689 cx: &mut App,
1690 ) {
1691 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
1692
1693 let confirmation = window.prompt(
1694 PromptLevel::Warning,
1695 &prompt_message,
1696 None,
1697 &["Yes, remove it", "No, keep it"],
1698 cx,
1699 );
1700
1701 cx.spawn(async move |cx| {
1702 if confirmation.await.ok() == Some(0) {
1703 remote_servers
1704 .update(cx, |this, cx| {
1705 this.delete_wsl_distro(index, cx);
1706 })
1707 .ok();
1708 remote_servers
1709 .update(cx, |this, cx| {
1710 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1711 cx.notify();
1712 })
1713 .ok();
1714 }
1715 anyhow::Ok(())
1716 })
1717 .detach_and_log_err(cx);
1718 }
1719 div()
1720 .id("wsl-options-remove-distro")
1721 .track_focus(&entries[0].focus_handle)
1722 .on_action(cx.listener({
1723 let distro_name = distro_name.clone();
1724 move |_, _: &menu::Confirm, window, cx| {
1725 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1726 cx.focus_self(window);
1727 }
1728 }))
1729 .child(
1730 ListItem::new("remove-distro")
1731 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1732 .inset(true)
1733 .spacing(ui::ListItemSpacing::Sparse)
1734 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1735 .child(Label::new("Remove Distro").color(Color::Error))
1736 .on_click(cx.listener(move |_, _, window, cx| {
1737 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1738 cx.focus_self(window);
1739 })),
1740 )
1741 })
1742 }
1743
1744 fn render_edit_ssh(
1745 &self,
1746 connection: &SshConnectionOptions,
1747 index: SshServerIndex,
1748 entries: &[NavigableEntry],
1749 window: &mut Window,
1750 cx: &mut Context<Self>,
1751 ) -> impl IntoElement {
1752 let connection_string = SharedString::new(connection.host.clone());
1753
1754 v_flex()
1755 .child({
1756 let label = if connection.nickname.is_some() {
1757 "Edit Nickname"
1758 } else {
1759 "Add Nickname to Server"
1760 };
1761 div()
1762 .id("ssh-options-add-nickname")
1763 .track_focus(&entries[0].focus_handle)
1764 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
1765 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
1766 cx.notify();
1767 }))
1768 .child(
1769 ListItem::new("add-nickname")
1770 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1771 .inset(true)
1772 .spacing(ui::ListItemSpacing::Sparse)
1773 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1774 .child(Label::new(label))
1775 .on_click(cx.listener(move |this, _, window, cx| {
1776 this.mode =
1777 Mode::EditNickname(EditNicknameState::new(index, window, cx));
1778 cx.notify();
1779 })),
1780 )
1781 })
1782 .child({
1783 let workspace = self.workspace.clone();
1784 fn callback(
1785 workspace: WeakEntity<Workspace>,
1786 connection_string: SharedString,
1787 cx: &mut App,
1788 ) {
1789 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
1790 workspace
1791 .update(cx, |this, cx| {
1792 struct SshServerAddressCopiedToClipboard;
1793 let notification = format!(
1794 "Copied server address ({}) to clipboard",
1795 connection_string
1796 );
1797
1798 this.show_toast(
1799 Toast::new(
1800 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
1801 connection_string.clone(),
1802 ),
1803 notification,
1804 )
1805 .autohide(),
1806 cx,
1807 );
1808 })
1809 .ok();
1810 }
1811 div()
1812 .id("ssh-options-copy-server-address")
1813 .track_focus(&entries[1].focus_handle)
1814 .on_action({
1815 let connection_string = connection_string.clone();
1816 let workspace = self.workspace.clone();
1817 move |_: &menu::Confirm, _, cx| {
1818 callback(workspace.clone(), connection_string.clone(), cx);
1819 }
1820 })
1821 .child(
1822 ListItem::new("copy-server-address")
1823 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
1824 .inset(true)
1825 .spacing(ui::ListItemSpacing::Sparse)
1826 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1827 .child(Label::new("Copy Server Address"))
1828 .end_hover_slot(
1829 Label::new(connection_string.clone()).color(Color::Muted),
1830 )
1831 .on_click({
1832 let connection_string = connection_string.clone();
1833 move |_, _, cx| {
1834 callback(workspace.clone(), connection_string.clone(), cx);
1835 }
1836 }),
1837 )
1838 })
1839 .child({
1840 fn remove_ssh_server(
1841 remote_servers: Entity<RemoteServerProjects>,
1842 index: SshServerIndex,
1843 connection_string: SharedString,
1844 window: &mut Window,
1845 cx: &mut App,
1846 ) {
1847 let prompt_message = format!("Remove server `{}`?", connection_string);
1848
1849 let confirmation = window.prompt(
1850 PromptLevel::Warning,
1851 &prompt_message,
1852 None,
1853 &["Yes, remove it", "No, keep it"],
1854 cx,
1855 );
1856
1857 cx.spawn(async move |cx| {
1858 if confirmation.await.ok() == Some(0) {
1859 remote_servers
1860 .update(cx, |this, cx| {
1861 this.delete_ssh_server(index, cx);
1862 })
1863 .ok();
1864 remote_servers
1865 .update(cx, |this, cx| {
1866 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1867 cx.notify();
1868 })
1869 .ok();
1870 }
1871 anyhow::Ok(())
1872 })
1873 .detach_and_log_err(cx);
1874 }
1875 div()
1876 .id("ssh-options-copy-server-address")
1877 .track_focus(&entries[2].focus_handle)
1878 .on_action(cx.listener({
1879 let connection_string = connection_string.clone();
1880 move |_, _: &menu::Confirm, window, cx| {
1881 remove_ssh_server(
1882 cx.entity(),
1883 index,
1884 connection_string.clone(),
1885 window,
1886 cx,
1887 );
1888 cx.focus_self(window);
1889 }
1890 }))
1891 .child(
1892 ListItem::new("remove-server")
1893 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
1894 .inset(true)
1895 .spacing(ui::ListItemSpacing::Sparse)
1896 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1897 .child(Label::new("Remove Server").color(Color::Error))
1898 .on_click(cx.listener(move |_, _, window, cx| {
1899 remove_ssh_server(
1900 cx.entity(),
1901 index,
1902 connection_string.clone(),
1903 window,
1904 cx,
1905 );
1906 cx.focus_self(window);
1907 })),
1908 )
1909 })
1910 }
1911
1912 fn render_edit_nickname(
1913 &self,
1914 state: &EditNicknameState,
1915 window: &mut Window,
1916 cx: &mut Context<Self>,
1917 ) -> impl IntoElement {
1918 let Some(connection) = SshSettings::get_global(cx)
1919 .ssh_connections()
1920 .nth(state.index.0)
1921 else {
1922 return v_flex()
1923 .id("ssh-edit-nickname")
1924 .track_focus(&self.focus_handle(cx));
1925 };
1926
1927 let connection_string = connection.host.clone();
1928 let nickname = connection.nickname.map(|s| s.into());
1929
1930 v_flex()
1931 .id("ssh-edit-nickname")
1932 .track_focus(&self.focus_handle(cx))
1933 .child(
1934 SshConnectionHeader {
1935 connection_string,
1936 paths: Default::default(),
1937 nickname,
1938 is_wsl: false,
1939 }
1940 .render(window, cx),
1941 )
1942 .child(
1943 h_flex()
1944 .p_2()
1945 .border_t_1()
1946 .border_color(cx.theme().colors().border_variant)
1947 .child(state.editor.clone()),
1948 )
1949 }
1950
1951 fn render_default(
1952 &mut self,
1953 mut state: DefaultState,
1954 window: &mut Window,
1955 cx: &mut Context<Self>,
1956 ) -> impl IntoElement {
1957 let ssh_settings = SshSettings::get_global(cx);
1958 let mut should_rebuild = false;
1959
1960 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
1961 .servers
1962 .iter()
1963 .filter_map(|server| match server {
1964 RemoteEntry::Project {
1965 connection: Connection::Ssh(connection),
1966 ..
1967 } => Some(connection),
1968 _ => None,
1969 }));
1970
1971 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
1972 .servers
1973 .iter()
1974 .filter_map(|server| match server {
1975 RemoteEntry::Project {
1976 connection: Connection::Wsl(connection),
1977 ..
1978 } => Some(connection),
1979 _ => None,
1980 }));
1981
1982 if ssh_connections_changed || wsl_connections_changed {
1983 should_rebuild = true;
1984 };
1985
1986 if !should_rebuild && ssh_settings.read_ssh_config {
1987 let current_ssh_hosts: BTreeSet<SharedString> = state
1988 .servers
1989 .iter()
1990 .filter_map(|server| match server {
1991 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1992 _ => None,
1993 })
1994 .collect();
1995 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1996 for server in &state.servers {
1997 if let RemoteEntry::Project {
1998 connection: Connection::Ssh(connection),
1999 ..
2000 } = server
2001 {
2002 expected_ssh_hosts.remove(&connection.host);
2003 }
2004 }
2005 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2006 }
2007
2008 if should_rebuild {
2009 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2010 if let Mode::Default(new_state) = &self.mode {
2011 state = new_state.clone();
2012 }
2013 }
2014
2015 let connect_button = div()
2016 .id("ssh-connect-new-server-container")
2017 .track_focus(&state.add_new_server.focus_handle)
2018 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2019 .child(
2020 ListItem::new("register-remove-server-button")
2021 .toggle_state(
2022 state
2023 .add_new_server
2024 .focus_handle
2025 .contains_focused(window, cx),
2026 )
2027 .inset(true)
2028 .spacing(ui::ListItemSpacing::Sparse)
2029 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2030 .child(Label::new("Connect New Server"))
2031 .on_click(cx.listener(|this, _, window, cx| {
2032 let state = CreateRemoteServer::new(window, cx);
2033 this.mode = Mode::CreateRemoteServer(state);
2034
2035 cx.notify();
2036 })),
2037 )
2038 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2039 let state = CreateRemoteServer::new(window, cx);
2040 this.mode = Mode::CreateRemoteServer(state);
2041
2042 cx.notify();
2043 }));
2044
2045 #[cfg(target_os = "windows")]
2046 let wsl_connect_button = div()
2047 .id("wsl-connect-new-server")
2048 .track_focus(&state.add_new_wsl.focus_handle)
2049 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2050 .child(
2051 ListItem::new("wsl-add-new-server")
2052 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2053 .inset(true)
2054 .spacing(ui::ListItemSpacing::Sparse)
2055 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2056 .child(Label::new("Add WSL Distro"))
2057 .on_click(cx.listener(|this, _, window, cx| {
2058 let state = AddWslDistro::new(window, cx);
2059 this.mode = Mode::AddWslDistro(state);
2060
2061 cx.notify();
2062 })),
2063 )
2064 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2065 let state = AddWslDistro::new(window, cx);
2066 this.mode = Mode::AddWslDistro(state);
2067
2068 cx.notify();
2069 }));
2070
2071 let modal_section = v_flex()
2072 .track_focus(&self.focus_handle(cx))
2073 .id("ssh-server-list")
2074 .overflow_y_scroll()
2075 .track_scroll(&state.scroll_handle)
2076 .size_full()
2077 .child(connect_button);
2078
2079 #[cfg(target_os = "windows")]
2080 let modal_section = modal_section.child(wsl_connect_button);
2081 #[cfg(not(target_os = "windows"))]
2082 let modal_section = modal_section;
2083
2084 let mut modal_section = Navigable::new(
2085 modal_section
2086 .child(
2087 List::new()
2088 .empty_message(
2089 v_flex()
2090 .child(
2091 div().px_3().child(
2092 Label::new("No remote servers registered yet.")
2093 .color(Color::Muted),
2094 ),
2095 )
2096 .into_any_element(),
2097 )
2098 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2099 self.render_ssh_connection(ix, connection.clone(), window, cx)
2100 .into_any_element()
2101 })),
2102 )
2103 .into_any_element(),
2104 )
2105 .entry(state.add_new_server.clone());
2106
2107 if cfg!(target_os = "windows") {
2108 modal_section = modal_section.entry(state.add_new_wsl.clone());
2109 }
2110
2111 for server in &state.servers {
2112 match server {
2113 RemoteEntry::Project {
2114 open_folder,
2115 projects,
2116 configure,
2117 ..
2118 } => {
2119 for (navigation_state, _) in projects {
2120 modal_section = modal_section.entry(navigation_state.clone());
2121 }
2122 modal_section = modal_section
2123 .entry(open_folder.clone())
2124 .entry(configure.clone());
2125 }
2126 RemoteEntry::SshConfig { open_folder, .. } => {
2127 modal_section = modal_section.entry(open_folder.clone());
2128 }
2129 }
2130 }
2131 let mut modal_section = modal_section.render(window, cx).into_any_element();
2132
2133 let (create_window, reuse_window) = if self.create_new_window {
2134 (
2135 window.keystroke_text_for(&menu::Confirm),
2136 window.keystroke_text_for(&menu::SecondaryConfirm),
2137 )
2138 } else {
2139 (
2140 window.keystroke_text_for(&menu::SecondaryConfirm),
2141 window.keystroke_text_for(&menu::Confirm),
2142 )
2143 };
2144 let placeholder_text = Arc::from(format!(
2145 "{reuse_window} reuses this window, {create_window} opens a new one",
2146 ));
2147
2148 Modal::new("remote-projects", None)
2149 .header(
2150 ModalHeader::new()
2151 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2152 .child(
2153 Label::new(placeholder_text)
2154 .color(Color::Muted)
2155 .size(LabelSize::XSmall),
2156 ),
2157 )
2158 .section(
2159 Section::new().padded(false).child(
2160 v_flex()
2161 .min_h(rems(20.))
2162 .size_full()
2163 .relative()
2164 .child(ListSeparator)
2165 .child(
2166 canvas(
2167 |bounds, window, cx| {
2168 modal_section.prepaint_as_root(
2169 bounds.origin,
2170 bounds.size.into(),
2171 window,
2172 cx,
2173 );
2174 modal_section
2175 },
2176 |_, mut modal_section, window, cx| {
2177 modal_section.paint(window, cx);
2178 },
2179 )
2180 .size_full(),
2181 )
2182 .vertical_scrollbar_for(state.scroll_handle, window, cx),
2183 ),
2184 )
2185 .into_any_element()
2186 }
2187
2188 fn create_host_from_ssh_config(
2189 &mut self,
2190 ssh_config_host: &SharedString,
2191 cx: &mut Context<'_, Self>,
2192 ) -> SshServerIndex {
2193 let new_ix = Arc::new(AtomicUsize::new(0));
2194
2195 let update_new_ix = new_ix.clone();
2196 self.update_settings_file(cx, move |settings, _| {
2197 update_new_ix.store(
2198 settings
2199 .ssh_connections
2200 .as_ref()
2201 .map_or(0, |connections| connections.len()),
2202 atomic::Ordering::Release,
2203 );
2204 });
2205
2206 self.add_ssh_server(
2207 SshConnectionOptions {
2208 host: ssh_config_host.to_string(),
2209 ..SshConnectionOptions::default()
2210 },
2211 cx,
2212 );
2213 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2214 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2215 }
2216}
2217
2218fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2219 let mut user_ssh_config_watcher =
2220 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2221 let mut global_ssh_config_watcher = watch_config_file(
2222 cx.background_executor(),
2223 fs,
2224 global_ssh_config_file().to_owned(),
2225 );
2226
2227 cx.spawn(async move |remote_server_projects, cx| {
2228 let mut global_hosts = BTreeSet::default();
2229 let mut user_hosts = BTreeSet::default();
2230 let mut running_receivers = 2;
2231
2232 loop {
2233 select! {
2234 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2235 match new_global_file_contents {
2236 Some(new_global_file_contents) => {
2237 global_hosts = parse_ssh_config_hosts(&new_global_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 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2254 match new_user_file_contents {
2255 Some(new_user_file_contents) => {
2256 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2257 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2258 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2259 cx.notify();
2260 }).is_err() {
2261 return;
2262 }
2263 },
2264 None => {
2265 running_receivers -= 1;
2266 if running_receivers == 0 {
2267 return;
2268 }
2269 }
2270 }
2271 },
2272 }
2273 }
2274 })
2275}
2276
2277fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2278 element.read(cx).text(cx).trim().to_string()
2279}
2280
2281impl ModalView for RemoteServerProjects {}
2282
2283impl Focusable for RemoteServerProjects {
2284 fn focus_handle(&self, cx: &App) -> FocusHandle {
2285 match &self.mode {
2286 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2287 _ => self.focus_handle.clone(),
2288 }
2289 }
2290}
2291
2292impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2293
2294impl Render for RemoteServerProjects {
2295 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2296 div()
2297 .elevation_3(cx)
2298 .w(rems(34.))
2299 .key_context("RemoteServerModal")
2300 .on_action(cx.listener(Self::cancel))
2301 .on_action(cx.listener(Self::confirm))
2302 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2303 this.focus_handle(cx).focus(window);
2304 }))
2305 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2306 if matches!(this.mode, Mode::Default(_)) {
2307 cx.emit(DismissEvent)
2308 }
2309 }))
2310 .child(match &self.mode {
2311 Mode::Default(state) => self
2312 .render_default(state.clone(), window, cx)
2313 .into_any_element(),
2314 Mode::ViewServerOptions(state) => self
2315 .render_view_options(state.clone(), window, cx)
2316 .into_any_element(),
2317 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2318 Mode::CreateRemoteServer(state) => self
2319 .render_create_remote_server(state, window, cx)
2320 .into_any_element(),
2321 Mode::EditNickname(state) => self
2322 .render_edit_nickname(state, window, cx)
2323 .into_any_element(),
2324 #[cfg(target_os = "windows")]
2325 Mode::AddWslDistro(state) => self
2326 .render_add_wsl_distro(state, window, cx)
2327 .into_any_element(),
2328 })
2329 }
2330}