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