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