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