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 connection_timeout: connection_options.connection_timeout,
1529 })
1530 });
1531 }
1532
1533 fn edit_in_dev_container_json(&mut self, window: &mut Window, cx: &mut Context<Self>) {
1534 let Some(workspace) = self.workspace.upgrade() else {
1535 cx.emit(DismissEvent);
1536 cx.notify();
1537 return;
1538 };
1539
1540 workspace.update(cx, |workspace, cx| {
1541 let project = workspace.project().clone();
1542
1543 let worktree = project
1544 .read(cx)
1545 .visible_worktrees(cx)
1546 .find_map(|tree| tree.read(cx).root_entry()?.is_dir().then_some(tree));
1547
1548 if let Some(worktree) = worktree {
1549 let tree_id = worktree.read(cx).id();
1550 let devcontainer_path = RelPath::unix(".devcontainer/devcontainer.json").unwrap();
1551 cx.spawn_in(window, async move |workspace, cx| {
1552 workspace
1553 .update_in(cx, |workspace, window, cx| {
1554 workspace.open_path(
1555 (tree_id, devcontainer_path),
1556 None,
1557 true,
1558 window,
1559 cx,
1560 )
1561 })?
1562 .await
1563 })
1564 .detach();
1565 } else {
1566 return;
1567 }
1568 });
1569 cx.emit(DismissEvent);
1570 cx.notify();
1571 }
1572
1573 fn open_dev_container(&self, window: &mut Window, cx: &mut Context<Self>) {
1574 let Some(app_state) = self
1575 .workspace
1576 .read_with(cx, |workspace, _| workspace.app_state().clone())
1577 .log_err()
1578 else {
1579 return;
1580 };
1581
1582 let replace_window = window.window_handle().downcast::<Workspace>();
1583
1584 cx.spawn_in(window, async move |entity, cx| {
1585 let (connection, starting_dir) =
1586 match start_dev_container(cx, app_state.node_runtime.clone()).await {
1587 Ok((c, s)) => (c, s),
1588 Err(e) => {
1589 log::error!("Failed to start dev container: {:?}", e);
1590 entity
1591 .update_in(cx, |remote_server_projects, window, cx| {
1592 remote_server_projects.mode = Mode::CreateRemoteDevContainer(
1593 CreateRemoteDevContainer::new(window, cx).progress(
1594 DevContainerCreationProgress::Error(format!("{:?}", e)),
1595 ),
1596 );
1597 })
1598 .log_err();
1599 return;
1600 }
1601 };
1602 entity
1603 .update(cx, |_, cx| {
1604 cx.emit(DismissEvent);
1605 })
1606 .log_err();
1607
1608 let result = open_remote_project(
1609 connection.into(),
1610 vec![starting_dir].into_iter().map(PathBuf::from).collect(),
1611 app_state,
1612 OpenOptions {
1613 replace_window,
1614 ..OpenOptions::default()
1615 },
1616 cx,
1617 )
1618 .await;
1619 if let Err(e) = result {
1620 log::error!("Failed to connect: {e:#}");
1621 cx.prompt(
1622 gpui::PromptLevel::Critical,
1623 "Failed to connect",
1624 Some(&e.to_string()),
1625 &["Ok"],
1626 )
1627 .await
1628 .ok();
1629 }
1630 })
1631 .detach();
1632 }
1633
1634 fn render_create_dev_container(
1635 &self,
1636 state: &CreateRemoteDevContainer,
1637 window: &mut Window,
1638 cx: &mut Context<Self>,
1639 ) -> impl IntoElement {
1640 match &state.progress {
1641 DevContainerCreationProgress::Error(message) => {
1642 self.focus_handle(cx).focus(window);
1643 return div()
1644 .track_focus(&self.focus_handle(cx))
1645 .size_full()
1646 .child(
1647 v_flex()
1648 .py_1()
1649 .child(
1650 ListItem::new("Error")
1651 .inset(true)
1652 .selectable(false)
1653 .spacing(ui::ListItemSpacing::Sparse)
1654 .start_slot(Icon::new(IconName::XCircle).color(Color::Error))
1655 .child(Label::new("Error Creating Dev Container:"))
1656 .child(Label::new(message).buffer_font(cx)),
1657 )
1658 .child(ListSeparator)
1659 .child(
1660 div()
1661 .id("devcontainer-go-back")
1662 .track_focus(&state.entries[0].focus_handle)
1663 .on_action(cx.listener(
1664 |this, _: &menu::Confirm, window, cx| {
1665 this.mode =
1666 Mode::default_mode(&this.ssh_config_servers, cx);
1667 cx.focus_self(window);
1668 cx.notify();
1669 },
1670 ))
1671 .child(
1672 ListItem::new("li-devcontainer-go-back")
1673 .toggle_state(
1674 state.entries[0]
1675 .focus_handle
1676 .contains_focused(window, cx),
1677 )
1678 .inset(true)
1679 .spacing(ui::ListItemSpacing::Sparse)
1680 .start_slot(
1681 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1682 )
1683 .child(Label::new("Go Back"))
1684 .end_slot(
1685 KeyBinding::for_action_in(
1686 &menu::Cancel,
1687 &self.focus_handle,
1688 cx,
1689 )
1690 .size(rems_from_px(12.)),
1691 )
1692 .on_click(cx.listener(|this, _, window, cx| {
1693 let state =
1694 CreateRemoteDevContainer::new(window, cx);
1695 this.mode = Mode::CreateRemoteDevContainer(state);
1696
1697 cx.notify();
1698 })),
1699 ),
1700 ),
1701 )
1702 .into_any_element();
1703 }
1704 _ => {}
1705 };
1706
1707 let mut view = Navigable::new(
1708 div()
1709 .track_focus(&self.focus_handle(cx))
1710 .size_full()
1711 .child(
1712 v_flex()
1713 .pb_1()
1714 .child(
1715 ModalHeader::new()
1716 .child(Headline::new("Dev Containers").size(HeadlineSize::XSmall)),
1717 )
1718 .child(ListSeparator)
1719 .child(
1720 div()
1721 .id("confirm-create-from-devcontainer-json")
1722 .track_focus(&state.entries[0].focus_handle)
1723 .on_action(cx.listener({
1724 move |this, _: &menu::Confirm, window, cx| {
1725 this.open_dev_container(window, cx);
1726 this.view_in_progress_dev_container(window, cx);
1727 }
1728 }))
1729 .map(|this| {
1730 if state.progress == DevContainerCreationProgress::Creating {
1731 this.child(
1732 ListItem::new("creating")
1733 .inset(true)
1734 .spacing(ui::ListItemSpacing::Sparse)
1735 .disabled(true)
1736 .start_slot(
1737 Icon::new(IconName::ArrowCircle)
1738 .color(Color::Muted)
1739 .with_rotate_animation(2),
1740 )
1741 .child(
1742 h_flex()
1743 .opacity(0.6)
1744 .gap_1()
1745 .child(Label::new("Creating From"))
1746 .child(
1747 Label::new("devcontainer.json")
1748 .buffer_font(cx),
1749 )
1750 .child(LoadingLabel::new("")),
1751 ),
1752 )
1753 } else {
1754 this.child(
1755 ListItem::new(
1756 "li-confirm-create-from-devcontainer-json",
1757 )
1758 .toggle_state(
1759 state.entries[0]
1760 .focus_handle
1761 .contains_focused(window, cx),
1762 )
1763 .inset(true)
1764 .spacing(ui::ListItemSpacing::Sparse)
1765 .start_slot(
1766 Icon::new(IconName::Plus).color(Color::Muted),
1767 )
1768 .child(
1769 h_flex()
1770 .gap_1()
1771 .child(Label::new("Open or Create New From"))
1772 .child(
1773 Label::new("devcontainer.json")
1774 .buffer_font(cx),
1775 ),
1776 )
1777 .on_click(
1778 cx.listener({
1779 move |this, _, window, cx| {
1780 this.open_dev_container(window, cx);
1781 this.view_in_progress_dev_container(
1782 window, cx,
1783 );
1784 cx.notify();
1785 }
1786 }),
1787 ),
1788 )
1789 }
1790 }),
1791 )
1792 .child(
1793 div()
1794 .id("edit-devcontainer-json")
1795 .track_focus(&state.entries[1].focus_handle)
1796 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1797 this.edit_in_dev_container_json(window, cx);
1798 }))
1799 .child(
1800 ListItem::new("li-edit-devcontainer-json")
1801 .toggle_state(
1802 state.entries[1]
1803 .focus_handle
1804 .contains_focused(window, cx),
1805 )
1806 .inset(true)
1807 .spacing(ui::ListItemSpacing::Sparse)
1808 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
1809 .child(
1810 h_flex().gap_1().child(Label::new("Edit")).child(
1811 Label::new("devcontainer.json").buffer_font(cx),
1812 ),
1813 )
1814 .on_click(cx.listener(move |this, _, window, cx| {
1815 this.edit_in_dev_container_json(window, cx);
1816 })),
1817 ),
1818 )
1819 .child(ListSeparator)
1820 .child(
1821 div()
1822 .id("devcontainer-go-back")
1823 .track_focus(&state.entries[2].focus_handle)
1824 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1825 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
1826 cx.focus_self(window);
1827 cx.notify();
1828 }))
1829 .child(
1830 ListItem::new("li-devcontainer-go-back")
1831 .toggle_state(
1832 state.entries[2]
1833 .focus_handle
1834 .contains_focused(window, cx),
1835 )
1836 .inset(true)
1837 .spacing(ui::ListItemSpacing::Sparse)
1838 .start_slot(
1839 Icon::new(IconName::ArrowLeft).color(Color::Muted),
1840 )
1841 .child(Label::new("Go Back"))
1842 .end_slot(
1843 KeyBinding::for_action_in(
1844 &menu::Cancel,
1845 &self.focus_handle,
1846 cx,
1847 )
1848 .size(rems_from_px(12.)),
1849 )
1850 .on_click(cx.listener(|this, _, window, cx| {
1851 this.mode =
1852 Mode::default_mode(&this.ssh_config_servers, cx);
1853 cx.focus_self(window);
1854 cx.notify()
1855 })),
1856 ),
1857 ),
1858 )
1859 .into_any_element(),
1860 );
1861
1862 view = view.entry(state.entries[0].clone());
1863 view = view.entry(state.entries[1].clone());
1864 view = view.entry(state.entries[2].clone());
1865
1866 view.render(window, cx).into_any_element()
1867 }
1868
1869 fn render_create_remote_server(
1870 &self,
1871 state: &CreateRemoteServer,
1872 window: &mut Window,
1873 cx: &mut Context<Self>,
1874 ) -> impl IntoElement {
1875 let ssh_prompt = state.ssh_prompt.clone();
1876
1877 state.address_editor.update(cx, |editor, cx| {
1878 if editor.text(cx).is_empty() {
1879 editor.set_placeholder_text("ssh user@example -p 2222", window, cx);
1880 }
1881 });
1882
1883 let theme = cx.theme();
1884
1885 v_flex()
1886 .track_focus(&self.focus_handle(cx))
1887 .id("create-remote-server")
1888 .overflow_hidden()
1889 .size_full()
1890 .flex_1()
1891 .child(
1892 div()
1893 .p_2()
1894 .border_b_1()
1895 .border_color(theme.colors().border_variant)
1896 .child(state.address_editor.clone()),
1897 )
1898 .child(
1899 h_flex()
1900 .bg(theme.colors().editor_background)
1901 .rounded_b_sm()
1902 .w_full()
1903 .map(|this| {
1904 if let Some(ssh_prompt) = ssh_prompt {
1905 this.child(h_flex().w_full().child(ssh_prompt))
1906 } else if let Some(address_error) = &state.address_error {
1907 this.child(
1908 h_flex().p_2().w_full().gap_2().child(
1909 Label::new(address_error.clone())
1910 .size(LabelSize::Small)
1911 .color(Color::Error),
1912 ),
1913 )
1914 } else {
1915 this.child(
1916 h_flex()
1917 .p_2()
1918 .w_full()
1919 .gap_1()
1920 .child(
1921 Label::new(
1922 "Enter the command you use to SSH into this server.",
1923 )
1924 .color(Color::Muted)
1925 .size(LabelSize::Small),
1926 )
1927 .child(
1928 Button::new("learn-more", "Learn More")
1929 .label_size(LabelSize::Small)
1930 .icon(IconName::ArrowUpRight)
1931 .icon_size(IconSize::XSmall)
1932 .on_click(|_, _, cx| {
1933 cx.open_url(
1934 "https://zed.dev/docs/remote-development",
1935 );
1936 }),
1937 ),
1938 )
1939 }
1940 }),
1941 )
1942 }
1943
1944 #[cfg(target_os = "windows")]
1945 fn render_add_wsl_distro(
1946 &self,
1947 state: &AddWslDistro,
1948 window: &mut Window,
1949 cx: &mut Context<Self>,
1950 ) -> impl IntoElement {
1951 let connection_prompt = state.connection_prompt.clone();
1952
1953 state.picker.update(cx, |picker, cx| {
1954 picker.focus_handle(cx).focus(window);
1955 });
1956
1957 v_flex()
1958 .id("add-wsl-distro")
1959 .overflow_hidden()
1960 .size_full()
1961 .flex_1()
1962 .map(|this| {
1963 if let Some(connection_prompt) = connection_prompt {
1964 this.child(connection_prompt)
1965 } else {
1966 this.child(state.picker.clone())
1967 }
1968 })
1969 }
1970
1971 fn render_view_options(
1972 &mut self,
1973 options: ViewServerOptionsState,
1974 window: &mut Window,
1975 cx: &mut Context<Self>,
1976 ) -> impl IntoElement {
1977 let last_entry = options.entries().last().unwrap();
1978
1979 let mut view = Navigable::new(
1980 div()
1981 .track_focus(&self.focus_handle(cx))
1982 .size_full()
1983 .child(match &options {
1984 ViewServerOptionsState::Ssh { connection, .. } => SshConnectionHeader {
1985 connection_string: connection.host.clone().into(),
1986 paths: Default::default(),
1987 nickname: connection.nickname.clone().map(|s| s.into()),
1988 is_wsl: false,
1989 is_devcontainer: false,
1990 }
1991 .render(window, cx)
1992 .into_any_element(),
1993 ViewServerOptionsState::Wsl { connection, .. } => SshConnectionHeader {
1994 connection_string: connection.distro_name.clone().into(),
1995 paths: Default::default(),
1996 nickname: None,
1997 is_wsl: true,
1998 is_devcontainer: false,
1999 }
2000 .render(window, cx)
2001 .into_any_element(),
2002 })
2003 .child(
2004 v_flex()
2005 .pb_1()
2006 .child(ListSeparator)
2007 .map(|this| match &options {
2008 ViewServerOptionsState::Ssh {
2009 connection,
2010 entries,
2011 server_index,
2012 } => this.child(self.render_edit_ssh(
2013 connection,
2014 *server_index,
2015 entries,
2016 window,
2017 cx,
2018 )),
2019 ViewServerOptionsState::Wsl {
2020 connection,
2021 entries,
2022 server_index,
2023 } => this.child(self.render_edit_wsl(
2024 connection,
2025 *server_index,
2026 entries,
2027 window,
2028 cx,
2029 )),
2030 })
2031 .child(ListSeparator)
2032 .child({
2033 div()
2034 .id("ssh-options-copy-server-address")
2035 .track_focus(&last_entry.focus_handle)
2036 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2037 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2038 cx.focus_self(window);
2039 cx.notify();
2040 }))
2041 .child(
2042 ListItem::new("go-back")
2043 .toggle_state(
2044 last_entry.focus_handle.contains_focused(window, cx),
2045 )
2046 .inset(true)
2047 .spacing(ui::ListItemSpacing::Sparse)
2048 .start_slot(
2049 Icon::new(IconName::ArrowLeft).color(Color::Muted),
2050 )
2051 .child(Label::new("Go Back"))
2052 .on_click(cx.listener(|this, _, window, cx| {
2053 this.mode =
2054 Mode::default_mode(&this.ssh_config_servers, cx);
2055 cx.focus_self(window);
2056 cx.notify()
2057 })),
2058 )
2059 }),
2060 )
2061 .into_any_element(),
2062 );
2063
2064 for entry in options.entries() {
2065 view = view.entry(entry.clone());
2066 }
2067
2068 view.render(window, cx).into_any_element()
2069 }
2070
2071 fn render_edit_wsl(
2072 &self,
2073 connection: &WslConnectionOptions,
2074 index: WslServerIndex,
2075 entries: &[NavigableEntry],
2076 window: &mut Window,
2077 cx: &mut Context<Self>,
2078 ) -> impl IntoElement {
2079 let distro_name = SharedString::new(connection.distro_name.clone());
2080
2081 v_flex().child({
2082 fn remove_wsl_distro(
2083 remote_servers: Entity<RemoteServerProjects>,
2084 index: WslServerIndex,
2085 distro_name: SharedString,
2086 window: &mut Window,
2087 cx: &mut App,
2088 ) {
2089 let prompt_message = format!("Remove WSL distro `{}`?", distro_name);
2090
2091 let confirmation = window.prompt(
2092 PromptLevel::Warning,
2093 &prompt_message,
2094 None,
2095 &["Yes, remove it", "No, keep it"],
2096 cx,
2097 );
2098
2099 cx.spawn(async move |cx| {
2100 if confirmation.await.ok() == Some(0) {
2101 remote_servers
2102 .update(cx, |this, cx| {
2103 this.delete_wsl_distro(index, cx);
2104 })
2105 .ok();
2106 remote_servers
2107 .update(cx, |this, cx| {
2108 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2109 cx.notify();
2110 })
2111 .ok();
2112 }
2113 anyhow::Ok(())
2114 })
2115 .detach_and_log_err(cx);
2116 }
2117 div()
2118 .id("wsl-options-remove-distro")
2119 .track_focus(&entries[0].focus_handle)
2120 .on_action(cx.listener({
2121 let distro_name = distro_name.clone();
2122 move |_, _: &menu::Confirm, window, cx| {
2123 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2124 cx.focus_self(window);
2125 }
2126 }))
2127 .child(
2128 ListItem::new("remove-distro")
2129 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2130 .inset(true)
2131 .spacing(ui::ListItemSpacing::Sparse)
2132 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2133 .child(Label::new("Remove Distro").color(Color::Error))
2134 .on_click(cx.listener(move |_, _, window, cx| {
2135 remove_wsl_distro(cx.entity(), index, distro_name.clone(), window, cx);
2136 cx.focus_self(window);
2137 })),
2138 )
2139 })
2140 }
2141
2142 fn render_edit_ssh(
2143 &self,
2144 connection: &SshConnectionOptions,
2145 index: SshServerIndex,
2146 entries: &[NavigableEntry],
2147 window: &mut Window,
2148 cx: &mut Context<Self>,
2149 ) -> impl IntoElement {
2150 let connection_string = SharedString::new(connection.host.clone());
2151
2152 v_flex()
2153 .child({
2154 let label = if connection.nickname.is_some() {
2155 "Edit Nickname"
2156 } else {
2157 "Add Nickname to Server"
2158 };
2159 div()
2160 .id("ssh-options-add-nickname")
2161 .track_focus(&entries[0].focus_handle)
2162 .on_action(cx.listener(move |this, _: &menu::Confirm, window, cx| {
2163 this.mode = Mode::EditNickname(EditNicknameState::new(index, window, cx));
2164 cx.notify();
2165 }))
2166 .child(
2167 ListItem::new("add-nickname")
2168 .toggle_state(entries[0].focus_handle.contains_focused(window, cx))
2169 .inset(true)
2170 .spacing(ui::ListItemSpacing::Sparse)
2171 .start_slot(Icon::new(IconName::Pencil).color(Color::Muted))
2172 .child(Label::new(label))
2173 .on_click(cx.listener(move |this, _, window, cx| {
2174 this.mode =
2175 Mode::EditNickname(EditNicknameState::new(index, window, cx));
2176 cx.notify();
2177 })),
2178 )
2179 })
2180 .child({
2181 let workspace = self.workspace.clone();
2182 fn callback(
2183 workspace: WeakEntity<Workspace>,
2184 connection_string: SharedString,
2185 cx: &mut App,
2186 ) {
2187 cx.write_to_clipboard(ClipboardItem::new_string(connection_string.to_string()));
2188 workspace
2189 .update(cx, |this, cx| {
2190 struct SshServerAddressCopiedToClipboard;
2191 let notification = format!(
2192 "Copied server address ({}) to clipboard",
2193 connection_string
2194 );
2195
2196 this.show_toast(
2197 Toast::new(
2198 NotificationId::composite::<SshServerAddressCopiedToClipboard>(
2199 connection_string.clone(),
2200 ),
2201 notification,
2202 )
2203 .autohide(),
2204 cx,
2205 );
2206 })
2207 .ok();
2208 }
2209 div()
2210 .id("ssh-options-copy-server-address")
2211 .track_focus(&entries[1].focus_handle)
2212 .on_action({
2213 let connection_string = connection_string.clone();
2214 let workspace = self.workspace.clone();
2215 move |_: &menu::Confirm, _, cx| {
2216 callback(workspace.clone(), connection_string.clone(), cx);
2217 }
2218 })
2219 .child(
2220 ListItem::new("copy-server-address")
2221 .toggle_state(entries[1].focus_handle.contains_focused(window, cx))
2222 .inset(true)
2223 .spacing(ui::ListItemSpacing::Sparse)
2224 .start_slot(Icon::new(IconName::Copy).color(Color::Muted))
2225 .child(Label::new("Copy Server Address"))
2226 .end_hover_slot(
2227 Label::new(connection_string.clone()).color(Color::Muted),
2228 )
2229 .on_click({
2230 let connection_string = connection_string.clone();
2231 move |_, _, cx| {
2232 callback(workspace.clone(), connection_string.clone(), cx);
2233 }
2234 }),
2235 )
2236 })
2237 .child({
2238 fn remove_ssh_server(
2239 remote_servers: Entity<RemoteServerProjects>,
2240 index: SshServerIndex,
2241 connection_string: SharedString,
2242 window: &mut Window,
2243 cx: &mut App,
2244 ) {
2245 let prompt_message = format!("Remove server `{}`?", connection_string);
2246
2247 let confirmation = window.prompt(
2248 PromptLevel::Warning,
2249 &prompt_message,
2250 None,
2251 &["Yes, remove it", "No, keep it"],
2252 cx,
2253 );
2254
2255 cx.spawn(async move |cx| {
2256 if confirmation.await.ok() == Some(0) {
2257 remote_servers
2258 .update(cx, |this, cx| {
2259 this.delete_ssh_server(index, cx);
2260 })
2261 .ok();
2262 remote_servers
2263 .update(cx, |this, cx| {
2264 this.mode = Mode::default_mode(&this.ssh_config_servers, cx);
2265 cx.notify();
2266 })
2267 .ok();
2268 }
2269 anyhow::Ok(())
2270 })
2271 .detach_and_log_err(cx);
2272 }
2273 div()
2274 .id("ssh-options-copy-server-address")
2275 .track_focus(&entries[2].focus_handle)
2276 .on_action(cx.listener({
2277 let connection_string = connection_string.clone();
2278 move |_, _: &menu::Confirm, window, cx| {
2279 remove_ssh_server(
2280 cx.entity(),
2281 index,
2282 connection_string.clone(),
2283 window,
2284 cx,
2285 );
2286 cx.focus_self(window);
2287 }
2288 }))
2289 .child(
2290 ListItem::new("remove-server")
2291 .toggle_state(entries[2].focus_handle.contains_focused(window, cx))
2292 .inset(true)
2293 .spacing(ui::ListItemSpacing::Sparse)
2294 .start_slot(Icon::new(IconName::Trash).color(Color::Error))
2295 .child(Label::new("Remove Server").color(Color::Error))
2296 .on_click(cx.listener(move |_, _, window, cx| {
2297 remove_ssh_server(
2298 cx.entity(),
2299 index,
2300 connection_string.clone(),
2301 window,
2302 cx,
2303 );
2304 cx.focus_self(window);
2305 })),
2306 )
2307 })
2308 }
2309
2310 fn render_edit_nickname(
2311 &self,
2312 state: &EditNicknameState,
2313 window: &mut Window,
2314 cx: &mut Context<Self>,
2315 ) -> impl IntoElement {
2316 let Some(connection) = SshSettings::get_global(cx)
2317 .ssh_connections()
2318 .nth(state.index.0)
2319 else {
2320 return v_flex()
2321 .id("ssh-edit-nickname")
2322 .track_focus(&self.focus_handle(cx));
2323 };
2324
2325 let connection_string = connection.host.clone();
2326 let nickname = connection.nickname.map(|s| s.into());
2327
2328 v_flex()
2329 .id("ssh-edit-nickname")
2330 .track_focus(&self.focus_handle(cx))
2331 .child(
2332 SshConnectionHeader {
2333 connection_string,
2334 paths: Default::default(),
2335 nickname,
2336 is_wsl: false,
2337 is_devcontainer: false,
2338 }
2339 .render(window, cx),
2340 )
2341 .child(
2342 h_flex()
2343 .p_2()
2344 .border_t_1()
2345 .border_color(cx.theme().colors().border_variant)
2346 .child(state.editor.clone()),
2347 )
2348 }
2349
2350 fn render_default(
2351 &mut self,
2352 mut state: DefaultState,
2353 window: &mut Window,
2354 cx: &mut Context<Self>,
2355 ) -> impl IntoElement {
2356 let ssh_settings = SshSettings::get_global(cx);
2357 let mut should_rebuild = false;
2358
2359 let ssh_connections_changed = ssh_settings.ssh_connections.0.iter().ne(state
2360 .servers
2361 .iter()
2362 .filter_map(|server| match server {
2363 RemoteEntry::Project {
2364 connection: Connection::Ssh(connection),
2365 ..
2366 } => Some(connection),
2367 _ => None,
2368 }));
2369
2370 let wsl_connections_changed = ssh_settings.wsl_connections.0.iter().ne(state
2371 .servers
2372 .iter()
2373 .filter_map(|server| match server {
2374 RemoteEntry::Project {
2375 connection: Connection::Wsl(connection),
2376 ..
2377 } => Some(connection),
2378 _ => None,
2379 }));
2380
2381 if ssh_connections_changed || wsl_connections_changed {
2382 should_rebuild = true;
2383 };
2384
2385 if !should_rebuild && ssh_settings.read_ssh_config {
2386 let current_ssh_hosts: BTreeSet<SharedString> = state
2387 .servers
2388 .iter()
2389 .filter_map(|server| match server {
2390 RemoteEntry::SshConfig { host, .. } => Some(host.clone()),
2391 _ => None,
2392 })
2393 .collect();
2394 let mut expected_ssh_hosts = self.ssh_config_servers.clone();
2395 for server in &state.servers {
2396 if let RemoteEntry::Project {
2397 connection: Connection::Ssh(connection),
2398 ..
2399 } = server
2400 {
2401 expected_ssh_hosts.remove(&connection.host);
2402 }
2403 }
2404 should_rebuild = current_ssh_hosts != expected_ssh_hosts;
2405 }
2406
2407 if should_rebuild {
2408 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2409 if let Mode::Default(new_state) = &self.mode {
2410 state = new_state.clone();
2411 }
2412 }
2413
2414 let connect_button = div()
2415 .id("ssh-connect-new-server-container")
2416 .track_focus(&state.add_new_server.focus_handle)
2417 .anchor_scroll(state.add_new_server.scroll_anchor.clone())
2418 .child(
2419 ListItem::new("register-remote-server-button")
2420 .toggle_state(
2421 state
2422 .add_new_server
2423 .focus_handle
2424 .contains_focused(window, cx),
2425 )
2426 .inset(true)
2427 .spacing(ui::ListItemSpacing::Sparse)
2428 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2429 .child(Label::new("Connect SSH Server"))
2430 .on_click(cx.listener(|this, _, window, cx| {
2431 let state = CreateRemoteServer::new(window, cx);
2432 this.mode = Mode::CreateRemoteServer(state);
2433
2434 cx.notify();
2435 })),
2436 )
2437 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2438 let state = CreateRemoteServer::new(window, cx);
2439 this.mode = Mode::CreateRemoteServer(state);
2440
2441 cx.notify();
2442 }));
2443
2444 let connect_dev_container_button = div()
2445 .id("connect-new-dev-container")
2446 .track_focus(&state.add_new_devcontainer.focus_handle)
2447 .anchor_scroll(state.add_new_devcontainer.scroll_anchor.clone())
2448 .child(
2449 ListItem::new("register-dev-container-button")
2450 .toggle_state(
2451 state
2452 .add_new_devcontainer
2453 .focus_handle
2454 .contains_focused(window, cx),
2455 )
2456 .inset(true)
2457 .spacing(ui::ListItemSpacing::Sparse)
2458 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2459 .child(Label::new("Connect Dev Container"))
2460 .on_click(cx.listener(|this, _, window, cx| {
2461 let state = CreateRemoteDevContainer::new(window, cx);
2462 this.mode = Mode::CreateRemoteDevContainer(state);
2463
2464 cx.notify();
2465 })),
2466 )
2467 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2468 let state = CreateRemoteDevContainer::new(window, cx);
2469 this.mode = Mode::CreateRemoteDevContainer(state);
2470
2471 cx.notify();
2472 }));
2473
2474 #[cfg(target_os = "windows")]
2475 let wsl_connect_button = div()
2476 .id("wsl-connect-new-server")
2477 .track_focus(&state.add_new_wsl.focus_handle)
2478 .anchor_scroll(state.add_new_wsl.scroll_anchor.clone())
2479 .child(
2480 ListItem::new("wsl-add-new-server")
2481 .toggle_state(state.add_new_wsl.focus_handle.contains_focused(window, cx))
2482 .inset(true)
2483 .spacing(ui::ListItemSpacing::Sparse)
2484 .start_slot(Icon::new(IconName::Plus).color(Color::Muted))
2485 .child(Label::new("Add WSL Distro"))
2486 .on_click(cx.listener(|this, _, window, cx| {
2487 let state = AddWslDistro::new(window, cx);
2488 this.mode = Mode::AddWslDistro(state);
2489
2490 cx.notify();
2491 })),
2492 )
2493 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
2494 let state = AddWslDistro::new(window, cx);
2495 this.mode = Mode::AddWslDistro(state);
2496
2497 cx.notify();
2498 }));
2499
2500 let has_open_project = self
2501 .workspace
2502 .upgrade()
2503 .map(|workspace| {
2504 workspace
2505 .read(cx)
2506 .project()
2507 .read(cx)
2508 .visible_worktrees(cx)
2509 .next()
2510 .is_some()
2511 })
2512 .unwrap_or(false);
2513
2514 let modal_section = v_flex()
2515 .track_focus(&self.focus_handle(cx))
2516 .id("ssh-server-list")
2517 .overflow_y_scroll()
2518 .track_scroll(&state.scroll_handle)
2519 .size_full()
2520 .child(connect_button)
2521 .when(has_open_project, |this| {
2522 this.child(connect_dev_container_button)
2523 });
2524
2525 #[cfg(target_os = "windows")]
2526 let modal_section = modal_section.child(wsl_connect_button);
2527 #[cfg(not(target_os = "windows"))]
2528 let modal_section = modal_section;
2529
2530 let mut modal_section = Navigable::new(
2531 modal_section
2532 .child(
2533 List::new()
2534 .empty_message(
2535 h_flex()
2536 .size_full()
2537 .p_2()
2538 .justify_center()
2539 .border_t_1()
2540 .border_color(cx.theme().colors().border_variant)
2541 .child(
2542 Label::new("No remote servers registered yet.")
2543 .color(Color::Muted),
2544 )
2545 .into_any_element(),
2546 )
2547 .children(state.servers.iter().enumerate().map(|(ix, connection)| {
2548 self.render_remote_connection(ix, connection.clone(), window, cx)
2549 .into_any_element()
2550 })),
2551 )
2552 .into_any_element(),
2553 )
2554 .entry(state.add_new_server.clone());
2555
2556 if has_open_project {
2557 modal_section = modal_section.entry(state.add_new_devcontainer.clone());
2558 }
2559
2560 if cfg!(target_os = "windows") {
2561 modal_section = modal_section.entry(state.add_new_wsl.clone());
2562 }
2563
2564 for server in &state.servers {
2565 match server {
2566 RemoteEntry::Project {
2567 open_folder,
2568 projects,
2569 configure,
2570 ..
2571 } => {
2572 for (navigation_state, _) in projects {
2573 modal_section = modal_section.entry(navigation_state.clone());
2574 }
2575 modal_section = modal_section
2576 .entry(open_folder.clone())
2577 .entry(configure.clone());
2578 }
2579 RemoteEntry::SshConfig { open_folder, .. } => {
2580 modal_section = modal_section.entry(open_folder.clone());
2581 }
2582 }
2583 }
2584 let mut modal_section = modal_section.render(window, cx).into_any_element();
2585
2586 let (create_window, reuse_window) = if self.create_new_window {
2587 (
2588 window.keystroke_text_for(&menu::Confirm),
2589 window.keystroke_text_for(&menu::SecondaryConfirm),
2590 )
2591 } else {
2592 (
2593 window.keystroke_text_for(&menu::SecondaryConfirm),
2594 window.keystroke_text_for(&menu::Confirm),
2595 )
2596 };
2597 let placeholder_text = Arc::from(format!(
2598 "{reuse_window} reuses this window, {create_window} opens a new one",
2599 ));
2600
2601 Modal::new("remote-projects", None)
2602 .header(
2603 ModalHeader::new()
2604 .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
2605 .child(
2606 Label::new(placeholder_text)
2607 .color(Color::Muted)
2608 .size(LabelSize::XSmall),
2609 ),
2610 )
2611 .section(
2612 Section::new().padded(false).child(
2613 v_flex()
2614 .min_h(rems(20.))
2615 .size_full()
2616 .relative()
2617 .child(ListSeparator)
2618 .child(
2619 canvas(
2620 |bounds, window, cx| {
2621 modal_section.prepaint_as_root(
2622 bounds.origin,
2623 bounds.size.into(),
2624 window,
2625 cx,
2626 );
2627 modal_section
2628 },
2629 |_, mut modal_section, window, cx| {
2630 modal_section.paint(window, cx);
2631 },
2632 )
2633 .size_full(),
2634 )
2635 .vertical_scrollbar_for(&state.scroll_handle, window, cx),
2636 ),
2637 )
2638 .into_any_element()
2639 }
2640
2641 fn create_host_from_ssh_config(
2642 &mut self,
2643 ssh_config_host: &SharedString,
2644 cx: &mut Context<'_, Self>,
2645 ) -> SshServerIndex {
2646 let new_ix = Arc::new(AtomicUsize::new(0));
2647
2648 let update_new_ix = new_ix.clone();
2649 self.update_settings_file(cx, move |settings, _| {
2650 update_new_ix.store(
2651 settings
2652 .ssh_connections
2653 .as_ref()
2654 .map_or(0, |connections| connections.len()),
2655 atomic::Ordering::Release,
2656 );
2657 });
2658
2659 self.add_ssh_server(
2660 SshConnectionOptions {
2661 host: ssh_config_host.to_string(),
2662 ..SshConnectionOptions::default()
2663 },
2664 cx,
2665 );
2666 self.mode = Mode::default_mode(&self.ssh_config_servers, cx);
2667 SshServerIndex(new_ix.load(atomic::Ordering::Acquire))
2668 }
2669}
2670
2671fn spawn_ssh_config_watch(fs: Arc<dyn Fs>, cx: &Context<RemoteServerProjects>) -> Task<()> {
2672 let mut user_ssh_config_watcher =
2673 watch_config_file(cx.background_executor(), fs.clone(), user_ssh_config_file());
2674 let mut global_ssh_config_watcher = global_ssh_config_file()
2675 .map(|it| watch_config_file(cx.background_executor(), fs, it.to_owned()))
2676 .unwrap_or_else(|| futures::channel::mpsc::unbounded().1);
2677
2678 cx.spawn(async move |remote_server_projects, cx| {
2679 let mut global_hosts = BTreeSet::default();
2680 let mut user_hosts = BTreeSet::default();
2681 let mut running_receivers = 2;
2682
2683 loop {
2684 select! {
2685 new_global_file_contents = global_ssh_config_watcher.next().fuse() => {
2686 match new_global_file_contents {
2687 Some(new_global_file_contents) => {
2688 global_hosts = parse_ssh_config_hosts(&new_global_file_contents);
2689 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2690 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2691 cx.notify();
2692 }).is_err() {
2693 return;
2694 }
2695 },
2696 None => {
2697 running_receivers -= 1;
2698 if running_receivers == 0 {
2699 return;
2700 }
2701 }
2702 }
2703 },
2704 new_user_file_contents = user_ssh_config_watcher.next().fuse() => {
2705 match new_user_file_contents {
2706 Some(new_user_file_contents) => {
2707 user_hosts = parse_ssh_config_hosts(&new_user_file_contents);
2708 if remote_server_projects.update(cx, |remote_server_projects, cx| {
2709 remote_server_projects.ssh_config_servers = global_hosts.iter().chain(user_hosts.iter()).map(SharedString::from).collect();
2710 cx.notify();
2711 }).is_err() {
2712 return;
2713 }
2714 },
2715 None => {
2716 running_receivers -= 1;
2717 if running_receivers == 0 {
2718 return;
2719 }
2720 }
2721 }
2722 },
2723 }
2724 }
2725 })
2726}
2727
2728fn get_text(element: &Entity<Editor>, cx: &mut App) -> String {
2729 element.read(cx).text(cx).trim().to_string()
2730}
2731
2732impl ModalView for RemoteServerProjects {}
2733
2734impl Focusable for RemoteServerProjects {
2735 fn focus_handle(&self, cx: &App) -> FocusHandle {
2736 match &self.mode {
2737 Mode::ProjectPicker(picker) => picker.focus_handle(cx),
2738 _ => self.focus_handle.clone(),
2739 }
2740 }
2741}
2742
2743impl EventEmitter<DismissEvent> for RemoteServerProjects {}
2744
2745impl Render for RemoteServerProjects {
2746 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
2747 div()
2748 .elevation_3(cx)
2749 .w(rems(34.))
2750 .key_context("RemoteServerModal")
2751 .on_action(cx.listener(Self::cancel))
2752 .on_action(cx.listener(Self::confirm))
2753 .capture_any_mouse_down(cx.listener(|this, _, window, cx| {
2754 this.focus_handle(cx).focus(window);
2755 }))
2756 .on_mouse_down_out(cx.listener(|this, _, _, cx| {
2757 if matches!(this.mode, Mode::Default(_)) {
2758 cx.emit(DismissEvent)
2759 }
2760 }))
2761 .child(match &self.mode {
2762 Mode::Default(state) => self
2763 .render_default(state.clone(), window, cx)
2764 .into_any_element(),
2765 Mode::ViewServerOptions(state) => self
2766 .render_view_options(state.clone(), window, cx)
2767 .into_any_element(),
2768 Mode::ProjectPicker(element) => element.clone().into_any_element(),
2769 Mode::CreateRemoteServer(state) => self
2770 .render_create_remote_server(state, window, cx)
2771 .into_any_element(),
2772 Mode::CreateRemoteDevContainer(state) => self
2773 .render_create_dev_container(state, window, cx)
2774 .into_any_element(),
2775 Mode::EditNickname(state) => self
2776 .render_edit_nickname(state, window, cx)
2777 .into_any_element(),
2778 #[cfg(target_os = "windows")]
2779 Mode::AddWslDistro(state) => self
2780 .render_add_wsl_distro(state, window, cx)
2781 .into_any_element(),
2782 })
2783 }
2784}