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