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