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