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