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