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 setting
1389 .wsl_connections
1390 .get_or_insert(Default::default())
1391 .push(settings::WslConnection {
1392 distro_name: SharedString::from(connection_options.distro_name),
1393 user: connection_options.user,
1394 projects: BTreeSet::new(),
1395 })
1396 });
1397 }
1398
1399 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1400 self.update_settings_file(cx, move |setting, _| {
1401 if let Some(connections) = setting.wsl_connections.as_mut() {
1402 connections.remove(server.0);
1403 }
1404 });
1405 }
1406
1407 fn add_ssh_server(
1408 &mut self,
1409 connection_options: remote::SshConnectionOptions,
1410 cx: &mut Context<Self>,
1411 ) {
1412 self.update_settings_file(cx, move |setting, _| {
1413 setting
1414 .ssh_connections
1415 .get_or_insert(Default::default())
1416 .push(SshConnection {
1417 host: SharedString::from(connection_options.host),
1418 username: connection_options.username,
1419 port: connection_options.port,
1420 projects: BTreeSet::new(),
1421 nickname: None,
1422 args: connection_options.args.unwrap_or_default(),
1423 upload_binary_over_ssh: None,
1424 port_forwards: connection_options.port_forwards,
1425 })
1426 });
1427 }
1428
1429 fn render_create_remote_server(
1430 &self,
1431 state: &CreateRemoteServer,
1432 window: &mut Window,
1433 cx: &mut Context<Self>,
1434 ) -> impl IntoElement {
1435 let ssh_prompt = state.ssh_prompt.clone();
1436
1437 state.address_editor.update(cx, |editor, cx| {
1438 if editor.text(cx).is_empty() {
1439 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1440 }
1441 });
1442
1443 let theme = cx.theme();
1444
1445 v_flex()
1446 .track_focus(&self.focus_handle(cx))
1447 .id("create-remote-server")
1448 .overflow_hidden()
1449 .size_full()
1450 .flex_1()
1451 .child(
1452 div()
1453 .p_2()
1454 .border_b_1()
1455 .border_color(theme.colors().border_variant)
1456 .child(state.address_editor.clone()),
1457 )
1458 .child(
1459 h_flex()
1460 .bg(theme.colors().editor_background)
1461 .rounded_b_sm()
1462 .w_full()
1463 .map(|this| {
1464 if let Some(ssh_prompt) = ssh_prompt {
1465 this.child(h_flex().w_full().child(ssh_prompt))
1466 } else if let Some(address_error) = &state.address_error {
1467 this.child(
1468 h_flex().p_2().w_full().gap_2().child(
1469 Label::new(address_error.clone())
1470 .size(LabelSize::Small)
1471 .color(Color::Error),
1472 ),
1473 )
1474 } else {
1475 this.child(
1476 h_flex()
1477 .p_2()
1478 .w_full()
1479 .gap_1()
1480 .child(
1481 Label::new(
1482 "Enter the command you use to SSH into this server.",
1483 )
1484 .color(Color::Muted)
1485 .size(LabelSize::Small),
1486 )
1487 .child(
1488 Button::new("learn-more", "Learn More")
1489 .label_size(LabelSize::Small)
1490 .icon(IconName::ArrowUpRight)
1491 .icon_size(IconSize::XSmall)
1492 .on_click(|_, _, cx| {
1493 cx.open_url(
1494 "https://zed.dev/docs/remote-development",
1495 );
1496 }),
1497 ),
1498 )
1499 }
1500 }),
1501 )
1502 }
1503
1504 #[cfg(target_os = "windows")]
1505 fn render_add_wsl_distro(
1506 &self,
1507 state: &AddWslDistro,
1508 window: &mut Window,
1509 cx: &mut Context<Self>,
1510 ) -> impl IntoElement {
1511 let connection_prompt = state.connection_prompt.clone();
1512
1513 state.picker.update(cx, |picker, cx| {
1514 picker.focus_handle(cx).focus(window);
1515 });
1516
1517 v_flex()
1518 .id("add-wsl-distro")
1519 .overflow_hidden()
1520 .size_full()
1521 .flex_1()
1522 .map(|this| {
1523 if let Some(connection_prompt) = connection_prompt {
1524 this.child(connection_prompt)
1525 } else {
1526 this.child(state.picker.clone())
1527 }
1528 })
1529 }
1530
1531 fn render_view_options(
1532 &mut self,
1533 options: ViewServerOptionsState,
1534 window: &mut Window,
1535 cx: &mut Context<Self>,
1536 ) -> impl IntoElement {
1537 let last_entry = options.entries().last().unwrap();
1538
1539 let mut view = Navigable::new(
1540 div()
1541 .track_focus(&self.focus_handle(cx))
1542 .size_full()
1543 .child(match &options {
1544 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1545 connection_string: connection.host.clone().into(),
1546 paths: Default::default(),
1547 nickname: connection.nickname.clone().map(|s| s.into()),
1548 is_wsl: false,
1549 }
1550 .render(window, cx)
1551 .into_any_element(),
1552 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1553 connection_string: connection.distro_name.clone().into(),
1554 paths: Default::default(),
1555 nickname: None,
1556 is_wsl: true,
1557 }
1558 .render(window, cx)
1559 .into_any_element(),
1560 })
1561 .child(
1562 v_flex()
1563 .pb_1()
1564 .child(ListSeparator)
1565 .map(|this| match &options {
1566 ViewServerOptionsState::Ssh {
1567 connection,
1568 entries,
1569 server_index,
1570 } => this.child(self.render_edit_ssh(
1571 connection,
1572 *server_index,
1573 entries,
1574 window,
1575 cx,
1576 )),
1577 ViewServerOptionsState::Wsl {
1578 connection,
1579 entries,
1580 server_index,
1581 } => this.child(self.render_edit_wsl(
1582 connection,
1583 *server_index,
1584 entries,
1585 window,
1586 cx,
1587 )),
1588 })
1589 .child(ListSeparator)
1590 .child({
1591 div()
1592 .id("ssh-options-copy-server-address")
1593 .track_focus(&last_entry.focus_handle)
1594 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1595 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1596 cx.focus_self(window);
1597 cx.notify();
1598 }))
1599 .child(
1600 ListItem::new("go-back")
1601 .toggle_state(
1602 last_entry.focus_handle.contains_focused(window, cx),
1603 )
1604 .inset(true)
1605 .spacing(ui::ListItemSpacing::Sparse)
1606 .start_slot(
1607 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1608 )
1609 .child(Label::new("Go Back"))
1610 .on_click(cx.listener(|this, _, window, cx| {
1611 this.mode =
1612 Mode::default_mode(&this.ssh_config_servers, cx);
1613 cx.focus_self(window);
1614 cx.notify()
1615 })),
1616 )
1617 }),
1618 )
1619 .into_any_element(),
1620 );
1621
1622 for entry in options.entries() {
1623 view = view.entry(entry.clone());
1624 }
1625
1626 view.render(window, cx).into_any_element()
1627 }
1628
1629 fn render_edit_wsl(
1630 &self,
1631 connection: &WslConnectionOptions,
1632 index: WslServerIndex,
1633 entries: &[NavigableEntry],
1634 window: &mut Window,
1635 cx: &mut Context<Self>,
1636 ) -> impl IntoElement {
1637 let distro_name = SharedString::new(connection.distro_name.clone());
1638
1639 v_flex().child({
1640 fn remove_wsl_distro(
1641 remote_servers: Entity<RemoteServerProjects>,
1642 index: WslServerIndex,
1643 distro_name: SharedString,
1644 window: &mut Window,
1645 cx: &mut App,
1646 ) {
1647 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
1648
1649 let confirmation = window.prompt(
1650 PromptLevel::Warning,
1651 &prompt_message,
1652 None,
1653 &["Yes, remove it", "No, keep it"],
1654 cx,
1655 );
1656
1657 cx.spawn(async move |cx| {
1658 if confirmation.await.ok() == Some(0) {
1659 remote_servers
1660 .update(cx, |this, cx| {
1661 this.delete_wsl_distro(index, cx);
1662 })
1663 .ok();
1664 remote_servers
1665 .update(cx, |this, cx| {
1666 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1667 cx.notify();
1668 })
1669 .ok();
1670 }
1671 anyhow::Ok(())
1672 })
1673 .detach_and_log_err(cx);
1674 }
1675 div()
1676 .id("wsl-options-remove-distro")
1677 .track_focus(&entries[0].focus_handle)
1678 .on_action(cx.listener({
1679 let distro_name = distro_name.clone();
1680 move |_, _: &menu::Confirm, window, cx| {
1681 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1682 cx.focus_self(window);
1683 }
1684 }))
1685 .child(
1686 ListItem::new("remove-distro")
1687 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1688 .inset(true)
1689 .spacing(ui::ListItemSpacing::Sparse)
1690 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1691 .child(Label::new("Remove Distro").color(Color::Error))
1692 .on_click(cx.listener(move |_, _, window, cx| {
1693 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
1694 cx.focus_self(window);
1695 })),
1696 )
1697 })
1698 }
1699
1700 fn render_edit_ssh(
1701 &self,
1702 connection: &SshConnectionOptions,
1703 index: SshServerIndex,
1704 entries: &[NavigableEntry],
1705 window: &mut Window,
1706 cx: &mut Context<Self>,
1707 ) -> impl IntoElement {
1708 let connection_string = SharedString::new(connection.host.clone());
1709
1710 v_flex()
1711 .child({
1712 let label = if connection.nickname.is_some() {
1713 "Edit Nickname"
1714 } else {
1715 "Add Nickname to Server"
1716 };
1717 div()
1718 .id("ssh-options-add-nickname")
1719 .track_focus(&entries[0].focus_handle)
1720 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
1721 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
1722 cx.notify();
1723 }))
1724 .child(
1725 ListItem::new("add-nickname")
1726 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
1727 .inset(true)
1728 .spacing(ui::ListItemSpacing::Sparse)
1729 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1730 .child(Label::new(label))
1731 .on_click(cx.listener(move |this, _, window, cx| {
1732 this.mode =
1733 Mode::EditNickname(EditNicknameState::new(index, window, cx));
1734 cx.notify();
1735 })),
1736 )
1737 })
1738 .child({
1739 let workspace = self.workspace.clone();
1740 fn callback(
1741 workspace: WeakEntity<Workspace>,
1742 connection_string: SharedString,
1743 cx: &mut App,
1744 ) {
1745 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
1746 workspace
1747 .update(cx, |this, cx| {
1748 struct SshServerAddressCopiedToClipboard;
1749 let notification = format!(
1750 "Copied server address ({}) to clipboard",
1751 connection_string
1752 );
1753
1754 this.show_toast(
1755 Toast::new(
1756 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
1757 connection_string.clone(),
1758 ),
1759 notification,
1760 )
1761 .autohide(),
1762 cx,
1763 );
1764 })
1765 .ok();
1766 }
1767 div()
1768 .id("ssh-options-copy-server-address")
1769 .track_focus(&entries[1].focus_handle)
1770 .on_action({
1771 let connection_string = connection_string.clone();
1772 let workspace = self.workspace.clone();
1773 move |_: &menu::Confirm, _, cx| {
1774 callback(workspace.clone(), connection_string.clone(), cx);
1775 }
1776 })
1777 .child(
1778 ListItem::new("copy-server-address")
1779 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
1780 .inset(true)
1781 .spacing(ui::ListItemSpacing::Sparse)
1782 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
1783 .child(Label::new("Copy Server Address"))
1784 .end_hover_slot(
1785 Label::new(connection_string.clone()).color(Color::Muted),
1786 )
1787 .on_click({
1788 let connection_string = connection_string.clone();
1789 move |_, _, cx| {
1790 callback(workspace.clone(), connection_string.clone(), cx);
1791 }
1792 }),
1793 )
1794 })
1795 .child({
1796 fn remove_ssh_server(
1797 remote_servers: Entity<RemoteServerProjects>,
1798 index: SshServerIndex,
1799 connection_string: SharedString,
1800 window: &mut Window,
1801 cx: &mut App,
1802 ) {
1803 let prompt_message = format!("Remove server `{}`?", connection_string);
1804
1805 let confirmation = window.prompt(
1806 PromptLevel::Warning,
1807 &prompt_message,
1808 None,
1809 &["Yes, remove it", "No, keep it"],
1810 cx,
1811 );
1812
1813 cx.spawn(async move |cx| {
1814 if confirmation.await.ok() == Some(0) {
1815 remote_servers
1816 .update(cx, |this, cx| {
1817 this.delete_ssh_server(index, cx);
1818 })
1819 .ok();
1820 remote_servers
1821 .update(cx, |this, cx| {
1822 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1823 cx.notify();
1824 })
1825 .ok();
1826 }
1827 anyhow::Ok(())
1828 })
1829 .detach_and_log_err(cx);
1830 }
1831 div()
1832 .id("ssh-options-copy-server-address")
1833 .track_focus(&entries[2].focus_handle)
1834 .on_action(cx.listener({
1835 let connection_string = connection_string.clone();
1836 move |_, _: &menu::Confirm, window, cx| {
1837 remove_ssh_server(
1838 cx.entity(),
1839 index,
1840 connection_string.clone(),
1841 window,
1842 cx,
1843 );
1844 cx.focus_self(window);
1845 }
1846 }))
1847 .child(
1848 ListItem::new("remove-server")
1849 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
1850 .inset(true)
1851 .spacing(ui::ListItemSpacing::Sparse)
1852 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
1853 .child(Label::new("Remove Server").color(Color::Error))
1854 .on_click(cx.listener(move |_, _, window, cx| {
1855 remove_ssh_server(
1856 cx.entity(),
1857 index,
1858 connection_string.clone(),
1859 window,
1860 cx,
1861 );
1862 cx.focus_self(window);
1863 })),
1864 )
1865 })
1866 }
1867
1868 fn render_edit_nickname(
1869 &self,
1870 state: &EditNicknameState,
1871 window: &mut Window,
1872 cx: &mut Context<Self>,
1873 ) -> impl IntoElement {
1874 let Some(connection) = SshSettings::get_global(cx)
1875 .ssh_connections()
1876 .nth(state.index.0)
1877 else {
1878 return v_flex()
1879 .id("ssh-edit-nickname")
1880 .track_focus(&self.focus_handle(cx));
1881 };
1882
1883 let connection_string = connection.host.clone();
1884 let nickname = connection.nickname.map(|s| s.into());
1885
1886 v_flex()
1887 .id("ssh-edit-nickname")
1888 .track_focus(&self.focus_handle(cx))
1889 .child(
1890 SshConnectionHeader {
1891 connection_string,
1892 paths: Default::default(),
1893 nickname,
1894 is_wsl: false,
1895 }
1896 .render(window, cx),
1897 )
1898 .child(
1899 h_flex()
1900 .p_2()
1901 .border_t_1()
1902 .border_color(cx.theme().colors().border_variant)
1903 .child(state.editor.clone()),
1904 )
1905 }
1906
1907 fn render_default(
1908 &mut self,
1909 mut state: DefaultState,
1910 window: &mut Window,
1911 cx: &mut Context<Self>,
1912 ) -> impl IntoElement {
1913 let ssh_settings = SshSettings::get_global(cx);
1914 let mut should_rebuild = false;
1915
1916 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
1917 .servers
1918 .iter()
1919 .filter_map(|server| match server {
1920 RemoteEntry::Project {
1921 connection: Connection::Ssh(connection),
1922 ..
1923 } => Some(connection),
1924 _ => None,
1925 }));
1926
1927 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
1928 .servers
1929 .iter()
1930 .filter_map(|server| match server {
1931 RemoteEntry::Project {
1932 connection: Connection::Wsl(connection),
1933 ..
1934 } => Some(connection),
1935 _ => None,
1936 }));
1937
1938 if ssh_connections_changed || wsl_connections_changed {
1939 should_rebuild = true;
1940 };
1941
1942 if !should_rebuild && ssh_settings.read_ssh_config {
1943 let current_ssh_hosts: BTreeSet<SharedString> = state
1944 .servers
1945 .iter()
1946 .filter_map(|server| match server {
1947 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
1948 _ => None,
1949 })
1950 .collect();
1951 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
1952 for server in &state.servers {
1953 if let RemoteEntry::Project {
1954 connection: Connection::Ssh(connection),
1955 ..
1956 } = server
1957 {
1958 expected_ssh_hosts.remove(&connection.host);
1959 }
1960 }
1961 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
1962 }
1963
1964 if should_rebuild {
1965 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1966 if let Mode::Default(new_state) = &self.mode {
1967 state = new_state.clone();
1968 }
1969 }
1970
1971 let connect_button = div()
1972 .id("ssh-connect-new-server-container")
1973 .track_focus(&state.add_new_server.focus_handle)
1974 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
1975 .child(
1976 ListItem::new("register-remove-server-button")
1977 .toggle_state(
1978 state
1979 .add_new_server
1980 .focus_handle
1981 .contains_focused(window, cx),
1982 )
1983 .inset(true)
1984 .spacing(ui::ListItemSpacing::Sparse)
1985 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1986 .child(Label::new("Connect New Server"))
1987 .on_click(cx.listener(|this, _, window, cx| {
1988 let state = CreateRemoteServer::new(window, cx);
1989 this.mode = Mode::CreateRemoteServer(state);
1990
1991 cx.notify();
1992 })),
1993 )
1994 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1995 let state = CreateRemoteServer::new(window, cx);
1996 this.mode = Mode::CreateRemoteServer(state);
1997
1998 cx.notify();
1999 }));
2000
2001 #[cfg(target_os = "windows")]
2002 let wsl_connect_button = div()
2003 .id("wsl-connect-new-server")
2004 .track_focus(&state.add_new_wsl.focus_handle)
2005 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2006 .child(
2007 ListItem::new("wsl-add-new-server")
2008 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2009 .inset(true)
2010 .spacing(ui::ListItemSpacing::Sparse)
2011 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2012 .child(Label::new("Add WSL Distro"))
2013 .on_click(cx.listener(|this, _, window, cx| {
2014 let state = AddWslDistro::new(window, cx);
2015 this.mode = Mode::AddWslDistro(state);
2016
2017 cx.notify();
2018 })),
2019 )
2020 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2021 let state = AddWslDistro::new(window, cx);
2022 this.mode = Mode::AddWslDistro(state);
2023
2024 cx.notify();
2025 }));
2026
2027 let modal_section = v_flex()
2028 .track_focus(&self.focus_handle(cx))
2029 .id("ssh-server-list")
2030 .overflow_y_scroll()
2031 .track_scroll(&state.scroll_handle)
2032 .size_full()
2033 .child(connect_button);
2034
2035 #[cfg(target_os = "windows")]
2036 let modal_section = modal_section.child(wsl_connect_button);
2037 #[cfg(not(target_os = "windows"))]
2038 let modal_section = modal_section;
2039
2040 let mut modal_section = Navigable::new(
2041 modal_section
2042 .child(
2043 List::new()
2044 .empty_message(
2045 v_flex()
2046 .child(
2047 div().px_3().child(
2048 Label::new("No remote servers registered yet.")
2049 .color(Color::Muted),
2050 ),
2051 )
2052 .into_any_element(),
2053 )
2054 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2055 self.render_ssh_connection(ix, connection.clone(), window, cx)
2056 .into_any_element()
2057 })),
2058 )
2059 .into_any_element(),
2060 )
2061 .entry(state.add_new_server.clone())
2062 .entry(state.add_new_wsl.clone());
2063
2064 for server in &state.servers {
2065 match server {
2066 RemoteEntry::Project {
2067 open_folder,
2068 projects,
2069 configure,
2070 ..
2071 } => {
2072 for (navigation_state, _) in projects {
2073 modal_section = modal_section.entry(navigation_state.clone());
2074 }
2075 modal_section = modal_section
2076 .entry(open_folder.clone())
2077 .entry(configure.clone());
2078 }
2079 RemoteEntry::SshConfig { open_folder, .. } => {
2080 modal_section = modal_section.entry(open_folder.clone());
2081 }
2082 }
2083 }
2084 let mut modal_section = modal_section.render(window, cx).into_any_element();
2085
2086 let (create_window, reuse_window) = if self.create_new_window {
2087 (
2088 window.keystroke_text_for(&menu::Confirm),
2089 window.keystroke_text_for(&menu::SecondaryConfirm),
2090 )
2091 } else {
2092 (
2093 window.keystroke_text_for(&menu::SecondaryConfirm),
2094 window.keystroke_text_for(&menu::Confirm),
2095 )
2096 };
2097 let placeholder_text = Arc::from(format!(
2098 "{reuse_window} reuses this window, {create_window} opens a new one",
2099 ));
2100
2101 Modal::new("remote-projects", None)
2102 .header(
2103 ModalHeader::new()
2104 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2105 .child(
2106 Label::new(placeholder_text)
2107 .color(Color::Muted)
2108 .size(LabelSize::XSmall),
2109 ),
2110 )
2111 .section(
2112 Section::new().padded(false).child(
2113 v_flex()
2114 .min_h(rems(20.))
2115 .size_full()
2116 .relative()
2117 .child(ListSeparator)
2118 .child(
2119 canvas(
2120 |bounds, window, cx| {
2121 modal_section.prepaint_as_root(
2122 bounds.origin,
2123 bounds.size.into(),
2124 window,
2125 cx,
2126 );
2127 modal_section
2128 },
2129 |_, mut modal_section, window, cx| {
2130 modal_section.paint(window, cx);
2131 },
2132 )
2133 .size_full(),
2134 )
2135 .vertical_scrollbar_for(state.scroll_handle, window, cx),
2136 ),
2137 )
2138 .into_any_element()
2139 }
2140
2141 fn create_host_from_ssh_config(
2142 &mut self,
2143 ssh_config_host: &SharedString,
2144 cx: &mut Context<'_, Self>,
2145 ) -> SshServerIndex {
2146 let new_ix = Arc::new(AtomicUsize::new(0));
2147
2148 let update_new_ix = new_ix.clone();
2149 self.update_settings_file(cx, move |settings, _| {
2150 update_new_ix.store(
2151 settings
2152 .ssh_connections
2153 .as_ref()
2154 .map_or(0, |connections| connections.len()),
2155 atomic::Ordering::Release,
2156 );
2157 });
2158
2159 self.add_ssh_server(
2160 SshConnectionOptions {
2161 host: ssh_config_host.to_string(),
2162 ..SshConnectionOptions::default()
2163 },
2164 cx,
2165 );
2166 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2167 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2168 }
2169}
2170
2171fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2172 let mut user_ssh_config_watcher =
2173 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2174 let mut global_ssh_config_watcher = watch_config_file(
2175 cx.background_executor(),
2176 fs,
2177 global_ssh_config_file().to_owned(),
2178 );
2179
2180 cx.spawn(async move |remote_server_projects, cx| {
2181 let mut global_hosts = BTreeSet::default();
2182 let mut user_hosts = BTreeSet::default();
2183 let mut running_receivers = 2;
2184
2185 loop {
2186 select! {
2187 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2188 match new_global_file_contents {
2189 Some(new_global_file_contents) => {
2190 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
2191 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2192 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2193 cx.notify();
2194 }).is_err() {
2195 return;
2196 }
2197 },
2198 None => {
2199 running_receivers -= 1;
2200 if running_receivers == 0 {
2201 return;
2202 }
2203 }
2204 }
2205 },
2206 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2207 match new_user_file_contents {
2208 Some(new_user_file_contents) => {
2209 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2210 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2211 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2212 cx.notify();
2213 }).is_err() {
2214 return;
2215 }
2216 },
2217 None => {
2218 running_receivers -= 1;
2219 if running_receivers == 0 {
2220 return;
2221 }
2222 }
2223 }
2224 },
2225 }
2226 }
2227 })
2228}
2229
2230fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2231 element.read(cx).text(cx).trim().to_string()
2232}
2233
2234impl ModalView for RemoteServerProjects {}
2235
2236impl Focusable for RemoteServerProjects {
2237 fn focus_handle(&self, cx: &App) -> FocusHandle {
2238 match &self.mode {
2239 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2240 _ => self.focus_handle.clone(),
2241 }
2242 }
2243}
2244
2245impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2246
2247impl Render for RemoteServerProjects {
2248 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2249 div()
2250 .elevation_3(cx)
2251 .w(rems(34.))
2252 .key_context("RemoteServerModal")
2253 .on_action(cx.listener(Self::cancel))
2254 .on_action(cx.listener(Self::confirm))
2255 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2256 this.focus_handle(cx).focus(window);
2257 }))
2258 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2259 if matches!(this.mode, Mode::Default(_)) {
2260 cx.emit(DismissEvent)
2261 }
2262 }))
2263 .child(match &self.mode {
2264 Mode::Default(state) => self
2265 .render_default(state.clone(), window, cx)
2266 .into_any_element(),
2267 Mode::ViewServerOptions(state) => self
2268 .render_view_options(state.clone(), window, cx)
2269 .into_any_element(),
2270 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2271 Mode::CreateRemoteServer(state) => self
2272 .render_create_remote_server(state, window, cx)
2273 .into_any_element(),
2274 Mode::EditNickname(state) => self
2275 .render_edit_nickname(state, window, cx)
2276 .into_any_element(),
2277 #[cfg(target_os = "windows")]
2278 Mode::AddWslDistro(state) => self
2279 .render_add_wsl_distro(state, window, cx)
2280 .into_any_element(),
2281 })
2282 }
2283}