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);
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);
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);
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);
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);
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);
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);
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 cx,
1004 ),
1005 )
1006 })?;
1007
1008 let home_dir = project
1009 .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
1010 .await
1011 .and_then(|path| path.into_abs_path())
1012 .map(|path| RemotePathBuf::new(path, path_style))
1013 .unwrap_or_else(|| match path_style {
1014 PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
1015 PathStyle::Windows => {
1016 RemotePathBuf::from_str("C:\\", PathStyle::Windows)
1017 }
1018 });
1019
1020 workspace
1021 .update_in(cx, |workspace, window, cx| {
1022 let weak = cx.entity().downgrade();
1023 workspace.toggle_modal(window, cx, |window, cx| {
1024 RemoteServerProjects::project_picker(
1025 create_new_window,
1026 index,
1027 connection_options,
1028 project,
1029 home_dir,
1030 window,
1031 cx,
1032 weak,
1033 )
1034 });
1035 })
1036 .ok();
1037 Ok(())
1038 })
1039 .detach();
1040 })
1041 })
1042 }
1043
1044 fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
1045 match &self.mode {
1046 Mode::Default(_) | Mode::ViewServerOptions(_) => {}
1047 Mode::ProjectPicker(_) => {}
1048 Mode::CreateRemoteServer(state) => {
1049 if let Some(prompt) = state.ssh_prompt.as_ref() {
1050 prompt.update(cx, |prompt, cx| {
1051 prompt.confirm(window, cx);
1052 });
1053 return;
1054 }
1055
1056 self.create_ssh_server(state.address_editor.clone(), window, cx);
1057 }
1058 Mode::CreateRemoteDevContainer(_) => {}
1059 Mode::EditNickname(state) => {
1060 let text = Some(state.editor.read(cx).text(cx)).filter(|text| !text.is_empty());
1061 let index = state.index;
1062 self.update_settings_file(cx, move |setting, _| {
1063 if let Some(connections) = setting.ssh_connections.as_mut()
1064 && let Some(connection) = connections.get_mut(index.0)
1065 {
1066 connection.nickname = text;
1067 }
1068 });
1069 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1070 self.focus_handle.focus(window);
1071 }
1072 #[cfg(target_os = "windows")]
1073 Mode::AddWslDistro(state) => {
1074 let delegate = &state.picker.read(cx).delegate;
1075 let distro = delegate.selected_distro().unwrap();
1076 self.connect_wsl_distro(state.picker.clone(), distro, window, cx);
1077 }
1078 }
1079 }
1080
1081 fn cancel(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
1082 match &self.mode {
1083 Mode::Default(_) => cx.emit(DismissEvent),
1084 Mode::CreateRemoteServer(state) if state.ssh_prompt.is_some() => {
1085 let new_state = CreateRemoteServer::new(window, cx);
1086 let old_prompt = state.address_editor.read(cx).text(cx);
1087 new_state.address_editor.update(cx, |this, cx| {
1088 this.set_text(old_prompt, window, cx);
1089 });
1090
1091 self.mode = Mode::CreateRemoteServer(new_state);
1092 cx.notify();
1093 }
1094 _ => {
1095 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
1096 self.focus_handle(cx).focus(window);
1097 cx.notify();
1098 }
1099 }
1100 }
1101
1102 fn render_remote_connection(
1103 &mut self,
1104 ix: usize,
1105 remote_server: RemoteEntry,
1106 window: &mut Window,
1107 cx: &mut Context<Self>,
1108 ) -> impl IntoElement {
1109 let connection = remote_server.connection().into_owned();
1110
1111 let (main_label, aux_label, is_wsl) = match &connection {
1112 Connection::Ssh(connection) => {
1113 if let Some(nickname) = connection.nickname.clone() {
1114 let aux_label = SharedString::from(format!("({})", connection.host));
1115 (nickname.into(), Some(aux_label), false)
1116 } else {
1117 (connection.host.clone(), None, false)
1118 }
1119 }
1120 Connection::Wsl(wsl_connection_options) => {
1121 (wsl_connection_options.distro_name.clone(), None, true)
1122 }
1123 Connection::DevContainer(dev_container_options) => {
1124 (dev_container_options.name.clone(), None, false)
1125 }
1126 };
1127 v_flex()
1128 .w_full()
1129 .child(ListSeparator)
1130 .child(
1131 h_flex()
1132 .group("ssh-server")
1133 .w_full()
1134 .pt_0p5()
1135 .px_3()
1136 .gap_1()
1137 .overflow_hidden()
1138 .child(
1139 h_flex()
1140 .gap_1()
1141 .max_w_96()
1142 .overflow_hidden()
1143 .text_ellipsis()
1144 .when(is_wsl, |this| {
1145 this.child(
1146 Label::new("WSL:")
1147 .size(LabelSize::Small)
1148 .color(Color::Muted),
1149 )
1150 })
1151 .child(
1152 Label::new(main_label)
1153 .size(LabelSize::Small)
1154 .color(Color::Muted),
1155 ),
1156 )
1157 .children(
1158 aux_label.map(|label| {
1159 Label::new(label).size(LabelSize::Small).color(Color::Muted)
1160 }),
1161 ),
1162 )
1163 .child(match &remote_server {
1164 RemoteEntry::Project {
1165 open_folder,
1166 projects,
1167 configure,
1168 connection,
1169 index,
1170 } => {
1171 let index = *index;
1172 List::new()
1173 .empty_message("No projects.")
1174 .children(projects.iter().enumerate().map(|(pix, p)| {
1175 v_flex().gap_0p5().child(self.render_remote_project(
1176 index,
1177 remote_server.clone(),
1178 pix,
1179 p,
1180 window,
1181 cx,
1182 ))
1183 }))
1184 .child(
1185 h_flex()
1186 .id(("new-remote-project-container", ix))
1187 .track_focus(&open_folder.focus_handle)
1188 .anchor_scroll(open_folder.scroll_anchor.clone())
1189 .on_action(cx.listener({
1190 let connection = connection.clone();
1191 move |this, _: &menu::Confirm, window, cx| {
1192 this.create_remote_project(
1193 index,
1194 connection.clone().into(),
1195 window,
1196 cx,
1197 );
1198 }
1199 }))
1200 .child(
1201 ListItem::new(("new-remote-project", ix))
1202 .toggle_state(
1203 open_folder.focus_handle.contains_focused(window, cx),
1204 )
1205 .inset(true)
1206 .spacing(ui::ListItemSpacing::Sparse)
1207 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1208 .child(Label::new("Open Folder"))
1209 .on_click(cx.listener({
1210 let connection = connection.clone();
1211 move |this, _, window, cx| {
1212 this.create_remote_project(
1213 index,
1214 connection.clone().into(),
1215 window,
1216 cx,
1217 );
1218 }
1219 })),
1220 ),
1221 )
1222 .child(
1223 h_flex()
1224 .id(("server-options-container", ix))
1225 .track_focus(&configure.focus_handle)
1226 .anchor_scroll(configure.scroll_anchor.clone())
1227 .on_action(cx.listener({
1228 let connection = connection.clone();
1229 move |this, _: &menu::Confirm, window, cx| {
1230 this.view_server_options(
1231 (index, connection.clone().into()),
1232 window,
1233 cx,
1234 );
1235 }
1236 }))
1237 .child(
1238 ListItem::new(("server-options", ix))
1239 .toggle_state(
1240 configure.focus_handle.contains_focused(window, cx),
1241 )
1242 .inset(true)
1243 .spacing(ui::ListItemSpacing::Sparse)
1244 .start_slot(
1245 Icon::new(IconName::Settings).color(Color::Muted),
1246 )
1247 .child(Label::new("View Server Options"))
1248 .on_click(cx.listener({
1249 let ssh_connection = connection.clone();
1250 move |this, _, window, cx| {
1251 this.view_server_options(
1252 (index, ssh_connection.clone().into()),
1253 window,
1254 cx,
1255 );
1256 }
1257 })),
1258 ),
1259 )
1260 }
1261 RemoteEntry::SshConfig { open_folder, host } => List::new().child(
1262 h_flex()
1263 .id(("new-remote-project-container", ix))
1264 .track_focus(&open_folder.focus_handle)
1265 .anchor_scroll(open_folder.scroll_anchor.clone())
1266 .on_action(cx.listener({
1267 let connection = connection.clone();
1268 let host = host.clone();
1269 move |this, _: &menu::Confirm, window, cx| {
1270 let new_ix = this.create_host_from_ssh_config(&host, cx);
1271 this.create_remote_project(
1272 new_ix.into(),
1273 connection.clone().into(),
1274 window,
1275 cx,
1276 );
1277 }
1278 }))
1279 .child(
1280 ListItem::new(("new-remote-project", ix))
1281 .toggle_state(open_folder.focus_handle.contains_focused(window, cx))
1282 .inset(true)
1283 .spacing(ui::ListItemSpacing::Sparse)
1284 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
1285 .child(Label::new("Open Folder"))
1286 .on_click(cx.listener({
1287 let host = host.clone();
1288 move |this, _, window, cx| {
1289 let new_ix = this.create_host_from_ssh_config(&host, cx);
1290 this.create_remote_project(
1291 new_ix.into(),
1292 connection.clone().into(),
1293 window,
1294 cx,
1295 );
1296 }
1297 })),
1298 ),
1299 ),
1300 })
1301 }
1302
1303 fn render_remote_project(
1304 &mut self,
1305 server_ix: ServerIndex,
1306 server: RemoteEntry,
1307 ix: usize,
1308 (navigation, project): &(NavigableEntry, RemoteProject),
1309 window: &mut Window,
1310 cx: &mut Context<Self>,
1311 ) -> impl IntoElement {
1312 let create_new_window = self.create_new_window;
1313 let is_from_zed = server.is_from_zed();
1314 let element_id_base = SharedString::from(format!(
1315 "remote-project-{}",
1316 match server_ix {
1317 ServerIndex::Ssh(index) => format!("ssh-{index}"),
1318 ServerIndex::Wsl(index) => format!("wsl-{index}"),
1319 }
1320 ));
1321 let container_element_id_base =
1322 SharedString::from(format!("remote-project-container-{element_id_base}"));
1323
1324 let callback = Rc::new({
1325 let project = project.clone();
1326 move |remote_server_projects: &mut Self,
1327 secondary_confirm: bool,
1328 window: &mut Window,
1329 cx: &mut Context<Self>| {
1330 let Some(app_state) = remote_server_projects
1331 .workspace
1332 .read_with(cx, |workspace, _| workspace.app_state().clone())
1333 .log_err()
1334 else {
1335 return;
1336 };
1337 let project = project.clone();
1338 let server = server.connection().into_owned();
1339 cx.emit(DismissEvent);
1340
1341 let replace_window = match (create_new_window, secondary_confirm) {
1342 (true, false) | (false, true) => None,
1343 (true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
1344 };
1345
1346 cx.spawn_in(window, async move |_, cx| {
1347 let result = open_remote_project(
1348 server.into(),
1349 project.paths.into_iter().map(PathBuf::from).collect(),
1350 app_state,
1351 OpenOptions {
1352 replace_window,
1353 ..OpenOptions::default()
1354 },
1355 cx,
1356 )
1357 .await;
1358 if let Err(e) = result {
1359 log::error!("Failed to connect: {e:#}");
1360 cx.prompt(
1361 gpui::PromptLevel::Critical,
1362 "Failed to connect",
1363 Some(&e.to_string()),
1364 &["Ok"],
1365 )
1366 .await
1367 .ok();
1368 }
1369 })
1370 .detach();
1371 }
1372 });
1373
1374 div()
1375 .id((container_element_id_base, ix))
1376 .track_focus(&navigation.focus_handle)
1377 .anchor_scroll(navigation.scroll_anchor.clone())
1378 .on_action(cx.listener({
1379 let callback = callback.clone();
1380 move |this, _: &menu::Confirm, window, cx| {
1381 callback(this, false, window, cx);
1382 }
1383 }))
1384 .on_action(cx.listener({
1385 let callback = callback.clone();
1386 move |this, _: &menu::SecondaryConfirm, window, cx| {
1387 callback(this, true, window, cx);
1388 }
1389 }))
1390 .child(
1391 ListItem::new((element_id_base, ix))
1392 .toggle_state(navigation.focus_handle.contains_focused(window, cx))
1393 .inset(true)
1394 .spacing(ui::ListItemSpacing::Sparse)
1395 .start_slot(
1396 Icon::new(IconName::Folder)
1397 .color(Color::Muted)
1398 .size(IconSize::Small),
1399 )
1400 .child(Label::new(project.paths.join(", ")))
1401 .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
1402 let secondary_confirm = e.modifiers().platform;
1403 callback(this, secondary_confirm, window, cx)
1404 }))
1405 .when(is_from_zed, |server_list_item| {
1406 server_list_item.end_hover_slot::<AnyElement>(Some(
1407 div()
1408 .mr_2()
1409 .child({
1410 let project = project.clone();
1411 // Right-margin to offset it from the Scrollbar
1412 IconButton::new("remove-remote-project", IconName::Trash)
1413 .icon_size(IconSize::Small)
1414 .shape(IconButtonShape::Square)
1415 .size(ButtonSize::Large)
1416 .tooltip(Tooltip::text("Delete Remote Project"))
1417 .on_click(cx.listener(move |this, _, _, cx| {
1418 this.delete_remote_project(server_ix, &project, cx)
1419 }))
1420 })
1421 .into_any_element(),
1422 ))
1423 }),
1424 )
1425 }
1426
1427 fn update_settings_file(
1428 &mut self,
1429 cx: &mut Context<Self>,
1430 f: impl FnOnce(&mut RemoteSettingsContent, &App) + Send + Sync + 'static,
1431 ) {
1432 let Some(fs) = self
1433 .workspace
1434 .read_with(cx, |workspace, _| workspace.app_state().fs.clone())
1435 .log_err()
1436 else {
1437 return;
1438 };
1439 update_settings_file(fs, cx, move |setting, cx| f(&mut setting.remote, cx));
1440 }
1441
1442 fn delete_ssh_server(&mut self, server: SshServerIndex, cx: &mut Context<Self>) {
1443 self.update_settings_file(cx, move |setting, _| {
1444 if let Some(connections) = setting.ssh_connections.as_mut() {
1445 connections.remove(server.0);
1446 }
1447 });
1448 }
1449
1450 fn delete_remote_project(
1451 &mut self,
1452 server: ServerIndex,
1453 project: &RemoteProject,
1454 cx: &mut Context<Self>,
1455 ) {
1456 match server {
1457 ServerIndex::Ssh(server) => {
1458 self.delete_ssh_project(server, project, cx);
1459 }
1460 ServerIndex::Wsl(server) => {
1461 self.delete_wsl_project(server, project, cx);
1462 }
1463 }
1464 }
1465
1466 fn delete_ssh_project(
1467 &mut self,
1468 server: SshServerIndex,
1469 project: &RemoteProject,
1470 cx: &mut Context<Self>,
1471 ) {
1472 let project = project.clone();
1473 self.update_settings_file(cx, move |setting, _| {
1474 if let Some(server) = setting
1475 .ssh_connections
1476 .as_mut()
1477 .and_then(|connections| connections.get_mut(server.0))
1478 {
1479 server.projects.remove(&project);
1480 }
1481 });
1482 }
1483
1484 fn delete_wsl_project(
1485 &mut self,
1486 server: WslServerIndex,
1487 project: &RemoteProject,
1488 cx: &mut Context<Self>,
1489 ) {
1490 let project = project.clone();
1491 self.update_settings_file(cx, move |setting, _| {
1492 if let Some(server) = setting
1493 .wsl_connections
1494 .as_mut()
1495 .and_then(|connections| connections.get_mut(server.0))
1496 {
1497 server.projects.remove(&project);
1498 }
1499 });
1500 }
1501
1502 fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
1503 self.update_settings_file(cx, move |setting, _| {
1504 if let Some(connections) = setting.wsl_connections.as_mut() {
1505 connections.remove(server.0);
1506 }
1507 });
1508 }
1509
1510 fn add_ssh_server(
1511 &mut self,
1512 connection_options: remote::SshConnectionOptions,
1513 cx: &mut Context<Self>,
1514 ) {
1515 self.update_settings_file(cx, move |setting, _| {
1516 setting
1517 .ssh_connections
1518 .get_or_insert(Default::default())
1519 .push(SshConnection {
1520 host: SharedString::from(connection_options.host),
1521 username: connection_options.username,
1522 port: connection_options.port,
1523 projects: BTreeSet::new(),
1524 nickname: None,
1525 args: connection_options.args.unwrap_or_default(),
1526 upload_binary_over_ssh: None,
1527 port_forwards: connection_options.port_forwards,
1528 })
1529 });
1530 }
1531
1532 fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1533 let Some(workspace) = self.workspace.upgrade() else {
1534 cx.emit(DismissEvent);
1535 cx.notify();
1536 return;
1537 };
1538
1539 workspace.update(cx, |workspace, cx| {
1540 let project = workspace.project().clone();
1541
1542 let worktree = project
1543 .read(cx)
1544 .visible_worktrees(cx)
1545 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
1546
1547 if let Some(worktree) = worktree {
1548 let tree_id = worktree.read(cx).id();
1549 let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
1550 cx.spawn_in(window, async move |workspace, cx| {
1551 workspace
1552 .update_in(cx, |workspace, window, cx| {
1553 workspace.open_path(
1554 (tree_id, devcontainer_path),
1555 None,
1556 true,
1557 window,
1558 cx,
1559 )
1560 })?
1561 .await
1562 })
1563 .detach();
1564 } else {
1565 return;
1566 }
1567 });
1568 cx.emit(DismissEvent);
1569 cx.notify();
1570 }
1571
1572 fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
1573 let Some(app_state) = self
1574 .workspace
1575 .read_with(cx, |workspace, _| workspace.app_state().clone())
1576 .log_err()
1577 else {
1578 return;
1579 };
1580
1581 let replace_window = window.window_handle().downcast::<Workspace>();
1582
1583 cx.spawn_in(window, async move |entity, cx| {
1584 let (connection, starting_dir) =
1585 match start_dev_container(cx, app_state.node_runtime.clone()).await {
1586 Ok((c, s)) => (c, s),
1587 Err(e) => {
1588 log::error!("Failed to start dev container: {:?}", e);
1589 entity
1590 .update_in(cx, |remote_server_projects, window, cx| {
1591 remote_server_projects.mode = Mode::CreateRemoteDevContainer(
1592 CreateRemoteDevContainer::new(window, cx).progress(
1593 DevContainerCreationProgress::Error(format!("{:?}", e)),
1594 ),
1595 );
1596 })
1597 .log_err();
1598 return;
1599 }
1600 };
1601 entity
1602 .update(cx, |_, cx| {
1603 cx.emit(DismissEvent);
1604 })
1605 .log_err();
1606
1607 let result = open_remote_project(
1608 connection.into(),
1609 vec![starting_dir].into_iter().map(PathBuf::from).collect(),
1610 app_state,
1611 OpenOptions {
1612 replace_window,
1613 ..OpenOptions::default()
1614 },
1615 cx,
1616 )
1617 .await;
1618 if let Err(e) = result {
1619 log::error!("Failed to connect: {e:#}");
1620 cx.prompt(
1621 gpui::PromptLevel::Critical,
1622 "Failed to connect",
1623 Some(&e.to_string()),
1624 &["Ok"],
1625 )
1626 .await
1627 .ok();
1628 }
1629 })
1630 .detach();
1631 }
1632
1633 fn render_create_dev_container(
1634 &self,
1635 state: &CreateRemoteDevContainer,
1636 window: &mut Window,
1637 cx: &mut Context<Self>,
1638 ) -> impl IntoElement {
1639 match &state.progress {
1640 DevContainerCreationProgress::Error(message) => {
1641 self.focus_handle(cx).focus(window);
1642 return div()
1643 .track_focus(&self.focus_handle(cx))
1644 .size_full()
1645 .child(
1646 v_flex()
1647 .py_1()
1648 .child(
1649 ListItem::new("Error")
1650 .inset(true)
1651 .selectable(false)
1652 .spacing(ui::ListItemSpacing::Sparse)
1653 .start_slot(Icon::new(IconName::XCircle).color(Color::Error))
1654 .child(Label::new("Error Creating Dev Container:"))
1655 .child(Label::new(message).buffer_font(cx)),
1656 )
1657 .child(ListSeparator)
1658 .child(
1659 div()
1660 .id("devcontainer-go-back")
1661 .track_focus(&state.entries[0].focus_handle)
1662 .on_action(cx.listener(
1663 |this, _: &menu::Confirm, window, cx| {
1664 this.mode =
1665 Mode::default_mode(&this.ssh_config_servers, cx);
1666 cx.focus_self(window);
1667 cx.notify();
1668 },
1669 ))
1670 .child(
1671 ListItem::new("li-devcontainer-go-back")
1672 .toggle_state(
1673 state.entries[0]
1674 .focus_handle
1675 .contains_focused(window, cx),
1676 )
1677 .inset(true)
1678 .spacing(ui::ListItemSpacing::Sparse)
1679 .start_slot(
1680 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1681 )
1682 .child(Label::new("Go Back"))
1683 .end_slot(
1684 KeyBinding::for_action_in(
1685 &menu::Cancel,
1686 &self.focus_handle,
1687 cx,
1688 )
1689 .size(rems_from_px(12.)),
1690 )
1691 .on_click(cx.listener(|this, _, window, cx| {
1692 let state =
1693 CreateRemoteDevContainer::new(window, cx);
1694 this.mode = Mode::CreateRemoteDevContainer(state);
1695
1696 cx.notify();
1697 })),
1698 ),
1699 ),
1700 )
1701 .into_any_element();
1702 }
1703 _ => {}
1704 };
1705
1706 let mut view = Navigable::new(
1707 div()
1708 .track_focus(&self.focus_handle(cx))
1709 .size_full()
1710 .child(
1711 v_flex()
1712 .pb_1()
1713 .child(
1714 ModalHeader::new()
1715 .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
1716 )
1717 .child(ListSeparator)
1718 .child(
1719 div()
1720 .id("confirm-create-from-devcontainer-json")
1721 .track_focus(&state.entries[0].focus_handle)
1722 .on_action(cx.listener({
1723 move |this, _: &menu::Confirm, window, cx| {
1724 this.open_dev_container(window, cx);
1725 this.view_in_progress_dev_container(window, cx);
1726 }
1727 }))
1728 .map(|this| {
1729 if state.progress == DevContainerCreationProgress::Creating {
1730 this.child(
1731 ListItem::new("creating")
1732 .inset(true)
1733 .spacing(ui::ListItemSpacing::Sparse)
1734 .disabled(true)
1735 .start_slot(
1736 Icon::new(IconName::ArrowCircle)
1737 .color(Color::Muted)
1738 .with_rotate_animation(2),
1739 )
1740 .child(
1741 h_flex()
1742 .opacity(0.6)
1743 .gap_1()
1744 .child(Label::new("Creating From"))
1745 .child(
1746 Label::new("devcontainer.json")
1747 .buffer_font(cx),
1748 )
1749 .child(LoadingLabel::new("")),
1750 ),
1751 )
1752 } else {
1753 this.child(
1754 ListItem::new(
1755 "li-confirm-create-from-devcontainer-json",
1756 )
1757 .toggle_state(
1758 state.entries[0]
1759 .focus_handle
1760 .contains_focused(window, cx),
1761 )
1762 .inset(true)
1763 .spacing(ui::ListItemSpacing::Sparse)
1764 .start_slot(
1765 Icon::new(IconName::Plus).color(Color::Muted),
1766 )
1767 .child(
1768 h_flex()
1769 .gap_1()
1770 .child(Label::new("Open or Create New From"))
1771 .child(
1772 Label::new("devcontainer.json")
1773 .buffer_font(cx),
1774 ),
1775 )
1776 .on_click(
1777 cx.listener({
1778 move |this, _, window, cx| {
1779 this.open_dev_container(window, cx);
1780 this.view_in_progress_dev_container(
1781 window, cx,
1782 );
1783 cx.notify();
1784 }
1785 }),
1786 ),
1787 )
1788 }
1789 }),
1790 )
1791 .child(
1792 div()
1793 .id("edit-devcontainer-json")
1794 .track_focus(&state.entries[1].focus_handle)
1795 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1796 this.edit_in_dev_container_json(window, cx);
1797 }))
1798 .child(
1799 ListItem::new("li-edit-devcontainer-json")
1800 .toggle_state(
1801 state.entries[1]
1802 .focus_handle
1803 .contains_focused(window, cx),
1804 )
1805 .inset(true)
1806 .spacing(ui::ListItemSpacing::Sparse)
1807 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1808 .child(
1809 h_flex().gap_1().child(Label::new("Edit")).child(
1810 Label::new("devcontainer.json").buffer_font(cx),
1811 ),
1812 )
1813 .on_click(cx.listener(move |this, _, window, cx| {
1814 this.edit_in_dev_container_json(window, cx);
1815 })),
1816 ),
1817 )
1818 .child(ListSeparator)
1819 .child(
1820 div()
1821 .id("devcontainer-go-back")
1822 .track_focus(&state.entries[2].focus_handle)
1823 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1824 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1825 cx.focus_self(window);
1826 cx.notify();
1827 }))
1828 .child(
1829 ListItem::new("li-devcontainer-go-back")
1830 .toggle_state(
1831 state.entries[2]
1832 .focus_handle
1833 .contains_focused(window, cx),
1834 )
1835 .inset(true)
1836 .spacing(ui::ListItemSpacing::Sparse)
1837 .start_slot(
1838 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1839 )
1840 .child(Label::new("Go Back"))
1841 .end_slot(
1842 KeyBinding::for_action_in(
1843 &menu::Cancel,
1844 &self.focus_handle,
1845 cx,
1846 )
1847 .size(rems_from_px(12.)),
1848 )
1849 .on_click(cx.listener(|this, _, window, cx| {
1850 this.mode =
1851 Mode::default_mode(&this.ssh_config_servers, cx);
1852 cx.focus_self(window);
1853 cx.notify()
1854 })),
1855 ),
1856 ),
1857 )
1858 .into_any_element(),
1859 );
1860
1861 view = view.entry(state.entries[0].clone());
1862 view = view.entry(state.entries[1].clone());
1863 view = view.entry(state.entries[2].clone());
1864
1865 view.render(window, cx).into_any_element()
1866 }
1867
1868 fn render_create_remote_server(
1869 &self,
1870 state: &CreateRemoteServer,
1871 window: &mut Window,
1872 cx: &mut Context<Self>,
1873 ) -> impl IntoElement {
1874 let ssh_prompt = state.ssh_prompt.clone();
1875
1876 state.address_editor.update(cx, |editor, cx| {
1877 if editor.text(cx).is_empty() {
1878 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1879 }
1880 });
1881
1882 let theme = cx.theme();
1883
1884 v_flex()
1885 .track_focus(&self.focus_handle(cx))
1886 .id("create-remote-server")
1887 .overflow_hidden()
1888 .size_full()
1889 .flex_1()
1890 .child(
1891 div()
1892 .p_2()
1893 .border_b_1()
1894 .border_color(theme.colors().border_variant)
1895 .child(state.address_editor.clone()),
1896 )
1897 .child(
1898 h_flex()
1899 .bg(theme.colors().editor_background)
1900 .rounded_b_sm()
1901 .w_full()
1902 .map(|this| {
1903 if let Some(ssh_prompt) = ssh_prompt {
1904 this.child(h_flex().w_full().child(ssh_prompt))
1905 } else if let Some(address_error) = &state.address_error {
1906 this.child(
1907 h_flex().p_2().w_full().gap_2().child(
1908 Label::new(address_error.clone())
1909 .size(LabelSize::Small)
1910 .color(Color::Error),
1911 ),
1912 )
1913 } else {
1914 this.child(
1915 h_flex()
1916 .p_2()
1917 .w_full()
1918 .gap_1()
1919 .child(
1920 Label::new(
1921 "Enter the command you use to SSH into this server.",
1922 )
1923 .color(Color::Muted)
1924 .size(LabelSize::Small),
1925 )
1926 .child(
1927 Button::new("learn-more", "Learn More")
1928 .label_size(LabelSize::Small)
1929 .icon(IconName::ArrowUpRight)
1930 .icon_size(IconSize::XSmall)
1931 .on_click(|_, _, cx| {
1932 cx.open_url(
1933 "https://zed.dev/docs/remote-development",
1934 );
1935 }),
1936 ),
1937 )
1938 }
1939 }),
1940 )
1941 }
1942
1943 #[cfg(target_os = "windows")]
1944 fn render_add_wsl_distro(
1945 &self,
1946 state: &AddWslDistro,
1947 window: &mut Window,
1948 cx: &mut Context<Self>,
1949 ) -> impl IntoElement {
1950 let connection_prompt = state.connection_prompt.clone();
1951
1952 state.picker.update(cx, |picker, cx| {
1953 picker.focus_handle(cx).focus(window);
1954 });
1955
1956 v_flex()
1957 .id("add-wsl-distro")
1958 .overflow_hidden()
1959 .size_full()
1960 .flex_1()
1961 .map(|this| {
1962 if let Some(connection_prompt) = connection_prompt {
1963 this.child(connection_prompt)
1964 } else {
1965 this.child(state.picker.clone())
1966 }
1967 })
1968 }
1969
1970 fn render_view_options(
1971 &mut self,
1972 options: ViewServerOptionsState,
1973 window: &mut Window,
1974 cx: &mut Context<Self>,
1975 ) -> impl IntoElement {
1976 let last_entry = options.entries().last().unwrap();
1977
1978 let mut view = Navigable::new(
1979 div()
1980 .track_focus(&self.focus_handle(cx))
1981 .size_full()
1982 .child(match &options {
1983 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1984 connection_string: connection.host.clone().into(),
1985 paths: Default::default(),
1986 nickname: connection.nickname.clone().map(|s| s.into()),
1987 is_wsl: false,
1988 is_devcontainer: false,
1989 }
1990 .render(window, cx)
1991 .into_any_element(),
1992 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1993 connection_string: connection.distro_name.clone().into(),
1994 paths: Default::default(),
1995 nickname: None,
1996 is_wsl: true,
1997 is_devcontainer: false,
1998 }
1999 .render(window, cx)
2000 .into_any_element(),
2001 })
2002 .child(
2003 v_flex()
2004 .pb_1()
2005 .child(ListSeparator)
2006 .map(|this| match &options {
2007 ViewServerOptionsState::Ssh {
2008 connection,
2009 entries,
2010 server_index,
2011 } => this.child(self.render_edit_ssh(
2012 connection,
2013 *server_index,
2014 entries,
2015 window,
2016 cx,
2017 )),
2018 ViewServerOptionsState::Wsl {
2019 connection,
2020 entries,
2021 server_index,
2022 } => this.child(self.render_edit_wsl(
2023 connection,
2024 *server_index,
2025 entries,
2026 window,
2027 cx,
2028 )),
2029 })
2030 .child(ListSeparator)
2031 .child({
2032 div()
2033 .id("ssh-options-copy-server-address")
2034 .track_focus(&last_entry.focus_handle)
2035 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2036 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2037 cx.focus_self(window);
2038 cx.notify();
2039 }))
2040 .child(
2041 ListItem::new("go-back")
2042 .toggle_state(
2043 last_entry.focus_handle.contains_focused(window, cx),
2044 )
2045 .inset(true)
2046 .spacing(ui::ListItemSpacing::Sparse)
2047 .start_slot(
2048 Icon::new(IconName::ArrowLeft).color(Color::Muted),
2049 )
2050 .child(Label::new("Go Back"))
2051 .on_click(cx.listener(|this, _, window, cx| {
2052 this.mode =
2053 Mode::default_mode(&this.ssh_config_servers, cx);
2054 cx.focus_self(window);
2055 cx.notify()
2056 })),
2057 )
2058 }),
2059 )
2060 .into_any_element(),
2061 );
2062
2063 for entry in options.entries() {
2064 view = view.entry(entry.clone());
2065 }
2066
2067 view.render(window, cx).into_any_element()
2068 }
2069
2070 fn render_edit_wsl(
2071 &self,
2072 connection: &WslConnectionOptions,
2073 index: WslServerIndex,
2074 entries: &[NavigableEntry],
2075 window: &mut Window,
2076 cx: &mut Context<Self>,
2077 ) -> impl IntoElement {
2078 let distro_name = SharedString::new(connection.distro_name.clone());
2079
2080 v_flex().child({
2081 fn remove_wsl_distro(
2082 remote_servers: Entity<RemoteServerProjects>,
2083 index: WslServerIndex,
2084 distro_name: SharedString,
2085 window: &mut Window,
2086 cx: &mut App,
2087 ) {
2088 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
2089
2090 let confirmation = window.prompt(
2091 PromptLevel::Warning,
2092 &prompt_message,
2093 None,
2094 &["Yes, remove it", "No, keep it"],
2095 cx,
2096 );
2097
2098 cx.spawn(async move |cx| {
2099 if confirmation.await.ok() == Some(0) {
2100 remote_servers
2101 .update(cx, |this, cx| {
2102 this.delete_wsl_distro(index, cx);
2103 })
2104 .ok();
2105 remote_servers
2106 .update(cx, |this, cx| {
2107 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2108 cx.notify();
2109 })
2110 .ok();
2111 }
2112 anyhow::Ok(())
2113 })
2114 .detach_and_log_err(cx);
2115 }
2116 div()
2117 .id("wsl-options-remove-distro")
2118 .track_focus(&entries[0].focus_handle)
2119 .on_action(cx.listener({
2120 let distro_name = distro_name.clone();
2121 move |_, _: &menu::Confirm, window, cx| {
2122 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2123 cx.focus_self(window);
2124 }
2125 }))
2126 .child(
2127 ListItem::new("remove-distro")
2128 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2129 .inset(true)
2130 .spacing(ui::ListItemSpacing::Sparse)
2131 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2132 .child(Label::new("Remove Distro").color(Color::Error))
2133 .on_click(cx.listener(move |_, _, window, cx| {
2134 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2135 cx.focus_self(window);
2136 })),
2137 )
2138 })
2139 }
2140
2141 fn render_edit_ssh(
2142 &self,
2143 connection: &SshConnectionOptions,
2144 index: SshServerIndex,
2145 entries: &[NavigableEntry],
2146 window: &mut Window,
2147 cx: &mut Context<Self>,
2148 ) -> impl IntoElement {
2149 let connection_string = SharedString::new(connection.host.clone());
2150
2151 v_flex()
2152 .child({
2153 let label = if connection.nickname.is_some() {
2154 "Edit Nickname"
2155 } else {
2156 "Add Nickname to Server"
2157 };
2158 div()
2159 .id("ssh-options-add-nickname")
2160 .track_focus(&entries[0].focus_handle)
2161 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
2162 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
2163 cx.notify();
2164 }))
2165 .child(
2166 ListItem::new("add-nickname")
2167 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2168 .inset(true)
2169 .spacing(ui::ListItemSpacing::Sparse)
2170 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
2171 .child(Label::new(label))
2172 .on_click(cx.listener(move |this, _, window, cx| {
2173 this.mode =
2174 Mode::EditNickname(EditNicknameState::new(index, window, cx));
2175 cx.notify();
2176 })),
2177 )
2178 })
2179 .child({
2180 let workspace = self.workspace.clone();
2181 fn callback(
2182 workspace: WeakEntity<Workspace>,
2183 connection_string: SharedString,
2184 cx: &mut App,
2185 ) {
2186 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
2187 workspace
2188 .update(cx, |this, cx| {
2189 struct SshServerAddressCopiedToClipboard;
2190 let notification = format!(
2191 "Copied server address ({}) to clipboard",
2192 connection_string
2193 );
2194
2195 this.show_toast(
2196 Toast::new(
2197 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
2198 connection_string.clone(),
2199 ),
2200 notification,
2201 )
2202 .autohide(),
2203 cx,
2204 );
2205 })
2206 .ok();
2207 }
2208 div()
2209 .id("ssh-options-copy-server-address")
2210 .track_focus(&entries[1].focus_handle)
2211 .on_action({
2212 let connection_string = connection_string.clone();
2213 let workspace = self.workspace.clone();
2214 move |_: &menu::Confirm, _, cx| {
2215 callback(workspace.clone(), connection_string.clone(), cx);
2216 }
2217 })
2218 .child(
2219 ListItem::new("copy-server-address")
2220 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
2221 .inset(true)
2222 .spacing(ui::ListItemSpacing::Sparse)
2223 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
2224 .child(Label::new("Copy Server Address"))
2225 .end_hover_slot(
2226 Label::new(connection_string.clone()).color(Color::Muted),
2227 )
2228 .on_click({
2229 let connection_string = connection_string.clone();
2230 move |_, _, cx| {
2231 callback(workspace.clone(), connection_string.clone(), cx);
2232 }
2233 }),
2234 )
2235 })
2236 .child({
2237 fn remove_ssh_server(
2238 remote_servers: Entity<RemoteServerProjects>,
2239 index: SshServerIndex,
2240 connection_string: SharedString,
2241 window: &mut Window,
2242 cx: &mut App,
2243 ) {
2244 let prompt_message = format!("Remove server `{}`?", connection_string);
2245
2246 let confirmation = window.prompt(
2247 PromptLevel::Warning,
2248 &prompt_message,
2249 None,
2250 &["Yes, remove it", "No, keep it"],
2251 cx,
2252 );
2253
2254 cx.spawn(async move |cx| {
2255 if confirmation.await.ok() == Some(0) {
2256 remote_servers
2257 .update(cx, |this, cx| {
2258 this.delete_ssh_server(index, cx);
2259 })
2260 .ok();
2261 remote_servers
2262 .update(cx, |this, cx| {
2263 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2264 cx.notify();
2265 })
2266 .ok();
2267 }
2268 anyhow::Ok(())
2269 })
2270 .detach_and_log_err(cx);
2271 }
2272 div()
2273 .id("ssh-options-copy-server-address")
2274 .track_focus(&entries[2].focus_handle)
2275 .on_action(cx.listener({
2276 let connection_string = connection_string.clone();
2277 move |_, _: &menu::Confirm, window, cx| {
2278 remove_ssh_server(
2279 cx.entity(),
2280 index,
2281 connection_string.clone(),
2282 window,
2283 cx,
2284 );
2285 cx.focus_self(window);
2286 }
2287 }))
2288 .child(
2289 ListItem::new("remove-server")
2290 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
2291 .inset(true)
2292 .spacing(ui::ListItemSpacing::Sparse)
2293 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2294 .child(Label::new("Remove Server").color(Color::Error))
2295 .on_click(cx.listener(move |_, _, window, cx| {
2296 remove_ssh_server(
2297 cx.entity(),
2298 index,
2299 connection_string.clone(),
2300 window,
2301 cx,
2302 );
2303 cx.focus_self(window);
2304 })),
2305 )
2306 })
2307 }
2308
2309 fn render_edit_nickname(
2310 &self,
2311 state: &EditNicknameState,
2312 window: &mut Window,
2313 cx: &mut Context<Self>,
2314 ) -> impl IntoElement {
2315 let Some(connection) = SshSettings::get_global(cx)
2316 .ssh_connections()
2317 .nth(state.index.0)
2318 else {
2319 return v_flex()
2320 .id("ssh-edit-nickname")
2321 .track_focus(&self.focus_handle(cx));
2322 };
2323
2324 let connection_string = connection.host.clone();
2325 let nickname = connection.nickname.map(|s| s.into());
2326
2327 v_flex()
2328 .id("ssh-edit-nickname")
2329 .track_focus(&self.focus_handle(cx))
2330 .child(
2331 SshConnectionHeader {
2332 connection_string,
2333 paths: Default::default(),
2334 nickname,
2335 is_wsl: false,
2336 is_devcontainer: false,
2337 }
2338 .render(window, cx),
2339 )
2340 .child(
2341 h_flex()
2342 .p_2()
2343 .border_t_1()
2344 .border_color(cx.theme().colors().border_variant)
2345 .child(state.editor.clone()),
2346 )
2347 }
2348
2349 fn render_default(
2350 &mut self,
2351 mut state: DefaultState,
2352 window: &mut Window,
2353 cx: &mut Context<Self>,
2354 ) -> impl IntoElement {
2355 let ssh_settings = SshSettings::get_global(cx);
2356 let mut should_rebuild = false;
2357
2358 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
2359 .servers
2360 .iter()
2361 .filter_map(|server| match server {
2362 RemoteEntry::Project {
2363 connection: Connection::Ssh(connection),
2364 ..
2365 } => Some(connection),
2366 _ => None,
2367 }));
2368
2369 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
2370 .servers
2371 .iter()
2372 .filter_map(|server| match server {
2373 RemoteEntry::Project {
2374 connection: Connection::Wsl(connection),
2375 ..
2376 } => Some(connection),
2377 _ => None,
2378 }));
2379
2380 if ssh_connections_changed || wsl_connections_changed {
2381 should_rebuild = true;
2382 };
2383
2384 if !should_rebuild && ssh_settings.read_ssh_config {
2385 let current_ssh_hosts: BTreeSet<SharedString> = state
2386 .servers
2387 .iter()
2388 .filter_map(|server| match server {
2389 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2390 _ => None,
2391 })
2392 .collect();
2393 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2394 for server in &state.servers {
2395 if let RemoteEntry::Project {
2396 connection: Connection::Ssh(connection),
2397 ..
2398 } = server
2399 {
2400 expected_ssh_hosts.remove(&connection.host);
2401 }
2402 }
2403 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2404 }
2405
2406 if should_rebuild {
2407 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2408 if let Mode::Default(new_state) = &self.mode {
2409 state = new_state.clone();
2410 }
2411 }
2412
2413 let connect_button = div()
2414 .id("ssh-connect-new-server-container")
2415 .track_focus(&state.add_new_server.focus_handle)
2416 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2417 .child(
2418 ListItem::new("register-remote-server-button")
2419 .toggle_state(
2420 state
2421 .add_new_server
2422 .focus_handle
2423 .contains_focused(window, cx),
2424 )
2425 .inset(true)
2426 .spacing(ui::ListItemSpacing::Sparse)
2427 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2428 .child(Label::new("Connect SSH Server"))
2429 .on_click(cx.listener(|this, _, window, cx| {
2430 let state = CreateRemoteServer::new(window, cx);
2431 this.mode = Mode::CreateRemoteServer(state);
2432
2433 cx.notify();
2434 })),
2435 )
2436 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2437 let state = CreateRemoteServer::new(window, cx);
2438 this.mode = Mode::CreateRemoteServer(state);
2439
2440 cx.notify();
2441 }));
2442
2443 let connect_dev_container_button = div()
2444 .id("connect-new-dev-container")
2445 .track_focus(&state.add_new_devcontainer.focus_handle)
2446 .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
2447 .child(
2448 ListItem::new("register-dev-container-button")
2449 .toggle_state(
2450 state
2451 .add_new_devcontainer
2452 .focus_handle
2453 .contains_focused(window, cx),
2454 )
2455 .inset(true)
2456 .spacing(ui::ListItemSpacing::Sparse)
2457 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2458 .child(Label::new("Connect Dev Container"))
2459 .on_click(cx.listener(|this, _, window, cx| {
2460 let state = CreateRemoteDevContainer::new(window, cx);
2461 this.mode = Mode::CreateRemoteDevContainer(state);
2462
2463 cx.notify();
2464 })),
2465 )
2466 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2467 let state = CreateRemoteDevContainer::new(window, cx);
2468 this.mode = Mode::CreateRemoteDevContainer(state);
2469
2470 cx.notify();
2471 }));
2472
2473 #[cfg(target_os = "windows")]
2474 let wsl_connect_button = div()
2475 .id("wsl-connect-new-server")
2476 .track_focus(&state.add_new_wsl.focus_handle)
2477 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2478 .child(
2479 ListItem::new("wsl-add-new-server")
2480 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2481 .inset(true)
2482 .spacing(ui::ListItemSpacing::Sparse)
2483 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2484 .child(Label::new("Add WSL Distro"))
2485 .on_click(cx.listener(|this, _, window, cx| {
2486 let state = AddWslDistro::new(window, cx);
2487 this.mode = Mode::AddWslDistro(state);
2488
2489 cx.notify();
2490 })),
2491 )
2492 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2493 let state = AddWslDistro::new(window, cx);
2494 this.mode = Mode::AddWslDistro(state);
2495
2496 cx.notify();
2497 }));
2498
2499 let has_open_project = self
2500 .workspace
2501 .upgrade()
2502 .map(|workspace| {
2503 workspace
2504 .read(cx)
2505 .project()
2506 .read(cx)
2507 .visible_worktrees(cx)
2508 .next()
2509 .is_some()
2510 })
2511 .unwrap_or(false);
2512
2513 let modal_section = v_flex()
2514 .track_focus(&self.focus_handle(cx))
2515 .id("ssh-server-list")
2516 .overflow_y_scroll()
2517 .track_scroll(&state.scroll_handle)
2518 .size_full()
2519 .child(connect_button)
2520 .when(has_open_project, |this| {
2521 this.child(connect_dev_container_button)
2522 });
2523
2524 #[cfg(target_os = "windows")]
2525 let modal_section = modal_section.child(wsl_connect_button);
2526 #[cfg(not(target_os = "windows"))]
2527 let modal_section = modal_section;
2528
2529 let mut modal_section = Navigable::new(
2530 modal_section
2531 .child(
2532 List::new()
2533 .empty_message(
2534 h_flex()
2535 .size_full()
2536 .p_2()
2537 .justify_center()
2538 .border_t_1()
2539 .border_color(cx.theme().colors().border_variant)
2540 .child(
2541 Label::new("No remote servers registered yet.")
2542 .color(Color::Muted),
2543 )
2544 .into_any_element(),
2545 )
2546 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2547 self.render_remote_connection(ix, connection.clone(), window, cx)
2548 .into_any_element()
2549 })),
2550 )
2551 .into_any_element(),
2552 )
2553 .entry(state.add_new_server.clone());
2554
2555 if has_open_project {
2556 modal_section = modal_section.entry(state.add_new_devcontainer.clone());
2557 }
2558
2559 if cfg!(target_os = "windows") {
2560 modal_section = modal_section.entry(state.add_new_wsl.clone());
2561 }
2562
2563 for server in &state.servers {
2564 match server {
2565 RemoteEntry::Project {
2566 open_folder,
2567 projects,
2568 configure,
2569 ..
2570 } => {
2571 for (navigation_state, _) in projects {
2572 modal_section = modal_section.entry(navigation_state.clone());
2573 }
2574 modal_section = modal_section
2575 .entry(open_folder.clone())
2576 .entry(configure.clone());
2577 }
2578 RemoteEntry::SshConfig { open_folder, .. } => {
2579 modal_section = modal_section.entry(open_folder.clone());
2580 }
2581 }
2582 }
2583 let mut modal_section = modal_section.render(window, cx).into_any_element();
2584
2585 let (create_window, reuse_window) = if self.create_new_window {
2586 (
2587 window.keystroke_text_for(&menu::Confirm),
2588 window.keystroke_text_for(&menu::SecondaryConfirm),
2589 )
2590 } else {
2591 (
2592 window.keystroke_text_for(&menu::SecondaryConfirm),
2593 window.keystroke_text_for(&menu::Confirm),
2594 )
2595 };
2596 let placeholder_text = Arc::from(format!(
2597 "{reuse_window} reuses this window, {create_window} opens a new one",
2598 ));
2599
2600 Modal::new("remote-projects", None)
2601 .header(
2602 ModalHeader::new()
2603 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2604 .child(
2605 Label::new(placeholder_text)
2606 .color(Color::Muted)
2607 .size(LabelSize::XSmall),
2608 ),
2609 )
2610 .section(
2611 Section::new().padded(false).child(
2612 v_flex()
2613 .min_h(rems(20.))
2614 .size_full()
2615 .relative()
2616 .child(ListSeparator)
2617 .child(
2618 canvas(
2619 |bounds, window, cx| {
2620 modal_section.prepaint_as_root(
2621 bounds.origin,
2622 bounds.size.into(),
2623 window,
2624 cx,
2625 );
2626 modal_section
2627 },
2628 |_, mut modal_section, window, cx| {
2629 modal_section.paint(window, cx);
2630 },
2631 )
2632 .size_full(),
2633 )
2634 .vertical_scrollbar_for(&state.scroll_handle, window, cx),
2635 ),
2636 )
2637 .into_any_element()
2638 }
2639
2640 fn create_host_from_ssh_config(
2641 &mut self,
2642 ssh_config_host: &SharedString,
2643 cx: &mut Context<'_, Self>,
2644 ) -> SshServerIndex {
2645 let new_ix = Arc::new(AtomicUsize::new(0));
2646
2647 let update_new_ix = new_ix.clone();
2648 self.update_settings_file(cx, move |settings, _| {
2649 update_new_ix.store(
2650 settings
2651 .ssh_connections
2652 .as_ref()
2653 .map_or(0, |connections| connections.len()),
2654 atomic::Ordering::Release,
2655 );
2656 });
2657
2658 self.add_ssh_server(
2659 SshConnectionOptions {
2660 host: ssh_config_host.to_string(),
2661 ..SshConnectionOptions::default()
2662 },
2663 cx,
2664 );
2665 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2666 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2667 }
2668}
2669
2670fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2671 let mut user_ssh_config_watcher =
2672 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2673 let mut global_ssh_config_watcher = global_ssh_config_file()
2674 .map(|it| watch_config_file(cx.background_executor(), fs, it.to_owned()))
2675 .unwrap_or_else(|| futures::channel::mpsc::unbounded().1);
2676
2677 cx.spawn(async move |remote_server_projects, cx| {
2678 let mut global_hosts = BTreeSet::default();
2679 let mut user_hosts = BTreeSet::default();
2680 let mut running_receivers = 2;
2681
2682 loop {
2683 select! {
2684 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2685 match new_global_file_contents {
2686 Some(new_global_file_contents) => {
2687 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
2688 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2689 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2690 cx.notify();
2691 }).is_err() {
2692 return;
2693 }
2694 },
2695 None => {
2696 running_receivers -= 1;
2697 if running_receivers == 0 {
2698 return;
2699 }
2700 }
2701 }
2702 },
2703 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2704 match new_user_file_contents {
2705 Some(new_user_file_contents) => {
2706 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2707 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2708 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2709 cx.notify();
2710 }).is_err() {
2711 return;
2712 }
2713 },
2714 None => {
2715 running_receivers -= 1;
2716 if running_receivers == 0 {
2717 return;
2718 }
2719 }
2720 }
2721 },
2722 }
2723 }
2724 })
2725}
2726
2727fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2728 element.read(cx).text(cx).trim().to_string()
2729}
2730
2731impl ModalView for RemoteServerProjects {}
2732
2733impl Focusable for RemoteServerProjects {
2734 fn focus_handle(&self, cx: &App) -> FocusHandle {
2735 match &self.mode {
2736 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2737 _ => self.focus_handle.clone(),
2738 }
2739 }
2740}
2741
2742impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2743
2744impl Render for RemoteServerProjects {
2745 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2746 div()
2747 .elevation_3(cx)
2748 .w(rems(34.))
2749 .key_context("RemoteServerModal")
2750 .on_action(cx.listener(Self::cancel))
2751 .on_action(cx.listener(Self::confirm))
2752 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2753 this.focus_handle(cx).focus(window);
2754 }))
2755 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2756 if matches!(this.mode, Mode::Default(_)) {
2757 cx.emit(DismissEvent)
2758 }
2759 }))
2760 .child(match &self.mode {
2761 Mode::Default(state) => self
2762 .render_default(state.clone(), window, cx)
2763 .into_any_element(),
2764 Mode::ViewServerOptions(state) => self
2765 .render_view_options(state.clone(), window, cx)
2766 .into_any_element(),
2767 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2768 Mode::CreateRemoteServer(state) => self
2769 .render_create_remote_server(state, window, cx)
2770 .into_any_element(),
2771 Mode::CreateRemoteDevContainer(state) => self
2772 .render_create_dev_container(state, window, cx)
2773 .into_any_element(),
2774 Mode::EditNickname(state) => self
2775 .render_edit_nickname(state, window, cx)
2776 .into_any_element(),
2777 #[cfg(target_os = "windows")]
2778 Mode::AddWslDistro(state) => self
2779 .render_add_wsl_distro(state, window, cx)
2780 .into_any_element(),
2781 })
2782 }
2783}