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