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